From ea6e14eb196c38c5607743768747f26056178e59 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Tue, 4 Jun 2024 17:21:48 -0400 Subject: [PATCH 01/23] Add TypeScript support. Vite does not type check in dev mode, only in build mode. --- .eslintrc.json | 14 +++++++++++--- package.json | 19 +++++++++++++------ src/{App.jsx => App.tsx} | 1 - src/components/Home.jsx | 2 +- src/main.jsx | 2 +- tailwind.config.js | 2 +- tsconfig.json | 24 ++++++++++++++++++++++++ 7 files changed, 51 insertions(+), 13 deletions(-) rename src/{App.jsx => App.tsx} (96%) create mode 100644 tsconfig.json diff --git a/.eslintrc.json b/.eslintrc.json index d800edb..2cacdf9 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -1,11 +1,13 @@ { + "root": true, "env": { "browser": true, "es2021": true }, "extends": [ "eslint:recommended", - "plugin:react/recommended" + "plugin:@typescript-eslint/recommended", + "plugin:react-hooks/recommended" ], "parserOptions": { "ecmaVersion": "latest", @@ -14,9 +16,11 @@ }, "sourceType": "module" }, + "parser": "@typescript-eslint/parser", "plugins": [ "react", - "react-hooks" + "react-hooks", + "react-refresh" ], "rules": { "indent": [ @@ -40,7 +44,11 @@ "indent-legacy": "warn", "react-hooks/rules-of-hooks": "error", // Checks rules of Hooks "react-hooks/exhaustive-deps": "warn", // Checks effect dependencies - "react/prop-types": "off" + "react/prop-types": "off", + "react-refresh/only-export-components": [ + "warn", + { "allowConstantExport": true } + ] }, "settings": { "react": { diff --git a/package.json b/package.json index 949f169..786853c 100644 --- a/package.json +++ b/package.json @@ -8,10 +8,11 @@ "build": "npm run tailwind-build && npm run vite-build ", "preview": "vite preview", "format": "eslint --fix", + "lint": "eslint . --ext ts,tsx,js,jsx --report-unused-disable-directives --max-warnings 0", "tailwind-dev": "tailwindcss -i ./src/styles.css -o ./dist/output.css --watch", "tailwind-build": "tailwindcss -i ./src/styles.css -o ./dist/output.css", - "vite-dev": "npm run wasm-dev && vite dev", - "vite-build": "npm run wasm-build && vite build", + "vite-dev": "npm run wasm-dev && vite", + "vite-build": "npm run wasm-build && tsc && vite build", "wasm-dev": "wasm-pack build -d ../../wasm --target web --dev rezasm-app/rezasm-wasm/", "wasm-build": "wasm-pack build -d ../../wasm --target web rezasm-app/rezasm-wasm/" }, @@ -29,13 +30,19 @@ "devDependencies": { "@babel/core": "^7.22.17", "@tauri-apps/cli": "^1.4.0", - "@vitejs/plugin-react": "^4.0.3", - "eslint": "^8.48.0", "eslint-plugin-react": "^7.33.2", - "eslint-plugin-react-hooks": "^4.6.0", "lodash": "^4.17.21", "npm-run-all": "^4.1.5", "tailwindcss": "^3.3.3", - "vite": "^4.4.4" + "@types/react": "^18.2.66", + "@types/react-dom": "^18.2.22", + "@typescript-eslint/eslint-plugin": "^7.2.0", + "@typescript-eslint/parser": "^7.2.0", + "@vitejs/plugin-react": "^4.2.1", + "eslint": "^8.57.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-plugin-react-refresh": "^0.4.6", + "typescript": "^5.2.2", + "vite": "^5.2.0" } } diff --git a/src/App.jsx b/src/App.tsx similarity index 96% rename from src/App.jsx rename to src/App.tsx index b8322e9..0870fb5 100644 --- a/src/App.jsx +++ b/src/App.tsx @@ -1,4 +1,3 @@ -import React from "react"; import {HashRouter, Route, Routes} from "react-router-dom"; import Code from "./components/Code.jsx"; import Home from "./components/Home.jsx"; diff --git a/src/components/Home.jsx b/src/components/Home.jsx index c3c5041..6d7a0de 100644 --- a/src/components/Home.jsx +++ b/src/components/Home.jsx @@ -1,6 +1,6 @@ import React from "react"; import {useNavigate} from "react-router-dom"; -import {CODE_PATH} from "../App.jsx"; +import {CODE_PATH} from "../App.tsx"; function Home() { diff --git a/src/main.jsx b/src/main.jsx index ec4ee8f..127e62f 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,7 +1,7 @@ import React from "react"; import ReactDOM from "react-dom/client"; import "../dist/output.css"; -import App from "./App.jsx"; +import App from "./App.tsx"; ReactDOM.createRoot(document.getElementById("root")).render( diff --git a/tailwind.config.js b/tailwind.config.js index 7db0e51..1d13c5f 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,7 @@ /** @type {import('tailwindcss').Config} */ export default { content: ["./src/components/*.jsx", - "./src/App.jsx"], + "./src/App.tsx"], theme: { extend: {}, }, diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..f67b3f3 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "jsx": "react-jsx", + "allowJs": true, + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} From 46368ab045bb955f998ae92f0e22c3a77cb553b8 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 7 Jun 2024 18:17:18 -0400 Subject: [PATCH 02/23] Type simulator.js --- src/components/Code.jsx | 2 +- src/components/Console.jsx | 2 +- src/components/Controls.jsx | 2 +- src/components/Editor.jsx | 2 +- src/components/MemoryView.jsx | 2 +- src/components/RegistryView.jsx | 2 +- src/components/{simulator.js => simulator.ts} | 79 +++++++++++-------- 7 files changed, 51 insertions(+), 40 deletions(-) rename src/components/{simulator.js => simulator.ts} (73%) diff --git a/src/components/Code.jsx b/src/components/Code.jsx index b840ca7..6e6a095 100644 --- a/src/components/Code.jsx +++ b/src/components/Code.jsx @@ -7,7 +7,7 @@ import MemoryView from "./MemoryView.jsx"; import Console from "./Console.jsx"; import Controls from "./Controls.jsx"; import Editor from "./Editor.jsx"; -import {useSimulator} from "./simulator.js"; +import {useSimulator} from "./simulator.ts"; function Code() { diff --git a/src/components/Console.jsx b/src/components/Console.jsx index 56f6152..84291e6 100644 --- a/src/components/Console.jsx +++ b/src/components/Console.jsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useReducer, useRef, useState} from "react"; import {listen} from "@tauri-apps/api/event"; -import {CALLBACKS_TRIGGERS, CALLBACK_TYPES, STATE} from "./simulator.js"; +import {CALLBACKS_TRIGGERS, CALLBACK_TYPES, STATE} from "./simulator.ts"; import {RUST} from "../rust_functions.js"; import { debounce } from "lodash"; diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index d6b4567..ec511a5 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -1,5 +1,5 @@ import React from "react"; -import {STATE} from "./simulator.js"; +import {STATE} from "./simulator.ts"; import _ from "lodash"; const debounce = diff --git a/src/components/Editor.jsx b/src/components/Editor.jsx index 89f0332..a01acca 100644 --- a/src/components/Editor.jsx +++ b/src/components/Editor.jsx @@ -1,5 +1,5 @@ import React from "react"; -import {STATE} from "./simulator.js"; +import {STATE} from "./simulator.ts"; function Editor({state, setCode}) { return ( diff --git a/src/components/MemoryView.jsx b/src/components/MemoryView.jsx index b7bd804..7976999 100644 --- a/src/components/MemoryView.jsx +++ b/src/components/MemoryView.jsx @@ -1,6 +1,6 @@ import React, {useCallback, useEffect, useRef, useState} from "react"; import {RUST} from "../rust_functions.js"; -import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.js"; +import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; const WIDTH = 4; const HEIGHT = 4; diff --git a/src/components/RegistryView.jsx b/src/components/RegistryView.jsx index 51105b5..93bc902 100644 --- a/src/components/RegistryView.jsx +++ b/src/components/RegistryView.jsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from "react"; import {RUST} from "../rust_functions.js"; -import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.js"; +import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; function RegistryView({loaded, registerCallback}) { const [registers, setRegisters] = useState([]); diff --git a/src/components/simulator.js b/src/components/simulator.ts similarity index 73% rename from src/components/simulator.js rename to src/components/simulator.ts index 20be245..d51675e 100644 --- a/src/components/simulator.js +++ b/src/components/simulator.ts @@ -1,28 +1,37 @@ import {useCallback, useReducer, useRef, useState} from "react"; import {RUST} from "../rust_functions.js"; -const STATE = { - IDLE: 1, - LOADING: 2, - LOADED: 3, - RUNNING: 4, - PAUSED: 5, - STOPPED: 6, -}; +enum STATE { + IDLE = 1, + LOADING = 2, + LOADED = 3, + RUNNING = 4, + PAUSED = 5, + STOPPED = 6, +} const CALLBACKS_TRIGGERS = { RESET: "RESET", STEP: "STEP" -}; +} as const; + +type ValidCallbackTriggers = keyof typeof CALLBACKS_TRIGGERS; const CALLBACK_TYPES = { CONSOLE: "CONSOLE", MEMORY: "MEMORY", REGISTRY: "REGISTRY", -}; +} as const; + +type ValidCallbackTypes = keyof typeof CALLBACK_TYPES; + -let initialCallbacks = {}; -Object.values(CALLBACKS_TRIGGERS).map(x => initialCallbacks[x] = {}); +type CallbackTriggerObject = Partial unknown>>; +type CallbackObject = Record; +const initialCallbacks: CallbackObject = Object.values(CALLBACKS_TRIGGERS).reduce((callbacks, x) => { + callbacks[x] = {}; + return callbacks; +}, {} as Partial) as CallbackObject; export const useSimulator = () => { const state = useRef(STATE.IDLE); @@ -30,42 +39,42 @@ export const useSimulator = () => { const [exitCode, setExitCode] = useState(""); const [code, setCode] = useState(""); - const timerId = useRef(null); + const timerId = useRef(null); const [instructionDelay, setInstructionDelay] = useState(5); const callbacks = useRef(initialCallbacks); //Still kind of a hack - const [, forceUpdate] = useReducer(() => Date.now()); + const [, forceUpdate] = useReducer(() => Date.now(), 0); - const setState = (newState) => { + const setState = useCallback((newState: STATE) => { state.current = newState; forceUpdate(); - }; + }, []); - const setError = (newError) => { + const setError = useCallback((newError: string) => { error.current = newError; forceUpdate(); - }; + }, []); - const registerCallback = (trigger, type, callback) => { + const registerCallback = (trigger: ValidCallbackTriggers, type: ValidCallbackTypes, callback: () => unknown) => { callbacks.current[trigger][type] = callback; }; - const callStepCallbacks = () => { + const callStepCallbacks = useCallback(() => { Object.values(callbacks.current[CALLBACKS_TRIGGERS.STEP]).map(callback => callback()); - }; + }, [callbacks]); - const callResetCallbacks = () => { + const callResetCallbacks = useCallback(() => { Object.values(callbacks.current[CALLBACKS_TRIGGERS.RESET]).map(callback => callback()); - }; + }, [callbacks]); - const haltExecution = (newState) => { + const haltExecution = useCallback((newState: STATE) => { setState(newState ?? STATE.STOPPED); if (timerId.current !== null) { clearTimeout(timerId.current); timerId.current = null; } - }; + }, [timerId]); const isError = () => { return error.current !== ""; @@ -138,20 +147,20 @@ export const useSimulator = () => { }, [checkProgramCompletion, handleStepCall, load, reset, state]); const stepBack = useCallback(async () => { - if (state.current > STATE.RUNNING || state.current == STATE.AWAITING) { + if (state.current > STATE.RUNNING) { console.log(state.current); RUST.STEP_BACK({}) - .catch((error) => { - setError(error); - setState(STATE.STOPPED) - }) - .finally(() => { - callStepCallbacks(); - }) + .catch((error) => { + setError(error); + setState(STATE.STOPPED); + }) + .finally(() => { + callStepCallbacks(); + }); } } - , [setError, setState, callStepCallbacks]) + , [setError, setState, callStepCallbacks]); const recursiveStep = useCallback(async () => { if (state.current === STATE.STOPPED) { @@ -160,6 +169,8 @@ export const useSimulator = () => { checkProgramCompletion().then(async completed => { if (!completed && state.current === STATE.RUNNING) { handleStepCall().then(() => { + // @ts-expect-error -- It assumes that setTimeout returns a NodeJS.Timeout object, + // which does not exist in the browser timerId.current = setTimeout(recursiveStep, instructionDelay); }).catch((e) => { timerId.current = null; From ccf4ff28f9d5a76de032044bbbfebe20de737046 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Tue, 11 Jun 2024 13:51:33 -0400 Subject: [PATCH 03/23] Add window globals --- src/window.d.ts | 8 ++++++++ 1 file changed, 8 insertions(+) create mode 100644 src/window.d.ts diff --git a/src/window.d.ts b/src/window.d.ts new file mode 100644 index 0000000..90f8b8a --- /dev/null +++ b/src/window.d.ts @@ -0,0 +1,8 @@ +import WorkerPromise from "webworker-promise"; + +declare global { + interface Window { + __WASM_DEFINED__?: boolean; + worker: WorkerPromise; + } +} From 7e3d873d78eca15efb2fc2cb6d3e1dccd519e40e Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 14 Jun 2024 14:03:49 -0400 Subject: [PATCH 04/23] Properly type rust functions/worker --- rezasm-app/rezasm-wasm/src/lib.rs | 7 +- src/components/Code.jsx | 2 +- src/components/Console.jsx | 2 +- src/components/MemoryView.jsx | 2 +- src/components/RegistryView.jsx | 2 +- src/{window.d.ts => globals.d.ts} | 3 + src/{rust_functions.js => rust_functions.ts} | 61 +++++++++++++-- src/vite-env.d.ts | 1 + src/worker.js | 55 -------------- src/worker.ts | 79 ++++++++++++++++++++ tsconfig.json | 2 +- vite.config.js | 15 +--- 12 files changed, 153 insertions(+), 78 deletions(-) rename src/{window.d.ts => globals.d.ts} (54%) rename src/{rust_functions.js => rust_functions.ts} (55%) create mode 100644 src/vite-env.d.ts delete mode 100644 src/worker.js create mode 100644 src/worker.ts diff --git a/rezasm-app/rezasm-wasm/src/lib.rs b/rezasm-app/rezasm-wasm/src/lib.rs index d1f5f78..509a647 100644 --- a/rezasm-app/rezasm-wasm/src/lib.rs +++ b/rezasm-app/rezasm-wasm/src/lib.rs @@ -13,7 +13,7 @@ use rezasm_core::util::as_any::AsAny; use rezasm_web_core::{ get_exit_status, get_memory_bounds, get_memory_slice, get_register_names, get_register_value, get_register_values, get_simulator_mut, get_word_size, initialize_simulator, is_completed, - load, reset, step, stop, + load, reset, step, step_back, stop, }; use wasm_bindgen::prelude::*; @@ -37,6 +37,11 @@ pub fn wasm_step() -> Result<(), String> { step() } +#[wasm_bindgen] +pub fn wasm_step_back() -> Result<(), String> { + step_back() +} + #[wasm_bindgen] pub fn wasm_is_completed() -> bool { is_completed() diff --git a/src/components/Code.jsx b/src/components/Code.jsx index 6e6a095..016066c 100644 --- a/src/components/Code.jsx +++ b/src/components/Code.jsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from "react"; import RegistryView from "./RegistryView.jsx"; -import {loadWasm} from "../rust_functions.js"; +import {loadWasm} from "../rust_functions.ts"; import {Tabs, Tab} from "./Tabs.jsx"; import MemoryView from "./MemoryView.jsx"; diff --git a/src/components/Console.jsx b/src/components/Console.jsx index 84291e6..e1d1454 100644 --- a/src/components/Console.jsx +++ b/src/components/Console.jsx @@ -1,7 +1,7 @@ import React, {useCallback, useEffect, useReducer, useRef, useState} from "react"; import {listen} from "@tauri-apps/api/event"; import {CALLBACKS_TRIGGERS, CALLBACK_TYPES, STATE} from "./simulator.ts"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import { debounce } from "lodash"; const ENTER = 13; diff --git a/src/components/MemoryView.jsx b/src/components/MemoryView.jsx index 7976999..70df923 100644 --- a/src/components/MemoryView.jsx +++ b/src/components/MemoryView.jsx @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useRef, useState} from "react"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; const WIDTH = 4; diff --git a/src/components/RegistryView.jsx b/src/components/RegistryView.jsx index 93bc902..5b43537 100644 --- a/src/components/RegistryView.jsx +++ b/src/components/RegistryView.jsx @@ -1,5 +1,5 @@ import React, {useEffect, useState} from "react"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; function RegistryView({loaded, registerCallback}) { diff --git a/src/window.d.ts b/src/globals.d.ts similarity index 54% rename from src/window.d.ts rename to src/globals.d.ts index 90f8b8a..1c1975c 100644 --- a/src/window.d.ts +++ b/src/globals.d.ts @@ -3,6 +3,9 @@ import WorkerPromise from "webworker-promise"; declare global { interface Window { __WASM_DEFINED__?: boolean; + __WASM_LOADED__?: boolean; worker: WorkerPromise; + // eslint-disable-next-line no-unused-vars + emitPrintString?: (string: string) => void; } } diff --git a/src/rust_functions.js b/src/rust_functions.ts similarity index 55% rename from src/rust_functions.js rename to src/rust_functions.ts index 45b4139..1b04876 100644 --- a/src/rust_functions.js +++ b/src/rust_functions.ts @@ -1,7 +1,10 @@ +/* eslint-disable no-unused-vars */ import {invoke} from "@tauri-apps/api/tauri"; import WorkerPromise from "webworker-promise"; +import workerURL from "./worker.ts?worker&url"; +import {type Message, type ValidWasmCommandStrings} from "./worker.ts"; -const setsEqual = (xs, ys) => xs.size === ys.size && [...xs].every((x) => ys.has(x)); +const setsEqual = (xs: Set, ys: Set) => xs.size === ys.size && [...xs].every((x) => ys.has(x)); const isWasmLoaded = () => { return callWorkerFunction({command: "status"}); @@ -11,7 +14,7 @@ const loadWasm = async () => { return import("../wasm/rezasm_wasm.js").then(() => { if (!window.__WASM_DEFINED__) { window.__WASM_DEFINED__ = true; - window.worker = new WorkerPromise(new Worker("/src/worker.js", { type: "module" })); + window.worker = new WorkerPromise(new Worker(workerURL, { type: "module" })); window.worker.postMessage({command: "ping"}).then((e) => { return e === "pong"; }); @@ -21,7 +24,7 @@ const loadWasm = async () => { }); }; -const callWorkerFunction = (message) => { +const callWorkerFunction = (message: Message) => { return new Promise((resolve, reject) => { window.worker.postMessage(message) .then(result => resolve(result)) @@ -34,14 +37,15 @@ const callWorkerFunction = (message) => { // name is the name of the function in rust (without "tauri_" or "wasm_") // shape is an array describing the keys that are expected to be defined in props -const get_rust_function = (name, shape) => { +export const get_rust_function = (name: ValidWasmCommandStrings, shape?: string[]) => { shape = shape ?? []; const shapeSet = new Set(shape); - return async (props) => { + return async (props: Record) => { props = props ?? {}; if (!setsEqual(shapeSet, new Set(Object.keys(props)))) { throw new Error(`Function '${name} passed with unexpected shape'`); } + // @ts-expect-error -- This is not always going to exist, but the compiler doesn't know that if (window.__TAURI_IPC__) { return invoke(`tauri_${name}`, props); } else { @@ -53,6 +57,51 @@ const get_rust_function = (name, shape) => { }; }; +export interface RustFunctions { + LOAD: ( + props: {lines: string} + ) => Promise; + STEP: ( + props: Record + ) => Promise; + STEP_BACK: ( + props: Record + ) => Promise; + RESET: ( + props: Record + ) => Promise; + STOP: ( + props: Record + ) => Promise; + IS_COMPLETED: ( + props: Record + ) => Promise; + GET_EXIT_STATUS: ( + props: Record + ) => Promise; + GET_REGISTER_VALUE: ( + props: {register: string} + ) => Promise; + GET_REGISTER_NAMES: ( + props: Record + ) => Promise; + GET_REGISTER_VALUES: ( + props: Record + ) => Promise; + GET_MEMORY_BOUNDS: ( + props: Record + ) => Promise; + GET_MEMORY_SLICE: ( + props: {address: number, length: number} + ) => Promise; + GET_WORD_SIZE: ( + props: Record + ) => Promise; + RECEIVE_INPUT: ( + props: {data: string} + ) => Promise; +} + const RUST = { LOAD: get_rust_function("load", ["lines"]), STEP: get_rust_function("step"), @@ -68,7 +117,7 @@ const RUST = { GET_MEMORY_SLICE: get_rust_function("get_memory_slice", ["address", "length"]), GET_WORD_SIZE: get_rust_function("get_word_size"), RECEIVE_INPUT: get_rust_function("receive_input", ["data"]), -}; +} as RustFunctions; export { RUST, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/worker.js b/src/worker.js deleted file mode 100644 index 2cfbffc..0000000 --- a/src/worker.js +++ /dev/null @@ -1,55 +0,0 @@ -import registerWebworker from "webworker-promise/lib/register"; - -let wasmFunctions = {}; - -if (self.__WASM_LOADED__ === undefined && self.document === undefined) { - self.__WASM_LOADED__ = false; - import("../wasm/rezasm_wasm.js").then(async (module) => { - await module.default(); - wasmFunctions = Object.entries(module) - .filter(([x,]) => x.startsWith("wasm_")) - .reduce((left, [x, y]) => { - left[x] = y; - return left; - }, {}); - self.__WASM_LOADED__ = true; - console.log("WebAssembly code loaded"); - }).catch((error) => { - console.log("WebAssembly could not load", error); - }); - - const worker = registerWebworker(async (message) => { - const command = message.command; - if (command === "status") { - return self.__WASM_LOADED__; - } - const data = message.argument ?? {}; - const shape = message.shape ?? []; - const functionArguments = shape.map(arg => { - const argument = data[arg]; - if (argument !== undefined) { - return argument; - } else { - throw new Error(`Function '${command}' passed without required argument ${arg}`); - } - }); - - const wasmFunction = wasmFunctions[`wasm_${command}`]; - - if (command === "ping") { - return "pong"; - } else if (wasmFunction) { - try { - return wasmFunction(...functionArguments); - } catch (error) { - throw new Error(error); - } - } else { - throw new Error(`Invalid command: '${command}'`); - } - }); - - self.emitPrintString = (string) => { - worker.emit("wasm_print", string); - }; -} diff --git a/src/worker.ts b/src/worker.ts new file mode 100644 index 0000000..fb3e704 --- /dev/null +++ b/src/worker.ts @@ -0,0 +1,79 @@ +import registerWebworker from "webworker-promise/lib/register"; + +type WasmExports = Omit; + +// https://stackoverflow.com/questions/53501721/typescript-exclude-property-key-when-starts-with-target-string +// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars +type FilterStartsWith = Set extends `${Needle}${infer _}` ? Set : never +// https://stackoverflow.com/questions/75418099/shorter-keys-with-typescript-key-remapping-removing-prefix +type RemovePrefix = + K extends `${Prefix}${infer P}` ? P : K + +type NonWasmCommandStrings = "status" | "ping" +export type ValidWasmCommandStrings = RemovePrefix, "wasm_">; +type ValidCommandStrings = ValidWasmCommandStrings | NonWasmCommandStrings; +type WasmFunctions = Pick + +let wasmFunctions: WasmFunctions; + +export interface Message { + command: ValidCommandStrings + argument?: Record + shape?: string[] +} + +if (self.__WASM_LOADED__ === undefined && self.document === undefined) { + self.__WASM_LOADED__ = false; + import("../wasm/rezasm_wasm.js").then(async (module) => { + await module.default(); + wasmFunctions = Object.entries(module) + .filter(([x,]) => x.startsWith("wasm_")) + .reduce((left, [x, y]) => { + left[x] = y; + return left; + }, {} as Record) as WasmFunctions; + self.__WASM_LOADED__ = true; + console.log("WebAssembly code loaded"); + }).catch((error) => { + console.log("WebAssembly could not load", error); + }); + + const worker = registerWebworker(async (message: Message) => { + const command = message.command; + if (command === "status") { + return self.__WASM_LOADED__; + } + const data = message.argument ?? {}; + const shape = message.shape ?? []; + const functionArguments: unknown[] = shape.map(arg => { + const argument = data[arg]; + if (argument !== undefined) { + return argument; + } else { + throw new Error(`Function '${command}' passed without required argument ${arg}`); + } + }); + + if (command === "ping") { + return "pong"; + } + + const wasmFunction = wasmFunctions[`wasm_${command}`]; + + if (wasmFunction) { + try { + // @ts-expect-error -- There is no easy way to ensure this is correctly typed here. + // We have this typed correctly in rust_functions.ts. + return wasmFunction(...functionArguments); + } catch (error) { + throw new Error(String(error)); + } + } else { + throw new Error(`Invalid command: '${command}'`); + } + }); + + self.emitPrintString = (string: string) => { + worker.emit("wasm_print", string); + }; +} diff --git a/tsconfig.json b/tsconfig.json index f67b3f3..06f68c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "wasm"] } diff --git a/vite.config.js b/vite.config.js index 546a028..1627d38 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,22 +8,15 @@ export default defineConfig({ plugins: [react(), wasm(), topLevelAwait()], build: { - rollupOptions: { - input: { - app: "./index.html", - worker: "./src/worker.js" - }, - output: { - entryFileNames: assetInfo => { - return assetInfo.name === "worker" ? "src/[name].js" : "assets/js/[name]-[hash].js"; - } - }, - }, root: "src", outDir: "dist", emptyOutDir: true, }, + worker: { + format: "es" + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent vite from obscuring rust errors From 8e04784ef69769ee346c8cfab71d8976b27f83e2 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:27:36 -0400 Subject: [PATCH 05/23] Move old JS files to typescript --- src/{window.d.ts => globals.d.ts} | 0 src/{rust_functions.js => rust_functions.ts} | 0 src/{worker.js => worker.ts} | 0 3 files changed, 0 insertions(+), 0 deletions(-) rename src/{window.d.ts => globals.d.ts} (100%) rename src/{rust_functions.js => rust_functions.ts} (100%) rename src/{worker.js => worker.ts} (100%) diff --git a/src/window.d.ts b/src/globals.d.ts similarity index 100% rename from src/window.d.ts rename to src/globals.d.ts diff --git a/src/rust_functions.js b/src/rust_functions.ts similarity index 100% rename from src/rust_functions.js rename to src/rust_functions.ts diff --git a/src/worker.js b/src/worker.ts similarity index 100% rename from src/worker.js rename to src/worker.ts From 50df09685f5f44f6722f8afeaebb253380c0474d Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 14 Jun 2024 15:25:34 -0400 Subject: [PATCH 06/23] Properly type rust functions/worker --- rezasm-app/rezasm-wasm/src/lib.rs | 7 +++- src/components/Code.jsx | 2 +- src/components/Console.jsx | 2 +- src/components/MemoryView.jsx | 2 +- src/components/RegistryView.jsx | 2 +- src/globals.d.ts | 3 ++ src/rust_functions.ts | 61 ++++++++++++++++++++++++++++--- src/vite-env.d.ts | 1 + src/worker.ts | 42 ++++++++++++++++----- tsconfig.json | 2 +- vite.config.js | 15 ++------ 11 files changed, 107 insertions(+), 32 deletions(-) create mode 100644 src/vite-env.d.ts diff --git a/rezasm-app/rezasm-wasm/src/lib.rs b/rezasm-app/rezasm-wasm/src/lib.rs index d1f5f78..509a647 100644 --- a/rezasm-app/rezasm-wasm/src/lib.rs +++ b/rezasm-app/rezasm-wasm/src/lib.rs @@ -13,7 +13,7 @@ use rezasm_core::util::as_any::AsAny; use rezasm_web_core::{ get_exit_status, get_memory_bounds, get_memory_slice, get_register_names, get_register_value, get_register_values, get_simulator_mut, get_word_size, initialize_simulator, is_completed, - load, reset, step, stop, + load, reset, step, step_back, stop, }; use wasm_bindgen::prelude::*; @@ -37,6 +37,11 @@ pub fn wasm_step() -> Result<(), String> { step() } +#[wasm_bindgen] +pub fn wasm_step_back() -> Result<(), String> { + step_back() +} + #[wasm_bindgen] pub fn wasm_is_completed() -> bool { is_completed() diff --git a/src/components/Code.jsx b/src/components/Code.jsx index 6e6a095..016066c 100644 --- a/src/components/Code.jsx +++ b/src/components/Code.jsx @@ -1,6 +1,6 @@ import React, {useEffect, useState} from "react"; import RegistryView from "./RegistryView.jsx"; -import {loadWasm} from "../rust_functions.js"; +import {loadWasm} from "../rust_functions.ts"; import {Tabs, Tab} from "./Tabs.jsx"; import MemoryView from "./MemoryView.jsx"; diff --git a/src/components/Console.jsx b/src/components/Console.jsx index 84291e6..e1d1454 100644 --- a/src/components/Console.jsx +++ b/src/components/Console.jsx @@ -1,7 +1,7 @@ import React, {useCallback, useEffect, useReducer, useRef, useState} from "react"; import {listen} from "@tauri-apps/api/event"; import {CALLBACKS_TRIGGERS, CALLBACK_TYPES, STATE} from "./simulator.ts"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import { debounce } from "lodash"; const ENTER = 13; diff --git a/src/components/MemoryView.jsx b/src/components/MemoryView.jsx index 7976999..70df923 100644 --- a/src/components/MemoryView.jsx +++ b/src/components/MemoryView.jsx @@ -1,5 +1,5 @@ import React, {useCallback, useEffect, useRef, useState} from "react"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; const WIDTH = 4; diff --git a/src/components/RegistryView.jsx b/src/components/RegistryView.jsx index 93bc902..5b43537 100644 --- a/src/components/RegistryView.jsx +++ b/src/components/RegistryView.jsx @@ -1,5 +1,5 @@ import React, {useEffect, useState} from "react"; -import {RUST} from "../rust_functions.js"; +import {RUST} from "../rust_functions.ts"; import {CALLBACK_TYPES, CALLBACKS_TRIGGERS} from "./simulator.ts"; function RegistryView({loaded, registerCallback}) { diff --git a/src/globals.d.ts b/src/globals.d.ts index 90f8b8a..1c1975c 100644 --- a/src/globals.d.ts +++ b/src/globals.d.ts @@ -3,6 +3,9 @@ import WorkerPromise from "webworker-promise"; declare global { interface Window { __WASM_DEFINED__?: boolean; + __WASM_LOADED__?: boolean; worker: WorkerPromise; + // eslint-disable-next-line no-unused-vars + emitPrintString?: (string: string) => void; } } diff --git a/src/rust_functions.ts b/src/rust_functions.ts index 45b4139..1b04876 100644 --- a/src/rust_functions.ts +++ b/src/rust_functions.ts @@ -1,7 +1,10 @@ +/* eslint-disable no-unused-vars */ import {invoke} from "@tauri-apps/api/tauri"; import WorkerPromise from "webworker-promise"; +import workerURL from "./worker.ts?worker&url"; +import {type Message, type ValidWasmCommandStrings} from "./worker.ts"; -const setsEqual = (xs, ys) => xs.size === ys.size && [...xs].every((x) => ys.has(x)); +const setsEqual = (xs: Set, ys: Set) => xs.size === ys.size && [...xs].every((x) => ys.has(x)); const isWasmLoaded = () => { return callWorkerFunction({command: "status"}); @@ -11,7 +14,7 @@ const loadWasm = async () => { return import("../wasm/rezasm_wasm.js").then(() => { if (!window.__WASM_DEFINED__) { window.__WASM_DEFINED__ = true; - window.worker = new WorkerPromise(new Worker("/src/worker.js", { type: "module" })); + window.worker = new WorkerPromise(new Worker(workerURL, { type: "module" })); window.worker.postMessage({command: "ping"}).then((e) => { return e === "pong"; }); @@ -21,7 +24,7 @@ const loadWasm = async () => { }); }; -const callWorkerFunction = (message) => { +const callWorkerFunction = (message: Message) => { return new Promise((resolve, reject) => { window.worker.postMessage(message) .then(result => resolve(result)) @@ -34,14 +37,15 @@ const callWorkerFunction = (message) => { // name is the name of the function in rust (without "tauri_" or "wasm_") // shape is an array describing the keys that are expected to be defined in props -const get_rust_function = (name, shape) => { +export const get_rust_function = (name: ValidWasmCommandStrings, shape?: string[]) => { shape = shape ?? []; const shapeSet = new Set(shape); - return async (props) => { + return async (props: Record) => { props = props ?? {}; if (!setsEqual(shapeSet, new Set(Object.keys(props)))) { throw new Error(`Function '${name} passed with unexpected shape'`); } + // @ts-expect-error -- This is not always going to exist, but the compiler doesn't know that if (window.__TAURI_IPC__) { return invoke(`tauri_${name}`, props); } else { @@ -53,6 +57,51 @@ const get_rust_function = (name, shape) => { }; }; +export interface RustFunctions { + LOAD: ( + props: {lines: string} + ) => Promise; + STEP: ( + props: Record + ) => Promise; + STEP_BACK: ( + props: Record + ) => Promise; + RESET: ( + props: Record + ) => Promise; + STOP: ( + props: Record + ) => Promise; + IS_COMPLETED: ( + props: Record + ) => Promise; + GET_EXIT_STATUS: ( + props: Record + ) => Promise; + GET_REGISTER_VALUE: ( + props: {register: string} + ) => Promise; + GET_REGISTER_NAMES: ( + props: Record + ) => Promise; + GET_REGISTER_VALUES: ( + props: Record + ) => Promise; + GET_MEMORY_BOUNDS: ( + props: Record + ) => Promise; + GET_MEMORY_SLICE: ( + props: {address: number, length: number} + ) => Promise; + GET_WORD_SIZE: ( + props: Record + ) => Promise; + RECEIVE_INPUT: ( + props: {data: string} + ) => Promise; +} + const RUST = { LOAD: get_rust_function("load", ["lines"]), STEP: get_rust_function("step"), @@ -68,7 +117,7 @@ const RUST = { GET_MEMORY_SLICE: get_rust_function("get_memory_slice", ["address", "length"]), GET_WORD_SIZE: get_rust_function("get_word_size"), RECEIVE_INPUT: get_rust_function("receive_input", ["data"]), -}; +} as RustFunctions; export { RUST, diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/src/worker.ts b/src/worker.ts index 2cfbffc..fb3e704 100644 --- a/src/worker.ts +++ b/src/worker.ts @@ -1,6 +1,26 @@ import registerWebworker from "webworker-promise/lib/register"; -let wasmFunctions = {}; +type WasmExports = Omit; + +// https://stackoverflow.com/questions/53501721/typescript-exclude-property-key-when-starts-with-target-string +// eslint-disable-next-line no-unused-vars,@typescript-eslint/no-unused-vars +type FilterStartsWith = Set extends `${Needle}${infer _}` ? Set : never +// https://stackoverflow.com/questions/75418099/shorter-keys-with-typescript-key-remapping-removing-prefix +type RemovePrefix = + K extends `${Prefix}${infer P}` ? P : K + +type NonWasmCommandStrings = "status" | "ping" +export type ValidWasmCommandStrings = RemovePrefix, "wasm_">; +type ValidCommandStrings = ValidWasmCommandStrings | NonWasmCommandStrings; +type WasmFunctions = Pick + +let wasmFunctions: WasmFunctions; + +export interface Message { + command: ValidCommandStrings + argument?: Record + shape?: string[] +} if (self.__WASM_LOADED__ === undefined && self.document === undefined) { self.__WASM_LOADED__ = false; @@ -11,21 +31,21 @@ if (self.__WASM_LOADED__ === undefined && self.document === undefined) { .reduce((left, [x, y]) => { left[x] = y; return left; - }, {}); + }, {} as Record) as WasmFunctions; self.__WASM_LOADED__ = true; console.log("WebAssembly code loaded"); }).catch((error) => { console.log("WebAssembly could not load", error); }); - const worker = registerWebworker(async (message) => { + const worker = registerWebworker(async (message: Message) => { const command = message.command; if (command === "status") { return self.__WASM_LOADED__; } const data = message.argument ?? {}; const shape = message.shape ?? []; - const functionArguments = shape.map(arg => { + const functionArguments: unknown[] = shape.map(arg => { const argument = data[arg]; if (argument !== undefined) { return argument; @@ -34,22 +54,26 @@ if (self.__WASM_LOADED__ === undefined && self.document === undefined) { } }); - const wasmFunction = wasmFunctions[`wasm_${command}`]; - if (command === "ping") { return "pong"; - } else if (wasmFunction) { + } + + const wasmFunction = wasmFunctions[`wasm_${command}`]; + + if (wasmFunction) { try { + // @ts-expect-error -- There is no easy way to ensure this is correctly typed here. + // We have this typed correctly in rust_functions.ts. return wasmFunction(...functionArguments); } catch (error) { - throw new Error(error); + throw new Error(String(error)); } } else { throw new Error(`Invalid command: '${command}'`); } }); - self.emitPrintString = (string) => { + self.emitPrintString = (string: string) => { worker.emit("wasm_print", string); }; } diff --git a/tsconfig.json b/tsconfig.json index f67b3f3..06f68c3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -20,5 +20,5 @@ "noUnusedParameters": true, "noFallthroughCasesInSwitch": true }, - "include": ["src"] + "include": ["src", "wasm"] } diff --git a/vite.config.js b/vite.config.js index 546a028..1627d38 100644 --- a/vite.config.js +++ b/vite.config.js @@ -8,22 +8,15 @@ export default defineConfig({ plugins: [react(), wasm(), topLevelAwait()], build: { - rollupOptions: { - input: { - app: "./index.html", - worker: "./src/worker.js" - }, - output: { - entryFileNames: assetInfo => { - return assetInfo.name === "worker" ? "src/[name].js" : "assets/js/[name]-[hash].js"; - } - }, - }, root: "src", outDir: "dist", emptyOutDir: true, }, + worker: { + format: "es" + }, + // Vite options tailored for Tauri development and only applied in `tauri dev` or `tauri build` // // 1. prevent vite from obscuring rust errors From 0a4957e4b8f62cada5c4f248425bebe3f80a68c3 Mon Sep 17 00:00:00 2001 From: Aleks Bekker Date: Tue, 18 Jun 2024 15:23:01 -0400 Subject: [PATCH 07/23] Create file system integration for Tauri I implemented a file system API for Tauri that lets us access and modify arbitrary parts of the file system, instead of the limited scope that the `tauri.fs` provides us. This involved exposing much of Rust's `std::io` via the `tauri::command` proc macro, and then reading these functions into the TS side. Also, I created the function definitions for the WASM side's filesystem, but I haven't implemented it yet because I don't totally understand browser local storage works in WASM. --- rezasm-app/rezasm-tauri/src/file_system.rs | 65 +++++++++ rezasm-app/rezasm-tauri/src/main.rs | 4 + rezasm-app/rezasm-wasm/src/file_system.rs | 31 ++++ rezasm-app/rezasm-wasm/src/lib.rs | 1 + src/file_system.ts | 156 +++++++++++++++++++++ 5 files changed, 257 insertions(+) create mode 100644 rezasm-app/rezasm-tauri/src/file_system.rs create mode 100644 rezasm-app/rezasm-wasm/src/file_system.rs create mode 100644 src/file_system.ts diff --git a/rezasm-app/rezasm-tauri/src/file_system.rs b/rezasm-app/rezasm-tauri/src/file_system.rs new file mode 100644 index 0000000..6d59e60 --- /dev/null +++ b/rezasm-app/rezasm-tauri/src/file_system.rs @@ -0,0 +1,65 @@ +use std::fs; + +extern crate tauri; + +/// Creates a Tauri command from a function that returns () OR an error +macro_rules! void_or_error_command { + ($fn_name:ident, $wrapped_fn:expr, $( $arg_name:ident : $arg_type:ty ),*) => { + #[tauri::command] + fn $fn_name($( $arg_name : $arg_type),*) -> Result<(), String> { + $wrapped_fn($($arg_name), *).map_err(|err| err.to_string())?; + Ok(()) + } + }; +} + +/// Creates a Tauri command from a function that returns the wrapped function's result OR an error +macro_rules! return_or_error_command { + ($fn_name:ident, $wrapped_fn:expr, $return_type:ty, $( $arg_name:ident : $arg_type:ty ),*) => { + #[tauri::command] + fn $fn_name($( $arg_name : $arg_type),*) -> Result<$return_type, String> { + $wrapped_fn($($arg_name), *).map_err(|err| err.to_string()) + } + }; +} + +return_or_error_command!(tauri_copy, std::fs::copy, u64, from: &str, to: &str); +return_or_error_command!(tauri_read_to_string, std::fs::read_to_string, String, path: &str); + +void_or_error_command!(tauri_create_dir, std::fs::create_dir, path: &str); +void_or_error_command!(tauri_create_dir_with_parents, std::fs::create_dir_all, path: &str); +void_or_error_command!(tauri_create_file, std::fs::File::create, path: &str); +void_or_error_command!(tauri_remove_file, std::fs::remove_file, path: &str); +void_or_error_command!(tauri_rename, std::fs::rename, from: &str, to: &str); + +// Can only delete empty directory +void_or_error_command!(tauri_remove_dir, std::fs::remove_dir, path: &str); + +// Deletes all contents of a (potentially) non-empty directory +void_or_error_command!(tauri_remove_dir_recursive, std::fs::remove_dir_all, path: &str); + +#[tauri::command] +fn tauri_read_dir(path: &str) -> Result, String> { + Ok(fs::read_dir(path) + .map_err(|err| err.to_string())? + .into_iter() + .filter_map(|entry| entry.ok()?.path().to_str().map(|s| s.to_string())) + .collect()) +} + +lazy_static::lazy_static! { + /// The tauri handler containing all file system methods + pub static ref HANDLER: Box = + Box::new(tauri::generate_handler![ + tauri_copy, + tauri_create_dir, + tauri_create_dir_with_parents, + tauri_create_file, + tauri_read_dir, + tauri_read_to_string, + tauri_remove_dir, + tauri_remove_dir_recursive, + tauri_remove_file, + tauri_rename, + ]); +} diff --git a/rezasm-app/rezasm-tauri/src/main.rs b/rezasm-app/rezasm-tauri/src/main.rs index 1a48837..7ca03cc 100644 --- a/rezasm-app/rezasm-tauri/src/main.rs +++ b/rezasm-app/rezasm-tauri/src/main.rs @@ -1,6 +1,7 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] +mod file_system; mod tauri_reader; mod tauri_writer; @@ -18,6 +19,8 @@ use rezasm_web_core::{ use tauri::{Manager, Window}; use tauri_reader::TauriReader; +use file_system::HANDLER as file_system_handler; + use crate::tauri_writer::TauriWriter; use std::{ io::Write, @@ -142,6 +145,7 @@ fn main() { tauri_get_word_size, tauri_receive_input, ]) + .invoke_handler(file_system_handler.as_ref()) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/rezasm-app/rezasm-wasm/src/file_system.rs b/rezasm-app/rezasm-wasm/src/file_system.rs new file mode 100644 index 0000000..938340e --- /dev/null +++ b/rezasm-app/rezasm-wasm/src/file_system.rs @@ -0,0 +1,31 @@ +use wasm_bindgen::prelude::wasm_bindgen; + +#[wasm_bindgen] +pub fn wasm_copy(_from: &str, _to: &str) -> u64 { todo!() } + +#[wasm_bindgen] +pub fn wasm_read_to_string(_path: &str) -> String { todo!() } + +#[wasm_bindgen] +pub fn wasm_create_dir(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_read_dir(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_create_dir_with_parents(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_create_file(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_remove_file(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_rename(_from: &str, _to: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_remove_dir(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_remove_dir_recursive(_path: &str) { todo!() } diff --git a/rezasm-app/rezasm-wasm/src/lib.rs b/rezasm-app/rezasm-wasm/src/lib.rs index 509a647..d53ba2b 100644 --- a/rezasm-app/rezasm-wasm/src/lib.rs +++ b/rezasm-app/rezasm-wasm/src/lib.rs @@ -1,4 +1,5 @@ mod wasm_writer; +mod file_system; extern crate rezasm_core; extern crate rezasm_web_core; diff --git a/src/file_system.ts b/src/file_system.ts new file mode 100644 index 0000000..4f7dec4 --- /dev/null +++ b/src/file_system.ts @@ -0,0 +1,156 @@ +import { get_rust_function } from "./rust_functions"; + +// TODO: comment in the exception that is thrown when an error is encountered in the functions + +/** + * File system interface + * + * This interface is implemented by the exported `fs` object. + */ +interface FileSystem { + + /** + * Copies a file or directory in the target filesystem + * + * @param props a record with the `from` and `to` paths, represented by `string`s. + * @returns a promise for the number of bytes copied. + * + * @example ```typescript + * let copiedBytes: bigint = await fs.copy({from: "path/to/file", to: "new/path/to/file"}); + * ``` + */ + copy(props: {from: string, to: string}): Promise; + + /** + * Creates a new directory in the target filesystem. + * + * This error will not create any missing parent directories while creating the directory. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * const parent = "some/existing/path"; + * const newDirectory = "new_directory_name" + * await fs.createDir({path: `${parent}/${newDirectory}`}); + * ``` + */ + createDir(props: {path: string}): Promise; + + /** + * Creates a new directories and all required parents in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.createDirWithParents({path: "path/to/new/dir"}); + * ``` + */ + createDirWithParents(props: {path: string}): Promise; + + /** + * Creates a new file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.createFile({path: "path/to/new/dir"}); + * ``` + */ + createFile(props: {path: string}): Promise; + + /** + * Reads a directory in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns a promise containing an array of the string names of the files in the directory. + * + * @example ```typescript + * let files: string[] = await fs.readDir({path: "path/to/new/dir"}); + * ``` + */ + readDir(props: {path: string}): Promise; + + /** + * Reads a whole file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns a promise for a string that contains the data from the whole file. + * + * @example ```typescript + * let fileContents: string = await fs.readToString({path: "path/to/new/dir"}); + * ``` + */ + readToString(props: {path: string}): Promise; + + /** + * Removes an empty directory from the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeDir({path: "path/to/empty/dir"}); + * ``` + */ + removeDir(props: {path: string}): Promise; + + /** + * Removes a directory and all the files it contains in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeDirRecursive({path: "path/to/target/dir"}); + * ``` + */ + removeDirRecursive(props: {path: string}): Promise; + + /** + * Removes a file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeFile({path: "path/to/target/file"}); + * ``` + */ + removeFile(props: {path: string}): Promise; + + /** + * Removes a file in the target filesystem. + * + * @param props a record with the `from` and `to` paths, represented by `string`s. + * @returns an empty promise. + * + * @example ```typescript + * await fs.rename({from: "old/path", to: "new/path"}); + * ``` + */ + rename(props: {from: string, to: string}): Promise; +} + +/** + * File system interaction + * + * Depending on the build target (WASM/Tauri), this object may either modify the local + * browser based or system filesystem. + */ +const fs = { + copy: get_rust_function("copy", ["from", "to"]), + createDir: get_rust_function("create_dir", ["path"]), + createDirWithParents: get_rust_function("create_dir_with_parents", ["path"]), + createFile: get_rust_function("create_file", ["path"]), + readDir: get_rust_function("read_dir", ["path"]), + readToString: get_rust_function("read_to_string", ["path"]), + removeDir: get_rust_function("remove_dir", ["path"]), + removeDirRecursive: get_rust_function("remove_dir_recursive", ["path"]), + removeFile: get_rust_function("remove_file", ["path"]), + rename: get_rust_function("rename", ["from", "to"]), +} as FileSystem; + +export default fs; From 96fa1e37710a589d1de1b84007a1a7704b7df11e Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 21 Jun 2024 14:15:11 -0400 Subject: [PATCH 08/23] Add a menu for browser (WASM) mode --- package.json | 9 +- src/App.tsx | 8 +- src/components/BrowserMenu.tsx | 119 ++++++++++++++++++++++++++ src/components/{Code.jsx => Code.tsx} | 12 +-- src/components/Controls.jsx | 2 +- src/components/Editor.jsx | 1 + src/styles.css | 2 +- tailwind.config.js | 2 +- 8 files changed, 138 insertions(+), 17 deletions(-) create mode 100644 src/components/BrowserMenu.tsx rename src/components/{Code.jsx => Code.tsx} (82%) diff --git a/package.json b/package.json index 786853c..c4714a2 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "wasm-build": "wasm-pack build -d ../../wasm --target web rezasm-app/rezasm-wasm/" }, "dependencies": { + "@headlessui/react": "^2.0.4", "@tauri-apps/api": "^1.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", @@ -30,18 +31,18 @@ "devDependencies": { "@babel/core": "^7.22.17", "@tauri-apps/cli": "^1.4.0", - "eslint-plugin-react": "^7.33.2", - "lodash": "^4.17.21", - "npm-run-all": "^4.1.5", - "tailwindcss": "^3.3.3", "@types/react": "^18.2.66", "@types/react-dom": "^18.2.22", "@typescript-eslint/eslint-plugin": "^7.2.0", "@typescript-eslint/parser": "^7.2.0", "@vitejs/plugin-react": "^4.2.1", "eslint": "^8.57.0", + "eslint-plugin-react": "^7.33.2", "eslint-plugin-react-hooks": "^4.6.0", "eslint-plugin-react-refresh": "^0.4.6", + "lodash": "^4.17.21", + "npm-run-all": "^4.1.5", + "tailwindcss": "^3.3.3", "typescript": "^5.2.2", "vite": "^5.2.0" } diff --git a/src/App.tsx b/src/App.tsx index 0870fb5..404aac2 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,14 +1,14 @@ import {HashRouter, Route, Routes} from "react-router-dom"; -import Code from "./components/Code.jsx"; +import Code from "./components/Code.js"; import Home from "./components/Home.jsx"; import Downloads from "./components/Downloads.jsx"; -import "../dist/output.css"; +import "./styles.css"; const HOME_PATH = "/"; const CODE_PATH = "/code/"; const DOWNLOAD_PATH = "/downloads/"; -function App() { +export default function App() { return ( @@ -20,6 +20,4 @@ function App() { ); } -export default App; - export { HOME_PATH, CODE_PATH, DOWNLOAD_PATH }; diff --git a/src/components/BrowserMenu.tsx b/src/components/BrowserMenu.tsx new file mode 100644 index 0000000..8271179 --- /dev/null +++ b/src/components/BrowserMenu.tsx @@ -0,0 +1,119 @@ +import {Menu, MenuButton, MenuItem, MenuItems, Transition} from "@headlessui/react"; +import React, {PropsWithChildren} from "react"; + +export function MenuHeading(props: PropsWithChildren) { + return ( + + {props.children} + + ); +} + +export function MenuOption(props: PropsWithChildren) { + const {children, ...otherProps} = props; + return + {({focus}) => ( + + {children} + + )} + ; +} + +function MenuSection(props: PropsWithChildren) { + return
+ {props.children} +
; +} + +function SectionMenu(props: PropsWithChildren<{ heading: string }>) { + return +
+ {props.heading} +
+ + + {props.children} + + +
; +} + +function FileMenu() { + return + + + Open Folder + + + Open File + + + + + Save + + + + + Export File + + + Export Folder + + + Export Project + + + ; +} + +function EditMenu() { + return + + + Undo + + + Redo + + + + + Cut + + + Copy + + + Paste + + + ; +} + +export default function BrowserMenu() { + return ( +
+ + +
+ ); +} diff --git a/src/components/Code.jsx b/src/components/Code.tsx similarity index 82% rename from src/components/Code.jsx rename to src/components/Code.tsx index 016066c..cdcdbc5 100644 --- a/src/components/Code.jsx +++ b/src/components/Code.tsx @@ -1,4 +1,4 @@ -import React, {useEffect, useState} from "react"; +import {useEffect, useState} from "react"; import RegistryView from "./RegistryView.jsx"; import {loadWasm} from "../rust_functions.ts"; import {Tabs, Tab} from "./Tabs.jsx"; @@ -8,6 +8,7 @@ import Console from "./Console.jsx"; import Controls from "./Controls.jsx"; import Editor from "./Editor.jsx"; import {useSimulator} from "./simulator.ts"; +import BrowserMenu from "./BrowserMenu.js"; function Code() { @@ -17,7 +18,7 @@ function Code() { exitCode, setState, setCode, - setInstructionDelay, + // setInstructionDelay, registerCallback, start, stop, @@ -30,14 +31,14 @@ function Code() { useEffect(() => { loadWasm() - .then((loaded) => setWasmLoaded(loaded)) + .then((loaded) => setWasmLoaded(Boolean(loaded))) .catch(() => setWasmLoaded(false)); }, []); return (
+ {!window.__TAURI__ && }
-
@@ -47,9 +48,10 @@ function Code() {
+ -
+
diff --git a/src/components/Controls.jsx b/src/components/Controls.jsx index ec511a5..1a8f732 100644 --- a/src/components/Controls.jsx +++ b/src/components/Controls.jsx @@ -9,7 +9,7 @@ function Controls({state, setState, start, stop, step, stepBack, reset, load, er const isErrorState = error.current !== ""; return ( -
+
{state.current === STATE.RUNNING ? + +
; +} diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx new file mode 100644 index 0000000..6cdabe1 --- /dev/null +++ b/src/components/FsContextProvider.tsx @@ -0,0 +1,33 @@ +import {FsContext, FsDir} from "../fsContext.ts"; +import {PropsWithChildren, useCallback, useState} from "react"; + +export default function FsContextProvider(props: PropsWithChildren) { + const [root, setRoot] = useState(undefined); + const getItem = useCallback((path: string) => { + if (!root || !path) { + return null; + } + const paths = path.split("/"); + if (paths[0] === root.name) { + paths.shift(); + } + let current: FsDir = root; + for (let num = 0; num < paths.length; num++) { + const path_part = paths[num]; + const next = current.children.get(path_part); + if (!next || (num !== paths.length - 1 && !next.isDir)) { + return null; + } + if (num === paths.length - 1 || !next.isDir) { + return next ?? null; + } + current = next; + } + return current; + + }, [root]) + return ; +} diff --git a/src/fsContext.ts b/src/fsContext.ts new file mode 100644 index 0000000..45e0f8a --- /dev/null +++ b/src/fsContext.ts @@ -0,0 +1,52 @@ +import { createContext } from "react"; + +export abstract class AbstractFsFile { + public name: string; + public isDir: boolean; + public parent: FsDir | null; // null for root + + constructor(name: string, isDir: boolean, parent: FsDir | null) { + this.name = name; + this.isDir = isDir; + this.parent = parent; + } + + path(): string { + return this.parent ? this.parent.path() + "/" + this.name : this.name; + } +} + +export class FsFile extends AbstractFsFile{ + public isDir: false = false; + public parent: FsDir; + + constructor(name: string, parent: FsDir) { + super(name, false, parent); + this.parent = parent; + } +} + +export class FsDir extends AbstractFsFile { + public isDir: true = true; + public children: Map = new Map(); + + constructor(name: string, parent: FsDir | null) { + super(name, true, parent); + } + + refresh() { + throw new Error("Method not implemented."); + } +} + +export type FsItem = FsFile | FsDir; + +export interface FsContext { + root: FsDir | undefined; + getItem(path: string): FsItem | null; +} + +export const FsContext = createContext({ + root: undefined, + getItem: () => null +}); diff --git a/tailwind.config.js b/tailwind.config.js index 840afab..3dbad06 100644 --- a/tailwind.config.js +++ b/tailwind.config.js @@ -1,7 +1,9 @@ -/** @type {import('tailwindcss').Config} */ +/** @type {import("tailwindcss").Config} */ export default { - content: ["./src/components/*.jsx", "./src/components/*.tsx", - "./src/App.tsx"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], theme: { extend: {}, }, From 9af6e2e7ba720583918e04f022aed728ec432160 Mon Sep 17 00:00:00 2001 From: Aleks Bekker Date: Fri, 28 Jun 2024 15:02:36 -0400 Subject: [PATCH 10/23] Add directory flag to read_dir Currently, our file system function to read a directory fails to inform the front-end whether a path leads to a file or a directory. As such, I have added a boolean to each output with this information. Also, renamed copy to copy_file for consistency, clarity, and compatibility with the initial interface. --- rezasm-app/rezasm-tauri/src/file_system.rs | 9 +++++---- rezasm-app/rezasm-wasm/src/file_system.rs | 6 ++++-- src/file_system.ts | 11 ++++++----- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/rezasm-app/rezasm-tauri/src/file_system.rs b/rezasm-app/rezasm-tauri/src/file_system.rs index 6d59e60..ed90de6 100644 --- a/rezasm-app/rezasm-tauri/src/file_system.rs +++ b/rezasm-app/rezasm-tauri/src/file_system.rs @@ -23,7 +23,7 @@ macro_rules! return_or_error_command { }; } -return_or_error_command!(tauri_copy, std::fs::copy, u64, from: &str, to: &str); +return_or_error_command!(tauri_copy_file, std::fs::copy, u64, from: &str, to: &str); return_or_error_command!(tauri_read_to_string, std::fs::read_to_string, String, path: &str); void_or_error_command!(tauri_create_dir, std::fs::create_dir, path: &str); @@ -39,11 +39,12 @@ void_or_error_command!(tauri_remove_dir, std::fs::remove_dir, path: &str); void_or_error_command!(tauri_remove_dir_recursive, std::fs::remove_dir_all, path: &str); #[tauri::command] -fn tauri_read_dir(path: &str) -> Result, String> { +fn tauri_read_dir(path: &str) -> Result, String> { Ok(fs::read_dir(path) .map_err(|err| err.to_string())? .into_iter() - .filter_map(|entry| entry.ok()?.path().to_str().map(|s| s.to_string())) + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter_map(|path| Some((path.to_str()?.to_string(), path.is_dir()))) .collect()) } @@ -51,7 +52,7 @@ lazy_static::lazy_static! { /// The tauri handler containing all file system methods pub static ref HANDLER: Box = Box::new(tauri::generate_handler![ - tauri_copy, + tauri_copy_file, tauri_create_dir, tauri_create_dir_with_parents, tauri_create_file, diff --git a/rezasm-app/rezasm-wasm/src/file_system.rs b/rezasm-app/rezasm-wasm/src/file_system.rs index 938340e..28fff54 100644 --- a/rezasm-app/rezasm-wasm/src/file_system.rs +++ b/rezasm-app/rezasm-wasm/src/file_system.rs @@ -1,10 +1,12 @@ use wasm_bindgen::prelude::wasm_bindgen; +type StringResult = Result; + #[wasm_bindgen] -pub fn wasm_copy(_from: &str, _to: &str) -> u64 { todo!() } +pub fn wasm_copy_file(_from: &str, _to: &str) -> StringResult { todo!() } #[wasm_bindgen] -pub fn wasm_read_to_string(_path: &str) -> String { todo!() } +pub fn wasm_read_to_string(_path: &str) -> StringResult { todo!() } #[wasm_bindgen] pub fn wasm_create_dir(_path: &str) { todo!() } diff --git a/src/file_system.ts b/src/file_system.ts index 4f7dec4..7714f16 100644 --- a/src/file_system.ts +++ b/src/file_system.ts @@ -10,7 +10,7 @@ import { get_rust_function } from "./rust_functions"; interface FileSystem { /** - * Copies a file or directory in the target filesystem + * Copies a file in the target filesystem * * @param props a record with the `from` and `to` paths, represented by `string`s. * @returns a promise for the number of bytes copied. @@ -19,7 +19,7 @@ interface FileSystem { * let copiedBytes: bigint = await fs.copy({from: "path/to/file", to: "new/path/to/file"}); * ``` */ - copy(props: {from: string, to: string}): Promise; + copy_file(props: {from: string, to: string}): Promise; /** * Creates a new directory in the target filesystem. @@ -65,13 +65,14 @@ interface FileSystem { * Reads a directory in the target filesystem. * * @param props a record with the `path` entry that refers to the target path. - * @returns a promise containing an array of the string names of the files in the directory. + * @returns a promise containing an array of tuples that contain the relative file name followed + * by a boolean that is true iff the file is a directory. * * @example ```typescript * let files: string[] = await fs.readDir({path: "path/to/new/dir"}); * ``` */ - readDir(props: {path: string}): Promise; + readDir(props: {path: string}): Promise<[string, boolean][]>; /** * Reads a whole file in the target filesystem. @@ -141,7 +142,7 @@ interface FileSystem { * browser based or system filesystem. */ const fs = { - copy: get_rust_function("copy", ["from", "to"]), + copy_file: get_rust_function("copy", ["from", "to"]), createDir: get_rust_function("create_dir", ["path"]), createDirWithParents: get_rust_function("create_dir_with_parents", ["path"]), createFile: get_rust_function("create_file", ["path"]), From dad68de256f433c6fba5e67b0e10e487e9986566 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Tue, 9 Jul 2024 14:16:49 -0400 Subject: [PATCH 11/23] Create support for a WASM FS --- .eslintrc.json | 3 +- package.json | 3 + src/components/FilesystemSidebar.tsx | 12 ++- src/components/FsContextProvider.tsx | 143 ++++++++++++++++++++++++++- src/fsContext.ts | 115 +++++++++++++++++++-- src/wasmFs.ts | 93 +++++++++++++++++ tsconfig.json | 4 +- 7 files changed, 355 insertions(+), 18 deletions(-) create mode 100644 src/wasmFs.ts diff --git a/.eslintrc.json b/.eslintrc.json index 2cacdf9..6f58d61 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -2,7 +2,8 @@ "root": true, "env": { "browser": true, - "es2021": true + "es2021": true, + "es2023": true }, "extends": [ "eslint:recommended", diff --git a/package.json b/package.json index 8894701..c2d1e81 100644 --- a/package.json +++ b/package.json @@ -47,5 +47,8 @@ "tailwindcss": "^3.4.4", "typescript": "^5.2.2", "vite": "^5.2.0" + }, + "engines": { + "node": ">=20" } } diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index 8cb60c2..1318702 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -8,21 +8,23 @@ export function FileSidebar(props: {file: AbstractFsFile}) { export function FolderSidebar(props: {folder: FsDir}) { const [expanded, setExpanded] = useState(true); // Set for development const children = useMemo(() => { - return props.folder.children.map((child) => { + return [...props.folder.children.values()].map((child) => { return child.isDir ? : ; }); }, [props.folder.children]); return
setExpanded(!expanded)}>{expanded ? "V" : ">"} {expanded &&
{children}
} -
+
; } export default function FilesystemSidebar() { const fs = useContext(FsContext); - return
+ return
{fs.root ? : "No filesystem loaded, create a file or open a directory."} - - +
+ + +
; } diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 6cdabe1..06d5dff 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -1,8 +1,20 @@ -import {FsContext, FsDir} from "../fsContext.ts"; -import {PropsWithChildren, useCallback, useState} from "react"; +import { + BaseFileSystem, + ContextFileSystem, directoryname, + filename, + FsContext, + FsDir, + FsFile, + FsItem, + joinPath, + parts +} from "../fsContext.ts"; +import {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; +import {initEmptyFs} from "../wasmFs.ts"; export default function FsContextProvider(props: PropsWithChildren) { const [root, setRoot] = useState(undefined); + const [fsProvider, setFsProvider] = useState(undefined); const getItem = useCallback((path: string) => { if (!root || !path) { return null; @@ -25,9 +37,134 @@ export default function FsContextProvider(props: PropsWithChildren) { } return current; - }, [root]) + }, [root]); + + const FsOps: ContextFileSystem = useMemo(() => { + const copyFile: ContextFileSystem["copyFile"] = async (from: FsFile, toParent: FsDir, toName?: string) => { + const fromPath = from.path(); + const toFileName = toName ?? from.name; + await fsProvider!.copyFile(fromPath, joinPath(toParent, toFileName)); + const toFile = new FsFile(toFileName, toParent); + toParent.addChild(toFile); + return toFile; + }; + + const createFile: ContextFileSystem["createFile"] = async(parent: FsDir, path: string) => { + const targetPath = joinPath(parent, path); + await fsProvider!.createFile(targetPath); + const fileName = filename(targetPath); + const newFile = new FsFile(fileName, parent); + parent.addChild(newFile); + return newFile; + }; + + const createDir: ContextFileSystem["createDir"] = async (parent: FsDir, path: string)=> { + const targetPath = joinPath(parent, path); + await fsProvider!.createDir(targetPath); + const dirName = filename(targetPath); + const newDir = new FsDir(dirName, parent); + parent.addChild(newDir); + return newDir; + }; + + const createDirWithParents: ContextFileSystem["createDirWithParents"] = async (parent: FsDir, path: string) => { + const pieces = parts(path); + let current = parent; + for (let i = 0; i < pieces.length; i++) { + const piece = pieces[i]; + if (!current.children.has(piece)) { + const part = await FsOps.createDir(current, piece); + current.addChild(part); + current = part; + } else { + const part = current.children.get(piece)!; + if (!part.isDir) { + throw new Error(`Path ${joinPath(parent, ...pieces.slice(0, i))} already exists as a file.`); + } + current = part; + } + } + console.assert(current.path() === joinPath(parent, path), `Path ${current.path()} does not match ${joinPath(parent, path)}`); + return current; + }; + + const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { + const items = await fsProvider!.readDir(parent.path()); + const map = new Map(); + const dirs: FsDir[] = []; + for (const [fileName, isDir] of items) { + const name = filename(fileName); + const newItem = isDir ? new FsDir(name, parent) : new FsFile(name, parent); + map.set(name, newItem); + if (newItem instanceof FsDir) { + dirs.push(newItem); + } + } + parent.children = map; + await Promise.all(dirs.map(readDir)); + return map; + }; + + const readToString: ContextFileSystem["readToString"] = async (file: FsFile) => { + return fsProvider!.readToString(file.path()); + }; + + const removeFile: ContextFileSystem["removeFile"] = async (file: FsFile) => { + await fsProvider!.removeFile(file.path()); + file.parent.children.delete(file.name); + }; + + const removeDir: ContextFileSystem["removeDir"] = async (dir: FsDir) => { + if (dir.parent === null) { + throw new Error("Cannot remove root directory."); + } + await fsProvider!.removeDir(dir.path()); + dir.parent.children.delete(dir.name); + }; + + const removeDirRecursive: ContextFileSystem["removeDirRecursive"] = async (dir: FsDir) => { + if (dir.parent === null) { + throw new Error("Cannot remove root directory."); + } + await fsProvider!.removeDirRecursive(dir.path()); + dir.parent.children.delete(dir.name); + }; + + const renameFile: ContextFileSystem["renameFile"] = async (file: FsFile, newPath: string) => { + const newName = filename(newPath); + const newPathParent = getItem(directoryname(newPath)); + if (!newPathParent) { + throw new Error(`Parent directory of ${newPath} does not exist.`); + } + await fsProvider!.renameFile(file.path(), newPath); + file.parent.children.delete(file.name); + file.name = newName; + file.parent.children.set(newName, file); + return file; + }; + + return { + copyFile, + createFile, + createDir, + createDirWithParents, + readDir, + readToString, + removeFile, + removeDir, + removeDirRecursive, + renameFile, + }; + }, [fsProvider, getItem]); + + useEffect(() => { + initEmptyFs().then((fs) => setFsProvider(fs)); + setRoot(new FsDir("/", null)); + }, []); + return ; } diff --git a/src/fsContext.ts b/src/fsContext.ts index 45e0f8a..f836ec5 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -1,3 +1,4 @@ +/* eslint-disable no-unused-vars */ import { createContext } from "react"; export abstract class AbstractFsFile { @@ -5,7 +6,7 @@ export abstract class AbstractFsFile { public isDir: boolean; public parent: FsDir | null; // null for root - constructor(name: string, isDir: boolean, parent: FsDir | null) { + protected constructor(name: string, isDir: boolean, parent: FsDir | null) { this.name = name; this.isDir = isDir; this.parent = parent; @@ -17,7 +18,7 @@ export abstract class AbstractFsFile { } export class FsFile extends AbstractFsFile{ - public isDir: false = false; + public isDir = false as const; public parent: FsDir; constructor(name: string, parent: FsDir) { @@ -27,26 +28,126 @@ export class FsFile extends AbstractFsFile{ } export class FsDir extends AbstractFsFile { - public isDir: true = true; + public isDir = true as const; public children: Map = new Map(); constructor(name: string, parent: FsDir | null) { super(name, true, parent); } - refresh() { - throw new Error("Method not implemented."); + addChild(child: FsItem) { + this.children.set(child.name, child); } } export type FsItem = FsFile | FsDir; +export function parts(path: string): string[]; +export function parts(path: FsItem, returnFsItems: false): string[]; +export function parts(path: FsItem, returnFsItems: true): FsItem[]; +export function parts(path: string | FsItem, returnFsItems: boolean = true): string[] | FsItem[] { + if (returnFsItems && typeof path !== "string") { + const partsArr: FsItem[] = [path]; + const parent = path.parent; + while (parent !== null) { + partsArr.push(parent); + } + return partsArr.reverse(); + } else { + const pathStr = typeof path === "string" ? path : path.path(); + return pathStr.split("/").filter((part) => part !== ""); + } +} + +export function joinPath(first: string | FsDir, ...rest: string[]): string { + let firstStr: string; + if (typeof first !== "string") { + firstStr = first.path(); + } else { + firstStr = first; + } + const firstSegments = firstStr.split("/"); + const parts = [...firstSegments, ...rest]; + const validatedParts: string[] = []; + for (let i: number = 0; i < parts.length; i++) { + const part = parts[i]; + if (part === ".") { + continue; + } else if (part === "..") { + validatedParts.pop(); + } else if (part.indexOf("/") !== -1) { + validatedParts.push(...part.split("/")); + } else { + validatedParts.push(part); + } + } + return validatedParts.join("/"); +} + +export function filename(path: string): string { + return path.substring(path.lastIndexOf("/") + 1); +} + +export function directoryname(path: string): string { + return path.substring(0, path.lastIndexOf("/")); +} + +export interface BaseFileSystem { + copyFile(from: string, to: string): Promise; + createDir(path: string): Promise; + createDirWithParents(path: string): Promise; + createFile(path: string): Promise; + + /** + * Read a directory and return its contents + * @param path The path to read + * @returns A list of [name, isDir] tuples + */ + readDir(path: string): Promise<[string, boolean][]>; + readToString(path: string): Promise; + removeDir(path: string): Promise; + removeDirRecursive(path: string): Promise; + removeFile(path: string): Promise; + renameFile(from: string, to: string): Promise; +} + +export interface ContextFileSystem { + copyFile(from: FsFile, toParent: FsDir, toName?: string): Promise; + createDir(parent: FsDir, path: string): Promise; + createDirWithParents(parent: FsDir, path: string): Promise; + createFile(parent: FsDir, path: string): Promise; + readDir(parent: FsDir): Promise>; + readToString(path: FsFile): Promise; + removeDir(path: FsDir): Promise; + removeDirRecursive(path: FsDir): Promise; + removeFile(path: FsFile): Promise; + renameFile(from: FsFile, to: string): Promise; +} + + export interface FsContext { root: FsDir | undefined; getItem(path: string): FsItem | null; + ops: ContextFileSystem; } -export const FsContext = createContext({ +const notDefined = () => { + throw new Error("Method not implemented."); +}; + +export const FsContext = createContext({ root: undefined, - getItem: () => null + getItem: () => null, + ops: { + copyFile: notDefined, + createDir: notDefined, + createDirWithParents: notDefined, + createFile: notDefined, + readDir: notDefined, + readToString: notDefined, + removeDir: notDefined, + removeDirRecursive: notDefined, + removeFile: notDefined, + renameFile: notDefined, + } }); diff --git a/src/wasmFs.ts b/src/wasmFs.ts new file mode 100644 index 0000000..298fcfe --- /dev/null +++ b/src/wasmFs.ts @@ -0,0 +1,93 @@ +import {BaseFileSystem} from "./fsContext.ts"; + +export default class WasmFs implements BaseFileSystem { + private readonly rootDirectoryHandle: FileSystemDirectoryHandle; + private readonly dirHandleCache: Map; + + constructor(root: FileSystemDirectoryHandle) { + this.rootDirectoryHandle = root; + this.dirHandleCache = new Map([["/", this.rootDirectoryHandle]]); + } + + static getParentPath(path: string): string { + return path.substring(0, path.lastIndexOf("/")); + } + + async getDirectoryHandle(path: string): Promise { + if (this.dirHandleCache.has(path)) { + return this.dirHandleCache.get(path)!; + } + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + return await parentHandle.getDirectoryHandle(path); + } + + async getFileHandle(path: string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + return await parentHandle.getFileHandle(path); + } + + async copyFile(from: string, to: string): Promise { + const src = await this.getFileHandle(from); + const dstParent = await this.getDirectoryHandle(WasmFs.getParentPath(to)); + const dst = await dstParent.getFileHandle(to, {create: true}); + const writable = await dst.createWritable(); + await writable.write(await src.getFile()); + await writable.close(); + return BigInt(0); + } + + async createDir(path: string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + await parentHandle.getDirectoryHandle(path, {create: true}); + } + async createDirWithParents(path: string): Promise { + const parts = path.split("/"); + let current = this.rootDirectoryHandle; + for (const part of parts) { + current = await current.getDirectoryHandle(part, {create: true}); + } + } + async createFile(path: string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + await parentHandle.getFileHandle(path, {create: true}); + } + async readDir(path: string): Promise<[string, boolean][]> { + const dirHandle = await this.getDirectoryHandle(path); + const entries: FileSystemHandle[] = []; + for await (const entry of dirHandle.values()) { + entries.push(entry); + } + return entries.map((entry) => [entry.name, entry.kind === "directory"]); + } + async readToString(path: string): Promise { + const handle = await this.getFileHandle(path); + const file = await handle.getFile(); + return await file.text(); + } + async removeDir(path: string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + await parentHandle.removeEntry(path); + } + async removeDirRecursive(path: string): Promise { + const dirHandle = await this.getDirectoryHandle(path); + const promises: Promise[] = []; + for await (const value of dirHandle.values()) { + promises.push(value.kind === "directory" ? this.removeDirRecursive(value.name) : this.removeFile(value.name)); + } + await Promise.all(promises); + await this.removeDir(path); + } + async removeFile(path: string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + await parentHandle.removeEntry(path); + } + async renameFile(from: string, to: string): Promise { + await this.copyFile(from, to); + await this.removeFile(from); + } +} + +export async function initEmptyFs(): Promise { + const root = await window.navigator.storage.getDirectory(); + return new WasmFs(root); +} diff --git a/tsconfig.json b/tsconfig.json index 06f68c3..dc8a131 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "target": "ES2020", + "target": "ES2022", "useDefineForClassFields": true, - "lib": ["ES2020", "DOM", "DOM.Iterable"], + "lib": ["ES2023", "DOM", "DOM.Iterable", "DOM.AsyncIterable"], "module": "ESNext", "skipLibCheck": true, From 058887af21192e07cc0149f04e78119adc0405e6 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 12 Jul 2024 17:24:07 -0400 Subject: [PATCH 12/23] Hook up sidebar to virtual FS --- src/components/Code.tsx | 9 +-- src/components/FilesystemModals.tsx | 99 ++++++++++++++++++++++++++++ src/components/FilesystemSidebar.tsx | 31 ++++----- src/components/FsActionsProvider.tsx | 24 +++++++ src/components/FsContextProvider.tsx | 16 ++--- src/fsContext.ts | 55 +++++++++++++++- src/wasmFs.ts | 20 ++++-- 7 files changed, 217 insertions(+), 37 deletions(-) create mode 100644 src/components/FilesystemModals.tsx create mode 100644 src/components/FsActionsProvider.tsx diff --git a/src/components/Code.tsx b/src/components/Code.tsx index 0381643..e2c3619 100644 --- a/src/components/Code.tsx +++ b/src/components/Code.tsx @@ -11,6 +11,7 @@ import {useSimulator} from "./simulator.ts"; import BrowserMenu from "./BrowserMenu.js"; import FilesystemSidebar from "./FilesystemSidebar.tsx"; import FsContextProvider from "./FsContextProvider.tsx"; +import FsActionsProvider from "./FsActionsProvider.tsx"; function Code() { @@ -38,7 +39,7 @@ function Code() { }, []); return ( - + {!window.__TAURI__ && }
@@ -54,12 +55,12 @@ function Code() {
+ load={load} error={error} stepBack={stepBack}/>
+ error={error}/>
@@ -70,7 +71,7 @@ function Code() {
- + ); } diff --git a/src/components/FilesystemModals.tsx b/src/components/FilesystemModals.tsx new file mode 100644 index 0000000..973eead --- /dev/null +++ b/src/components/FilesystemModals.tsx @@ -0,0 +1,99 @@ +import {Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react"; +import {FsContext, FsDir} from "../fsContext.ts"; +import React, {useContext, useMemo, useState} from "react"; + +function DirectorySelectorContent(props: {directory: FsDir}) { + const parentPath = props.directory.parent ? props.directory.parent.path() : null; + const name = props.directory.name; + return + {parentPath && {parentPath}/} + {name} + ; +} + +function DirectorySelectorOption(props: { directory: FsDir }) { + return +
+ +
+
; +} + +function buildDirectorySelectorOptions(directory: FsDir): React.ReactNode[] { + const options: React.ReactNode[] = []; + for (const child of Object.values(directory.children)) { + if (child.isDir) { + options.push(...buildDirectorySelectorOptions(child)); + } + } + return options; +} + +export function CreateFileModal(props: {folder: FsDir, closeModal: () => unknown, onSuccess: (filename: string) => unknown, creatingDirectory: boolean, setAlternateDirectory: (directory: FsDir) => unknown}) { + const [name, setName] = useState(""); + const fs = useContext(FsContext); + const options = useMemo(() => buildDirectorySelectorOptions(fs.root!), [fs.root]); + return +
+
+
+

+ Create {props.creatingDirectory ? "Folder" : "File"} +

+ +
+
+ + setName(e.target.value)} + value={name}/> + + +
+ + + + "â–¾" + + + + + {options} + +
+
+
+
+ + +
+
+
+
; +} diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index 1318702..9af2b8f 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -1,30 +1,31 @@ -import {useContext, useMemo, useState} from "react"; -import {AbstractFsFile, FsContext, FsDir} from "../fsContext.ts"; +import {useContext, useReducer, useState} from "react"; +import {AbstractFsFile, FsActionsContext, FsContext, FsDir} from "../fsContext.ts"; -export function FileSidebar(props: {file: AbstractFsFile}) { - return {props.file.name}; +export function FileSidebar(props: {file: AbstractFsFile, clickable?: boolean}) { + return {props.file.name}; } -export function FolderSidebar(props: {folder: FsDir}) { - const [expanded, setExpanded] = useState(true); // Set for development - const children = useMemo(() => { - return [...props.folder.children.values()].map((child) => { - return child.isDir ? : ; - }); - }, [props.folder.children]); +export function FolderSidebar(props: {folder: FsDir, hash: number}) { + const locked = props.folder.parent === null; // Root directory cannot be collapsed + const [expanded, setExpanded] = useState(locked); // Set for development return
- setExpanded(!expanded)}>{expanded ? "V" : ">"} - {expanded &&
{children}
} + setExpanded(!expanded)) : undefined}>{expanded ? "â–¼" : "â–¶"} + {expanded &&
{Object.values(props.folder.children).map((child) => { + return child.isDir ? : ; + })}
}
; } export default function FilesystemSidebar() { const fs = useContext(FsContext); + const actions = useContext(FsActionsContext); + const [, setCounter] = useReducer((x) => x + 1, 0); return
- {fs.root ? : "No filesystem loaded, create a file or open a directory."} + {fs.root ? : "No filesystem loaded, create a file or open a directory."}
- + +
; } diff --git a/src/components/FsActionsProvider.tsx b/src/components/FsActionsProvider.tsx new file mode 100644 index 0000000..531bb98 --- /dev/null +++ b/src/components/FsActionsProvider.tsx @@ -0,0 +1,24 @@ +import {PropsWithChildren, useMemo, useState} from "react"; +import {filename, FsActions, FsActionsContext, FsDir} from "../fsContext.ts"; +import {CreateFileModal} from "./FilesystemModals.tsx"; + +export default function FsActionsProvider(props: PropsWithChildren) { + const [createFileModalDir, setCreateFileModalDir] = useState(null); + const [createDirModalDir, setCreateDirModalDir] = useState(null); + const [createFileModalOnSuccessHandler, setCreateFileModalOnSuccessHandler] = useState<((filename: string) => unknown) | null>(null); + const [createDirModalOnSuccessHandler, setCreateDirModalOnSuccessHandler] = useState<((filename: string) => unknown) | null>(null); + const actions: FsActions = useMemo(() => ({ + showCreateFileModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => { + setCreateFileModalDir(folder); + setCreateFileModalOnSuccessHandler(() => onSuccess); // passing a callback to setState calls the callback to set the state. + }, + showCreateDirModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => { + setCreateDirModalDir(folder); + setCreateDirModalOnSuccessHandler(() => onSuccess); // passing a callback to setState calls the callback to set the state. + }, + }), []); + return + {(createFileModalDir !== null && createFileModalOnSuccessHandler !== null) && setCreateFileModalDir(null)} creatingDirectory={false} setAlternateDirectory={setCreateFileModalDir} />} + {(createDirModalDir !== null && createDirModalOnSuccessHandler !== null) && setCreateDirModalDir(null)} creatingDirectory={true} setAlternateDirectory={setCreateDirModalDir} />} + {props.children}; +} diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 06d5dff..466a930 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -26,7 +26,7 @@ export default function FsContextProvider(props: PropsWithChildren) { let current: FsDir = root; for (let num = 0; num < paths.length; num++) { const path_part = paths[num]; - const next = current.children.get(path_part); + const next = current.getChild(path_part); if (!next || (num !== paths.length - 1 && !next.isDir)) { return null; } @@ -72,12 +72,12 @@ export default function FsContextProvider(props: PropsWithChildren) { let current = parent; for (let i = 0; i < pieces.length; i++) { const piece = pieces[i]; - if (!current.children.has(piece)) { + if (!current.getChild(piece)) { const part = await FsOps.createDir(current, piece); current.addChild(part); current = part; } else { - const part = current.children.get(piece)!; + const part = current.getChild(piece)!; if (!part.isDir) { throw new Error(`Path ${joinPath(parent, ...pieces.slice(0, i))} already exists as a file.`); } @@ -111,7 +111,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const removeFile: ContextFileSystem["removeFile"] = async (file: FsFile) => { await fsProvider!.removeFile(file.path()); - file.parent.children.delete(file.name); + file.parent.removeChild(file.name); }; const removeDir: ContextFileSystem["removeDir"] = async (dir: FsDir) => { @@ -119,7 +119,7 @@ export default function FsContextProvider(props: PropsWithChildren) { throw new Error("Cannot remove root directory."); } await fsProvider!.removeDir(dir.path()); - dir.parent.children.delete(dir.name); + dir.parent.removeChild(dir.name); }; const removeDirRecursive: ContextFileSystem["removeDirRecursive"] = async (dir: FsDir) => { @@ -127,7 +127,7 @@ export default function FsContextProvider(props: PropsWithChildren) { throw new Error("Cannot remove root directory."); } await fsProvider!.removeDirRecursive(dir.path()); - dir.parent.children.delete(dir.name); + dir.parent.removeChild(dir.name); }; const renameFile: ContextFileSystem["renameFile"] = async (file: FsFile, newPath: string) => { @@ -137,9 +137,9 @@ export default function FsContextProvider(props: PropsWithChildren) { throw new Error(`Parent directory of ${newPath} does not exist.`); } await fsProvider!.renameFile(file.path(), newPath); - file.parent.children.delete(file.name); + file.parent.removeChild(file.name); file.name = newName; - file.parent.children.set(newName, file); + file.parent.addChild(file); return file; }; diff --git a/src/fsContext.ts b/src/fsContext.ts index f836ec5..c231325 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -17,7 +17,7 @@ export abstract class AbstractFsFile { } } -export class FsFile extends AbstractFsFile{ +export class FsFile extends AbstractFsFile { public isDir = false as const; public parent: FsDir; @@ -29,14 +29,54 @@ export class FsFile extends AbstractFsFile{ export class FsDir extends AbstractFsFile { public isDir = true as const; - public children: Map = new Map(); + private fsChildren: Map = new Map(); + private modificationCounter = 0; // This helps to track when the directory was last modified constructor(name: string, parent: FsDir | null) { super(name, true, parent); } + private get counter(): number { + return this.modificationCounter; + } + + private set counter(value: number) { + this.modificationCounter = value; + this.parent?.counter && (this.parent.counter++); + } + + /** + * Get the hash of when the directory was last modified. + * + * The value of the number is not significant, only that every time the + * directory's children are modified the number is incremented. + */ + public get modifiedHash(): number { + return this.counter; + } + addChild(child: FsItem) { - this.children.set(child.name, child); + this.fsChildren.set(child.name, child); + this.counter++; + } + + removeChild(child: FsItem | string) { + this.fsChildren.delete(typeof child === "string" ? child : child.name); + this.counter++; + } + + getChild(name: string): FsItem | undefined { + return this.fsChildren.get(name); + } + + + public get children(): Readonly> { + return Object.fromEntries(this.fsChildren.entries()); + } + + public set children(children: Map | Readonly>) { + this.fsChildren = children instanceof Map ? children : new Map(Object.entries(children)); + this.counter++; } } @@ -151,3 +191,12 @@ export const FsContext = createContext({ renameFile: notDefined, } }); + +export interface FsActions { + showCreateFileModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => void; + showCreateDirModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => void; +} +export const FsActionsContext = createContext({ + showCreateFileModal: notDefined, + showCreateDirModal: notDefined, +}); diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 298fcfe..5480c0f 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -1,4 +1,4 @@ -import {BaseFileSystem} from "./fsContext.ts"; +import {BaseFileSystem, filename} from "./fsContext.ts"; export default class WasmFs implements BaseFileSystem { private readonly rootDirectoryHandle: FileSystemDirectoryHandle; @@ -23,13 +23,15 @@ export default class WasmFs implements BaseFileSystem { async getFileHandle(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - return await parentHandle.getFileHandle(path); + const basename = filename(path); + return await parentHandle.getFileHandle(basename); } async copyFile(from: string, to: string): Promise { const src = await this.getFileHandle(from); const dstParent = await this.getDirectoryHandle(WasmFs.getParentPath(to)); - const dst = await dstParent.getFileHandle(to, {create: true}); + const dstFilename = filename(to); + const dst = await dstParent.getFileHandle(dstFilename, {create: true}); const writable = await dst.createWritable(); await writable.write(await src.getFile()); await writable.close(); @@ -38,7 +40,8 @@ export default class WasmFs implements BaseFileSystem { async createDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - await parentHandle.getDirectoryHandle(path, {create: true}); + const folderName = filename(path); + await parentHandle.getDirectoryHandle(folderName, {create: true}); } async createDirWithParents(path: string): Promise { const parts = path.split("/"); @@ -49,7 +52,8 @@ export default class WasmFs implements BaseFileSystem { } async createFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - await parentHandle.getFileHandle(path, {create: true}); + const fileName = filename(path); + await parentHandle.getFileHandle(fileName, {create: true}); } async readDir(path: string): Promise<[string, boolean][]> { const dirHandle = await this.getDirectoryHandle(path); @@ -66,7 +70,8 @@ export default class WasmFs implements BaseFileSystem { } async removeDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - await parentHandle.removeEntry(path); + const folderName = filename(path); + await parentHandle.removeEntry(folderName); } async removeDirRecursive(path: string): Promise { const dirHandle = await this.getDirectoryHandle(path); @@ -79,7 +84,8 @@ export default class WasmFs implements BaseFileSystem { } async removeFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - await parentHandle.removeEntry(path); + const fileName = filename(path); + await parentHandle.removeEntry(fileName); } async renameFile(from: string, to: string): Promise { await this.copyFile(from, to); From 8d305e457fc1616a3c09f0c3d5a3779f3832444b Mon Sep 17 00:00:00 2001 From: Aleks Bekker Date: Tue, 16 Jul 2024 14:00:38 -0400 Subject: [PATCH 13/23] Implement write in file system interface --- rezasm-app/rezasm-tauri/src/file_system.rs | 2 ++ rezasm-app/rezasm-wasm/src/file_system.rs | 3 +++ src/file_system.ts | 13 +++++++++++++ 3 files changed, 18 insertions(+) diff --git a/rezasm-app/rezasm-tauri/src/file_system.rs b/rezasm-app/rezasm-tauri/src/file_system.rs index ed90de6..d5a4043 100644 --- a/rezasm-app/rezasm-tauri/src/file_system.rs +++ b/rezasm-app/rezasm-tauri/src/file_system.rs @@ -31,6 +31,7 @@ void_or_error_command!(tauri_create_dir_with_parents, std::fs::create_dir_all, p void_or_error_command!(tauri_create_file, std::fs::File::create, path: &str); void_or_error_command!(tauri_remove_file, std::fs::remove_file, path: &str); void_or_error_command!(tauri_rename, std::fs::rename, from: &str, to: &str); +void_or_error_command!(tauri_write_file, std::fs::write, path: &str, contents: &str); // Can only delete empty directory void_or_error_command!(tauri_remove_dir, std::fs::remove_dir, path: &str); @@ -62,5 +63,6 @@ lazy_static::lazy_static! { tauri_remove_dir_recursive, tauri_remove_file, tauri_rename, + tauri_write_file, ]); } diff --git a/rezasm-app/rezasm-wasm/src/file_system.rs b/rezasm-app/rezasm-wasm/src/file_system.rs index 28fff54..3c55e51 100644 --- a/rezasm-app/rezasm-wasm/src/file_system.rs +++ b/rezasm-app/rezasm-wasm/src/file_system.rs @@ -31,3 +31,6 @@ pub fn wasm_remove_dir(_path: &str) { todo!() } #[wasm_bindgen] pub fn wasm_remove_dir_recursive(_path: &str) { todo!() } + +#[wasm_bindgen] +pub fn wasm_write_file(_path: &str, _contents: &str) { todo!() } diff --git a/src/file_system.ts b/src/file_system.ts index 7714f16..e9dc2d9 100644 --- a/src/file_system.ts +++ b/src/file_system.ts @@ -133,6 +133,18 @@ interface FileSystem { * ``` */ rename(props: {from: string, to: string}): Promise; + + /** + * Writes a string to a file. + * + * @param props a record with the `path` file path and `contents` which holds the contents of the new file + * @returns an empty promise. + * + * @example ```typescript + * await fs.rename({path: "some/path", contents: "this line will be the only contents of the file"}); + * ``` + */ + writeFile(props: {path: string, contents: string}): Promise; } /** @@ -152,6 +164,7 @@ const fs = { removeDirRecursive: get_rust_function("remove_dir_recursive", ["path"]), removeFile: get_rust_function("remove_file", ["path"]), rename: get_rust_function("rename", ["from", "to"]), + writeFile: get_rust_function("write_file", ["path", "contents"]), } as FileSystem; export default fs; From 2fa808d8e0fd0ab4f07ada52259dbe0406314ac2 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Tue, 16 Jul 2024 15:17:44 -0400 Subject: [PATCH 14/23] Make a working file/folder FS implementation --- src/components/Code.tsx | 2 +- src/components/FilesystemModals.tsx | 63 +++++++++++++++------------- src/components/FilesystemSidebar.tsx | 16 ++++--- src/components/FsActionsProvider.tsx | 2 +- src/components/FsContextProvider.tsx | 24 ++++++++++- src/fsContext.ts | 37 +++++++++------- src/wasmFs.ts | 44 +++++++++++++++++-- 7 files changed, 132 insertions(+), 56 deletions(-) diff --git a/src/components/Code.tsx b/src/components/Code.tsx index e2c3619..19cf32d 100644 --- a/src/components/Code.tsx +++ b/src/components/Code.tsx @@ -41,7 +41,7 @@ function Code() { return ( {!window.__TAURI__ && } -
+
diff --git a/src/components/FilesystemModals.tsx b/src/components/FilesystemModals.tsx index 973eead..ab38bf1 100644 --- a/src/components/FilesystemModals.tsx +++ b/src/components/FilesystemModals.tsx @@ -6,7 +6,7 @@ function DirectorySelectorContent(props: {directory: FsDir}) { const parentPath = props.directory.parent ? props.directory.parent.path() : null; const name = props.directory.name; return - {parentPath && {parentPath}/} + {parentPath && {parentPath === "/" ? null : parentPath}/} {name} ; } @@ -14,12 +14,12 @@ function DirectorySelectorContent(props: {directory: FsDir}) { function DirectorySelectorOption(props: { directory: FsDir }) { return -
- -
-
; + className="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 data-[focus]:bg-indigo-600 data-[focus]:text-white" + > +
+ +
+ ; } function buildDirectorySelectorOptions(directory: FsDir): React.ReactNode[] { @@ -60,33 +60,36 @@ export function CreateFileModal(props: {folder: FsDir, closeModal: () => unknown setName(e.target.value)} value={name}/> - -
- - - - "â–¾" - - + +
+ + + â–¾ + - - {options} - -
- + + {options} + +
+
diff --git a/src/components/FsActionsProvider.tsx b/src/components/FsActionsProvider.tsx index 531bb98..be34c61 100644 --- a/src/components/FsActionsProvider.tsx +++ b/src/components/FsActionsProvider.tsx @@ -1,5 +1,5 @@ import {PropsWithChildren, useMemo, useState} from "react"; -import {filename, FsActions, FsActionsContext, FsDir} from "../fsContext.ts"; +import { FsActions, FsActionsContext, FsDir} from "../fsContext.ts"; import {CreateFileModal} from "./FilesystemModals.tsx"; export default function FsActionsProvider(props: PropsWithChildren) { diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 466a930..330f2d3 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -1,6 +1,6 @@ import { BaseFileSystem, - ContextFileSystem, directoryname, + ContextFileSystem, directoryname, DummyFsOps, filename, FsContext, FsDir, @@ -40,6 +40,9 @@ export default function FsContextProvider(props: PropsWithChildren) { }, [root]); const FsOps: ContextFileSystem = useMemo(() => { + if (!fsProvider) { + return DummyFsOps; + } const copyFile: ContextFileSystem["copyFile"] = async (from: FsFile, toParent: FsDir, toName?: string) => { const fromPath = from.path(); const toFileName = toName ?? from.name; @@ -89,7 +92,10 @@ export default function FsContextProvider(props: PropsWithChildren) { }; const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { + console.debug("Starting: "); + console.debug(parent); const items = await fsProvider!.readDir(parent.path()); + console.debug(items); const map = new Map(); const dirs: FsDir[] = []; for (const [fileName, isDir] of items) { @@ -101,6 +107,7 @@ export default function FsContextProvider(props: PropsWithChildren) { } } parent.children = map; + console.debug(dirs); await Promise.all(dirs.map(readDir)); return map; }; @@ -143,6 +150,10 @@ export default function FsContextProvider(props: PropsWithChildren) { return file; }; + const writeFile: ContextFileSystem["writeFile"] = async (file: FsFile, contents: string) => { + await fsProvider!.writeFile(file.path(), contents); + }; + return { copyFile, createFile, @@ -154,6 +165,8 @@ export default function FsContextProvider(props: PropsWithChildren) { removeDir, removeDirRecursive, renameFile, + writeFile, + init: true }; }, [fsProvider, getItem]); @@ -162,6 +175,15 @@ export default function FsContextProvider(props: PropsWithChildren) { setRoot(new FsDir("/", null)); }, []); + useEffect(() => { + // Load dir cache on root change + console.debug(root, FsOps.init); + if (root && FsOps.init) { + console.debug(root); + FsOps.readDir(root); + } + }, [root, FsOps]); + return ; removeFile(path: string): Promise; renameFile(from: string, to: string): Promise; + writeFile(path: string, contents: string): Promise; } export interface ContextFileSystem { @@ -162,6 +165,8 @@ export interface ContextFileSystem { removeDirRecursive(path: FsDir): Promise; removeFile(path: FsFile): Promise; renameFile(from: FsFile, to: string): Promise; + writeFile(file: FsFile, contents: string): Promise; + init: boolean; } @@ -175,21 +180,25 @@ const notDefined = () => { throw new Error("Method not implemented."); }; +export const DummyFsOps: ContextFileSystem = { + copyFile: notDefined, + createDir: notDefined, + createDirWithParents: notDefined, + createFile: notDefined, + readDir: notDefined, + readToString: notDefined, + removeDir: notDefined, + removeDirRecursive: notDefined, + removeFile: notDefined, + renameFile: notDefined, + writeFile: notDefined, + init: false +}; + export const FsContext = createContext({ root: undefined, getItem: () => null, - ops: { - copyFile: notDefined, - createDir: notDefined, - createDirWithParents: notDefined, - createFile: notDefined, - readDir: notDefined, - readToString: notDefined, - removeDir: notDefined, - removeDirRecursive: notDefined, - removeFile: notDefined, - renameFile: notDefined, - } + ops: DummyFsOps, }); export interface FsActions { diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 5480c0f..1850a7d 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -1,4 +1,4 @@ -import {BaseFileSystem, filename} from "./fsContext.ts"; +import {BaseFileSystem, directoryname, filename} from "./fsContext.ts"; export default class WasmFs implements BaseFileSystem { private readonly rootDirectoryHandle: FileSystemDirectoryHandle; @@ -10,15 +10,25 @@ export default class WasmFs implements BaseFileSystem { } static getParentPath(path: string): string { - return path.substring(0, path.lastIndexOf("/")); + console.debug(path, path.indexOf("/"), path.lastIndexOf("/")); + if (path.indexOf("/") === path.lastIndexOf("/")) { + // There is only 1 /, the root directory. + return "/"; + } + return directoryname(path); } async getDirectoryHandle(path: string): Promise { if (this.dirHandleCache.has(path)) { return this.dirHandleCache.get(path)!; } - const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); - return await parentHandle.getDirectoryHandle(path); + const parentPath = WasmFs.getParentPath(path); + console.debug(parentPath); + const parentHandle = await this.getDirectoryHandle(parentPath); + const folderName = filename(path); + const handle = await parentHandle.getDirectoryHandle(folderName); + this.dirHandleCache.set(path, handle); + return handle; } async getFileHandle(path: string): Promise { @@ -27,10 +37,12 @@ export default class WasmFs implements BaseFileSystem { return await parentHandle.getFileHandle(basename); } + async copyFile(from: string, to: string): Promise { const src = await this.getFileHandle(from); const dstParent = await this.getDirectoryHandle(WasmFs.getParentPath(to)); const dstFilename = filename(to); + console.debug(`Copying ${from} to ${to} (parent: ${dstParent.name}, filename: ${dstFilename})`); const dst = await dstParent.getFileHandle(dstFilename, {create: true}); const writable = await dst.createWritable(); await writable.write(await src.getFile()); @@ -41,11 +53,13 @@ export default class WasmFs implements BaseFileSystem { async createDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); + console.debug(`Creating directory ${folderName} in ${parentHandle.name}`); await parentHandle.getDirectoryHandle(folderName, {create: true}); } async createDirWithParents(path: string): Promise { const parts = path.split("/"); let current = this.rootDirectoryHandle; + console.debug(`Creating parts: ${JSON.stringify(parts)}`); for (const part of parts) { current = await current.getDirectoryHandle(part, {create: true}); } @@ -53,29 +67,38 @@ export default class WasmFs implements BaseFileSystem { async createFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); + console.debug(`Creating file ${fileName} in ${parentHandle.name}`); await parentHandle.getFileHandle(fileName, {create: true}); } async readDir(path: string): Promise<[string, boolean][]> { + console.debug(path); const dirHandle = await this.getDirectoryHandle(path); + console.debug(dirHandle); const entries: FileSystemHandle[] = []; + console.debug(`Reading directory ${path}`, dirHandle); for await (const entry of dirHandle.values()) { entries.push(entry); } + console.debug(entries); return entries.map((entry) => [entry.name, entry.kind === "directory"]); } async readToString(path: string): Promise { const handle = await this.getFileHandle(path); + console.debug(`Reading file ${path}`); const file = await handle.getFile(); return await file.text(); } async removeDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); + console.debug(`Removing directory ${folderName} from ${parentHandle.name}`); await parentHandle.removeEntry(folderName); + this.dirHandleCache.delete(path); } async removeDirRecursive(path: string): Promise { const dirHandle = await this.getDirectoryHandle(path); const promises: Promise[] = []; + console.debug(`Removing directory ${dirHandle.name} recursively`); for await (const value of dirHandle.values()) { promises.push(value.kind === "directory" ? this.removeDirRecursive(value.name) : this.removeFile(value.name)); } @@ -85,12 +108,25 @@ export default class WasmFs implements BaseFileSystem { async removeFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); + console.debug(`Removing file ${fileName} from ${parentHandle.name}`); await parentHandle.removeEntry(fileName); } async renameFile(from: string, to: string): Promise { await this.copyFile(from, to); await this.removeFile(from); } + + async writeFile(path:string, contents:string): Promise { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + const fileName = filename(path); + console.debug(`Writing to file ${fileName} in ${parentHandle.name}`); + const fileHandle = await parentHandle.getFileHandle(fileName, {create: true}); + const writable = await fileHandle.createWritable(); + await writable.write(contents); + await writable.close(); + return BigInt(contents.length); + + } } export async function initEmptyFs(): Promise { From c25e6d416e248e8fed3398ae00d111801e143693 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 19 Jul 2024 17:48:52 -0400 Subject: [PATCH 15/23] Add new modal and project persistence --- src/components/FilesystemModals.tsx | 158 +++++++++++++++++--------- src/components/FilesystemSidebar.tsx | 11 +- src/components/FsActionsProvider.tsx | 8 ++ src/fsContext.ts | 21 ++++ src/projectData.ts | 36 ++++++ src/wasmFs.ts | 159 ++++++++++++++++++++++++++- 6 files changed, 341 insertions(+), 52 deletions(-) create mode 100644 src/projectData.ts diff --git a/src/components/FilesystemModals.tsx b/src/components/FilesystemModals.tsx index ab38bf1..0e6cf8e 100644 --- a/src/components/FilesystemModals.tsx +++ b/src/components/FilesystemModals.tsx @@ -1,8 +1,8 @@ -import {Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/react"; +import {Label, Listbox, ListboxButton, ListboxOption, ListboxOptions} from "@headlessui/react"; import {FsContext, FsDir} from "../fsContext.ts"; import React, {useContext, useMemo, useState} from "react"; -function DirectorySelectorContent(props: {directory: FsDir}) { +function DirectorySelectorContent(props: { directory: FsDir }) { const parentPath = props.directory.parent ? props.directory.parent.path() : null; const name = props.directory.name; return @@ -17,7 +17,7 @@ function DirectorySelectorOption(props: { directory: FsDir }) { className="group relative cursor-default select-none py-2 pl-3 pr-9 text-gray-900 data-[focus]:bg-indigo-600 data-[focus]:text-white" >
- +
; } @@ -32,21 +32,37 @@ function buildDirectorySelectorOptions(directory: FsDir): React.ReactNode[] { return options; } -export function CreateFileModal(props: {folder: FsDir, closeModal: () => unknown, onSuccess: (filename: string) => unknown, creatingDirectory: boolean, setAlternateDirectory: (directory: FsDir) => unknown}) { - const [name, setName] = useState(""); - const fs = useContext(FsContext); - const options = useMemo(() => buildDirectorySelectorOptions(fs.root!), [fs.root]); - return ) { + return
- - setName(e.target.value)} - value={name}/> - - -
- - - â–¾ - - - - {options} - -
-
-
-
- - + {props.children}
; } + +export function CreateFileModal(props: { + folder: FsDir, + closeModal: () => unknown, + onSuccess: (filename: string) => unknown, + creatingDirectory: boolean, + setAlternateDirectory: (directory: FsDir) => unknown +}) { + const [name, setName] = useState(""); + const fs = useContext(FsContext); + const options = useMemo(() => buildDirectorySelectorOptions(fs.root!), [fs.root]); + return +
+ + setName(e.target.value)} + value={name}/> + + +
+ + + â–¾ + + + + {options} + +
+
+
+ + { + if (name.includes("/")) { + alert("The name cannot contain a slash."); + } + (props.creatingDirectory ? fs.ops.createDir : fs.ops.createFile)(props.folder, name).then(() => { + props.closeModal(); + props.onSuccess(name); + }).catch((error) => { + console.error(`Error while creating ${props.creatingDirectory ? "folder" : "file"}: ${error}`); + alert(`Error while creating ${props.creatingDirectory ? "folder" : "file"}: ${error}`); + props.closeModal(); + }); + }}>Create + + Cancel + +
; +} + +export function SaveProjectModal(props: { closeModal: () => unknown }) { + const [name, setName] = useState(""); + const fs = useContext(FsContext); + return +
+ + setName(e.target.value)} + value={name}/> +
+ + { + fs.projectHandler!.saveProject(fs.root!, name); + } + }>Create + + Cancel + +
; +} diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index 5fe623e..bb582d5 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -30,8 +30,15 @@ export default function FilesystemSidebar() { {rootSidebar}
- - + + +
; } diff --git a/src/components/FsActionsProvider.tsx b/src/components/FsActionsProvider.tsx index be34c61..f58e508 100644 --- a/src/components/FsActionsProvider.tsx +++ b/src/components/FsActionsProvider.tsx @@ -7,6 +7,8 @@ export default function FsActionsProvider(props: PropsWithChildren) { const [createDirModalDir, setCreateDirModalDir] = useState(null); const [createFileModalOnSuccessHandler, setCreateFileModalOnSuccessHandler] = useState<((filename: string) => unknown) | null>(null); const [createDirModalOnSuccessHandler, setCreateDirModalOnSuccessHandler] = useState<((filename: string) => unknown) | null>(null); + const [showOpenProjectModal, setShowOpenProjectModal] = useState(false); + const [saveProjectModalRoot, setSaveProjectModalRoot] = useState(null); const actions: FsActions = useMemo(() => ({ showCreateFileModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => { setCreateFileModalDir(folder); @@ -16,6 +18,12 @@ export default function FsActionsProvider(props: PropsWithChildren) { setCreateDirModalDir(folder); setCreateDirModalOnSuccessHandler(() => onSuccess); // passing a callback to setState calls the callback to set the state. }, + showOpenProjectModal: () => { + setShowOpenProjectModal(true); + }, + showSaveProjectModal: (root: FsDir) => { + setSaveProjectModalRoot(root); + } }), []); return {(createFileModalDir !== null && createFileModalOnSuccessHandler !== null) && setCreateFileModalDir(null)} creatingDirectory={false} setAlternateDirectory={setCreateFileModalDir} />} diff --git a/src/fsContext.ts b/src/fsContext.ts index 6cacef0..7bf2625 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -1,5 +1,6 @@ /* eslint-disable no-unused-vars */ import { createContext } from "react"; +import {ProjectDataStore} from "./projectData.ts"; export abstract class AbstractFsFile { public name: string; @@ -174,6 +175,7 @@ export interface FsContext { root: FsDir | undefined; getItem(path: string): FsItem | null; ops: ContextFileSystem; + projectHandler: ProjectDataStore; } const notDefined = () => { @@ -195,17 +197,36 @@ export const DummyFsOps: ContextFileSystem = { init: false }; +class DummyProjectHandler extends ProjectDataStore { + async initDataStore() { + notDefined(); + } + async saveProject() { + notDefined(); + } + async getProject() { + notDefined(); + return null; + } +} + + export const FsContext = createContext({ root: undefined, getItem: () => null, ops: DummyFsOps, + projectHandler: new DummyProjectHandler() }); export interface FsActions { showCreateFileModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => void; showCreateDirModal: (folder: FsDir, onSuccess: (filename: string) => unknown) => void; + showOpenProjectModal: () => void; + showSaveProjectModal: (root: FsDir) => void; } export const FsActionsContext = createContext({ showCreateFileModal: notDefined, showCreateDirModal: notDefined, + showOpenProjectModal: notDefined, + showSaveProjectModal: notDefined, }); diff --git a/src/projectData.ts b/src/projectData.ts new file mode 100644 index 0000000..888b91f --- /dev/null +++ b/src/projectData.ts @@ -0,0 +1,36 @@ +import {FsDir} from "./fsContext.ts"; +import {fs} from "@tauri-apps/api"; +import {BaseDirectory} from "@tauri-apps/api/fs"; + +export interface ProjectDataEntry { + lastModified: number; // Unix timestamp +} + +export type ProjectData = Record; + +export abstract class ProjectDataStore { + protected savedProjects: ProjectData = {}; + abstract initDataStore(): Promise; + abstract saveProject(root: FsDir, projectName: string): Promise; + abstract getProject(projectName: string): Promise; +} + +export class TauriProjectDataStore extends ProjectDataStore { + async initDataStore(): Promise { + if (await fs.exists("projects.json", {dir: BaseDirectory.AppLocalData})) { + this.savedProjects = JSON.parse(await fs.readTextFile("projects.json", {dir: BaseDirectory.AppLocalData})); + } + } + + async saveProject(_: FsDir, projectName: string): Promise { + this.savedProjects[projectName] = {lastModified: Date.now()}; + await fs.writeTextFile("projects.json", JSON.stringify(this.savedProjects), {dir: BaseDirectory.AppLocalData}); + } + + async getProject(projectName: string): Promise { + if (!this.savedProjects[projectName]) { + return null; + } + return new FsDir("/", null); + } +} diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 1850a7d..8c0dd6d 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -1,4 +1,5 @@ -import {BaseFileSystem, directoryname, filename} from "./fsContext.ts"; +import {BaseFileSystem, type ContextFileSystem, directoryname, filename, FsDir, joinPath} from "./fsContext.ts"; +import {ProjectDataEntry, ProjectDataStore} from "./projectData.ts"; export default class WasmFs implements BaseFileSystem { private readonly rootDirectoryHandle: FileSystemDirectoryHandle; @@ -133,3 +134,159 @@ export async function initEmptyFs(): Promise { const root = await window.navigator.storage.getDirectory(); return new WasmFs(root); } + +/* + +SAVE/OPEN PROJECT FUNCTIONALITY + +*/ + + +function promisifyRequest(request: IDBRequest): Promise { + return new Promise((resolve, reject) => { + request.onsuccess = () => resolve(request.result); + request.onerror = () => reject(request.error); + }); +} + +function promisifyTransaction(transaction: IDBTransaction): Promise { + return new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve(); + transaction.onerror = () => reject(transaction.error); + }); +} + +interface IndexedDbProjectEntry extends ProjectDataEntry { + name: string; +} + +export interface IndexedDbProjectItem { + name: string; + isDir: boolean; + contents: string | null; + children: IndexedDbProjectItem[] | null; +} + + +export class WasmProjectDataStore extends ProjectDataStore { + private indexedDb: IDBDatabase | null = null; + private readonly ops: ContextFileSystem; + private readonly basefs: WasmFs; + private static readonly highestVersion = 1; + + constructor(ops: ContextFileSystem, basefs: WasmFs) { + super(); + this.ops = ops; + this.basefs = basefs; + } + /** + * Serialize a filesystem directory to an object suitable for IndexedDb insertion. + * @param input The directory to serialize + * @param ops Operations to interact with the filesystem + * @param nameOverride Used to override the name of the top-level directory, should only be used for the project root. + */ + private async serializeFsDirToIndexedDb(input: FsDir, nameOverride?: string): Promise { + const children = await Promise.all(Object.values(input.children).map(async (child) => { + if (child instanceof FsDir) { + return this.serializeFsDirToIndexedDb(child); + } else { + return { + name: child.name, + isDir: false, + contents: await this.ops.readToString(child), + children: null + }; + } + })); + return { + name: nameOverride ?? input.name, + isDir: true, + contents: null, + children + }; + } + + /** + * Deserialize an IndexedDb object to a filesystem directory. + * @param input The object to deserialize + * @param basefs The filesystem to deserialize to + * @param parentDirName The name of the parent directory (do not touch this when calling externally) + */ + private async deserializeIndexedDbToWasmFs(input: IndexedDbProjectItem, parentDirName = "/") { + if (!input.children) { + throw new Error("Invalid project data"); + } + await Promise.all(input.children.map(async (child) => { + const path = joinPath(parentDirName, child.name); + if (child.isDir) { + await this.basefs.createDir(path); + await this.deserializeIndexedDbToWasmFs(child, path); + } else { + await this.basefs.writeFile(path, child.contents!); + } + })); + } + + private async migrate(currentVersion: number): Promise { + if (this.indexedDb === null) { + throw new Error("IndexedDB not initialized"); + } + if (currentVersion <= 0) { + // Initial DB structure + const objectStore = this.indexedDb.createObjectStore("projects", {keyPath: "name"}); + objectStore.createIndex("lastSaved", "lastSaved", { unique: false }); + await promisifyTransaction(objectStore.transaction); + const objectStore2 = this.indexedDb.createObjectStore("projectData", {keyPath: "name"}); + await promisifyTransaction(objectStore2.transaction); + // currentVersion = 1; + } + + } + async initDataStore(): Promise { + const request = indexedDB.open("projectData", WasmProjectDataStore.highestVersion); + let migrationNeededVersion = -1; + request.onupgradeneeded = (event) => migrationNeededVersion = event.oldVersion; + this.indexedDb = await promisifyRequest(request); + if (migrationNeededVersion !== -1) { + await this.migrate(migrationNeededVersion); + } + const fetchTransaction = this.indexedDb.transaction("projects", "readonly"); + const objectStore = fetchTransaction.objectStore("projects"); + const data: IndexedDbProjectEntry[] = await promisifyRequest(objectStore.getAll()); + data.forEach(item => this.savedProjects[item.name] = item); + } + + async saveProject(item: FsDir, projectName: string): Promise { + if (this.indexedDb === null) { + throw new Error("IndexedDB not initialized"); + } + const transaction = this.indexedDb.transaction(["projects", "projectData"], "readwrite"); + const projectsObjectStore = transaction.objectStore("projects"); + const lastModifiedTime = Date.now(); + this.savedProjects[projectName] = {lastModified: lastModifiedTime}; + const entry: IndexedDbProjectEntry = { + lastModified: lastModifiedTime, + name: projectName + }; + projectsObjectStore.put(entry); + await promisifyTransaction(transaction); + const projectDataObjectStore = transaction.objectStore("projectData"); + projectDataObjectStore.put(await this.serializeFsDirToIndexedDb(item)); + await promisifyTransaction(transaction); + } + + async getProject(projectName: string): Promise { + if (this.indexedDb === null) { + throw new Error("IndexedDB not initialized"); + } + const transaction = this.indexedDb.transaction("projectData", "readonly"); + const objectStore = transaction.objectStore("projectData"); + const data: IndexedDbProjectItem | null = (await promisifyRequest(objectStore.get(projectName))) ?? null; + if (data === null) { + return null; + } else { + await this.deserializeIndexedDbToWasmFs(data); + return new FsDir("/", null); + } + } +} From d1e4818ef36d4e3a7edc80e16a1ed9d78d3d4cf3 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:13:54 -0400 Subject: [PATCH 16/23] Make open/saving work as expected --- src/components/FilesystemModals.tsx | 47 ++++++++++++++++--- src/components/FilesystemSidebar.tsx | 7 ++- src/components/FsActionsProvider.tsx | 4 +- src/components/FsContextProvider.tsx | 50 ++++++++++++++++---- src/fsContext.ts | 11 ++++- src/projectData.ts | 4 ++ src/wasmFs.ts | 68 +++++++++++++++------------- 7 files changed, 140 insertions(+), 51 deletions(-) diff --git a/src/components/FilesystemModals.tsx b/src/components/FilesystemModals.tsx index 0e6cf8e..76e420d 100644 --- a/src/components/FilesystemModals.tsx +++ b/src/components/FilesystemModals.tsx @@ -1,6 +1,7 @@ import {Label, Listbox, ListboxButton, ListboxOption, ListboxOptions} from "@headlessui/react"; import {FsContext, FsDir} from "../fsContext.ts"; import React, {useContext, useMemo, useState} from "react"; +import {ProjectDataEntry} from "../projectData.ts"; function DirectorySelectorContent(props: { directory: FsDir }) { const parentPath = props.directory.parent ? props.directory.parent.path() : null; @@ -71,7 +72,7 @@ function Modal(props: React.PropsWithChildren<{ heading: React.ReactNode, closeM Close modal -
+
{props.children}
@@ -137,24 +138,58 @@ export function CreateFileModal(props: { ; } -export function SaveProjectModal(props: { closeModal: () => unknown }) { +export function SaveProjectModal(props: { root: FsDir, closeModal: () => unknown }) { const [name, setName] = useState(""); const fs = useContext(FsContext); return
+ className="block mb-2 font-medium text-gray-900 dark:text-white">Project Name setName(e.target.value)} value={name}/> + {fs.projectHandler!.projects[name] !== undefined &&
Project with name {name} already exists and was last saved on {new Date(fs.projectHandler!.projects[name].lastModified).toLocaleString()}. Saving will overwrite any existing project data.
}
{ - fs.projectHandler!.saveProject(fs.root!, name); - } - }>Create + fs.projectHandler!.saveProject(props.root, name).then(props.closeModal); + }}>Save + + Cancel + +
; +} + +function ProjectItem(props: {name: string, data: ProjectDataEntry, selected: boolean, onSelect: () => unknown}) { + return ( + + ); +} + +export function OpenProjectModal(props: { closeModal: () => unknown }) { + const [selectedProjectName, setSelectedProjectName] = useState(null); + const fs = useContext(FsContext); + return +
+

Existing Projects

+ {Object.entries(fs.projectHandler!.projects).map(([name, data]) => selectedProjectName === name ? setSelectedProjectName(null) : setSelectedProjectName(name)} />)} +
+
Opening a project will delete ALL current project data if not saved. Make sure to save your existing project before opening a new project.
+ + { + fs.projectHandler!.getProject(selectedProjectName!).then((newRoot) => { + if (newRoot) { + fs.setRoot(newRoot); + } else { + alert("Invalid project found.") + } + props.closeModal(); + }); + }}>Open Cancel diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index bb582d5..e38f411 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -6,7 +6,7 @@ export function FileSidebar(props: {file: AbstractFsFile, clickable?: boolean}) } export function FolderSidebar(props: {folder: FsDir, hash: number}) { - console.debug(`Rerendering folder sidebar for ${props.folder.path()}, hash: ${props.hash}`); + // console.debug(`Rerendering folder sidebar for ${props.folder.path()}, hash: ${props.hash}`); const locked = props.folder.parent === null; // Root directory cannot be collapsed const [expanded, setExpanded] = useState(locked); // Set for development return
@@ -37,7 +37,10 @@ export default function FilesystemSidebar() { onClick={() => actions.showCreateFileModal(fs.root!, setCounter)}>Create File +
; diff --git a/src/components/FsActionsProvider.tsx b/src/components/FsActionsProvider.tsx index f58e508..fecd75d 100644 --- a/src/components/FsActionsProvider.tsx +++ b/src/components/FsActionsProvider.tsx @@ -1,6 +1,6 @@ import {PropsWithChildren, useMemo, useState} from "react"; import { FsActions, FsActionsContext, FsDir} from "../fsContext.ts"; -import {CreateFileModal} from "./FilesystemModals.tsx"; +import {CreateFileModal, OpenProjectModal, SaveProjectModal} from "./FilesystemModals.tsx"; export default function FsActionsProvider(props: PropsWithChildren) { const [createFileModalDir, setCreateFileModalDir] = useState(null); @@ -28,5 +28,7 @@ export default function FsActionsProvider(props: PropsWithChildren) { return {(createFileModalDir !== null && createFileModalOnSuccessHandler !== null) && setCreateFileModalDir(null)} creatingDirectory={false} setAlternateDirectory={setCreateFileModalDir} />} {(createDirModalDir !== null && createDirModalOnSuccessHandler !== null) && setCreateDirModalDir(null)} creatingDirectory={true} setAlternateDirectory={setCreateDirModalDir} />} + {showOpenProjectModal && setShowOpenProjectModal(false)} />} + {saveProjectModalRoot !== null && setSaveProjectModalRoot(null)} />} {props.children}; } diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 330f2d3..a29f162 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -1,20 +1,26 @@ import { BaseFileSystem, - ContextFileSystem, directoryname, DummyFsOps, + ContextFileSystem, + directoryname, + DummyFsOps, filename, FsContext, FsDir, FsFile, FsItem, + FsType, joinPath, parts } from "../fsContext.ts"; import {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; -import {initEmptyFs} from "../wasmFs.ts"; +import WasmFs, {initEmptyFs, WasmProjectDataStore} from "../wasmFs.ts"; +import {ProjectDataStore, TauriProjectDataStore} from "../projectData.ts"; export default function FsContextProvider(props: PropsWithChildren) { + const [fsType, setFsType] = useState(FsType.WasmLocal); const [root, setRoot] = useState(undefined); const [fsProvider, setFsProvider] = useState(undefined); + const [projectDataStore, setProjectDataStore] = useState(undefined); const getItem = useCallback((path: string) => { if (!root || !path) { return null; @@ -92,10 +98,10 @@ export default function FsContextProvider(props: PropsWithChildren) { }; const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { - console.debug("Starting: "); - console.debug(parent); + // console.debug("Starting: "); + // console.debug(parent); const items = await fsProvider!.readDir(parent.path()); - console.debug(items); + // console.debug(items); const map = new Map(); const dirs: FsDir[] = []; for (const [fileName, isDir] of items) { @@ -107,7 +113,7 @@ export default function FsContextProvider(props: PropsWithChildren) { } } parent.children = map; - console.debug(dirs); + // console.debug(dirs); await Promise.all(dirs.map(readDir)); return map; }; @@ -177,16 +183,40 @@ export default function FsContextProvider(props: PropsWithChildren) { useEffect(() => { // Load dir cache on root change - console.debug(root, FsOps.init); + // console.debug(root, FsOps.init); if (root && FsOps.init) { - console.debug(root); + // console.debug(root); FsOps.readDir(root); } }, [root, FsOps]); + useEffect(() => { + switch (fsType) { + case FsType.Tauri: { + setProjectDataStore(new TauriProjectDataStore()); + break; + } + case FsType.WasmLocal: { + if (fsProvider && fsProvider instanceof WasmFs) { + setProjectDataStore(new WasmProjectDataStore(FsOps, fsProvider)); + } + break; + } + } + }, [FsOps, fsProvider]); + + useEffect(() => { + if (projectDataStore) { + projectDataStore.initDataStore(); + } + }, [projectDataStore]); + return ; } diff --git a/src/fsContext.ts b/src/fsContext.ts index 7bf2625..5ad5961 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -176,6 +176,8 @@ export interface FsContext { getItem(path: string): FsItem | null; ops: ContextFileSystem; projectHandler: ProjectDataStore; + setRoot: (root: FsDir) => void; + setBaseFS: (base: BaseFileSystem) => void; } const notDefined = () => { @@ -215,7 +217,9 @@ export const FsContext = createContext({ root: undefined, getItem: () => null, ops: DummyFsOps, - projectHandler: new DummyProjectHandler() + projectHandler: new DummyProjectHandler(), + setRoot: notDefined, + setBaseFS: notDefined }); export interface FsActions { @@ -230,3 +234,8 @@ export const FsActionsContext = createContext({ showOpenProjectModal: notDefined, showSaveProjectModal: notDefined, }); + +export enum FsType { + Tauri = 0, + WasmLocal = 1 // This is WASM but it does not directly write to the filesystem. +} diff --git a/src/projectData.ts b/src/projectData.ts index 888b91f..50fe9ea 100644 --- a/src/projectData.ts +++ b/src/projectData.ts @@ -13,6 +13,10 @@ export abstract class ProjectDataStore { abstract initDataStore(): Promise; abstract saveProject(root: FsDir, projectName: string): Promise; abstract getProject(projectName: string): Promise; + + public get projects(): Readonly { + return this.savedProjects; + } } export class TauriProjectDataStore extends ProjectDataStore { diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 8c0dd6d..0f92e15 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -11,7 +11,7 @@ export default class WasmFs implements BaseFileSystem { } static getParentPath(path: string): string { - console.debug(path, path.indexOf("/"), path.lastIndexOf("/")); + // console.debug(path, path.indexOf("/"), path.lastIndexOf("/")); if (path.indexOf("/") === path.lastIndexOf("/")) { // There is only 1 /, the root directory. return "/"; @@ -24,7 +24,7 @@ export default class WasmFs implements BaseFileSystem { return this.dirHandleCache.get(path)!; } const parentPath = WasmFs.getParentPath(path); - console.debug(parentPath); + // console.debug(parentPath); const parentHandle = await this.getDirectoryHandle(parentPath); const folderName = filename(path); const handle = await parentHandle.getDirectoryHandle(folderName); @@ -43,7 +43,7 @@ export default class WasmFs implements BaseFileSystem { const src = await this.getFileHandle(from); const dstParent = await this.getDirectoryHandle(WasmFs.getParentPath(to)); const dstFilename = filename(to); - console.debug(`Copying ${from} to ${to} (parent: ${dstParent.name}, filename: ${dstFilename})`); + // console.debug(`Copying ${from} to ${to} (parent: ${dstParent.name}, filename: ${dstFilename})`); const dst = await dstParent.getFileHandle(dstFilename, {create: true}); const writable = await dst.createWritable(); await writable.write(await src.getFile()); @@ -54,13 +54,13 @@ export default class WasmFs implements BaseFileSystem { async createDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); - console.debug(`Creating directory ${folderName} in ${parentHandle.name}`); + // console.debug(`Creating directory ${folderName} in ${parentHandle.name}`); await parentHandle.getDirectoryHandle(folderName, {create: true}); } async createDirWithParents(path: string): Promise { const parts = path.split("/"); let current = this.rootDirectoryHandle; - console.debug(`Creating parts: ${JSON.stringify(parts)}`); + // console.debug(`Creating parts: ${JSON.stringify(parts)}`); for (const part of parts) { current = await current.getDirectoryHandle(part, {create: true}); } @@ -68,40 +68,41 @@ export default class WasmFs implements BaseFileSystem { async createFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); - console.debug(`Creating file ${fileName} in ${parentHandle.name}`); + // console.debug(`Creating file ${fileName} in ${parentHandle.name}`); await parentHandle.getFileHandle(fileName, {create: true}); } async readDir(path: string): Promise<[string, boolean][]> { - console.debug(path); + // console.debug(path); const dirHandle = await this.getDirectoryHandle(path); - console.debug(dirHandle); + // console.debug(dirHandle); const entries: FileSystemHandle[] = []; - console.debug(`Reading directory ${path}`, dirHandle); + // console.debug(`Reading directory ${path}`, dirHandle); for await (const entry of dirHandle.values()) { entries.push(entry); } - console.debug(entries); + // console.debug(entries); return entries.map((entry) => [entry.name, entry.kind === "directory"]); } async readToString(path: string): Promise { const handle = await this.getFileHandle(path); - console.debug(`Reading file ${path}`); + // console.debug(`Reading file ${path}`); const file = await handle.getFile(); return await file.text(); } async removeDir(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); - console.debug(`Removing directory ${folderName} from ${parentHandle.name}`); + // console.debug(`Removing directory ${folderName} from ${parentHandle.name}`); await parentHandle.removeEntry(folderName); this.dirHandleCache.delete(path); } async removeDirRecursive(path: string): Promise { const dirHandle = await this.getDirectoryHandle(path); const promises: Promise[] = []; - console.debug(`Removing directory ${dirHandle.name} recursively`); + // console.debug(`Removing directory ${dirHandle.name} recursively`); for await (const value of dirHandle.values()) { - promises.push(value.kind === "directory" ? this.removeDirRecursive(value.name) : this.removeFile(value.name)); + const truePath = joinPath(path, value.name); + promises.push(value.kind === "directory" ? this.removeDirRecursive(truePath) : this.removeFile(truePath)); } await Promise.all(promises); await this.removeDir(path); @@ -109,7 +110,7 @@ export default class WasmFs implements BaseFileSystem { async removeFile(path: string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); - console.debug(`Removing file ${fileName} from ${parentHandle.name}`); + // console.debug(`Removing file ${fileName} from ${parentHandle.name}`); await parentHandle.removeEntry(fileName); } async renameFile(from: string, to: string): Promise { @@ -120,7 +121,7 @@ export default class WasmFs implements BaseFileSystem { async writeFile(path:string, contents:string): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); - console.debug(`Writing to file ${fileName} in ${parentHandle.name}`); + // console.debug(`Writing to file ${fileName} in ${parentHandle.name}`); const fileHandle = await parentHandle.getFileHandle(fileName, {create: true}); const writable = await fileHandle.createWritable(); await writable.write(contents); @@ -227,16 +228,17 @@ export class WasmProjectDataStore extends ProjectDataStore { })); } - private async migrate(currentVersion: number): Promise { - if (this.indexedDb === null) { - throw new Error("IndexedDB not initialized"); - } + private async migrate(event: IDBVersionChangeEvent): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any -- This is a hack to get the result of the request because the DOM API typings are lacking here. + const db: IDBDatabase = (event.target! as any).result as IDBDatabase; + const currentVersion = event.oldVersion; + // Make sure all objectStore variables are created before the first promisifyTransaction, otherwise it *will* error, if (currentVersion <= 0) { // Initial DB structure - const objectStore = this.indexedDb.createObjectStore("projects", {keyPath: "name"}); + const objectStore = db.createObjectStore("projects", {keyPath: "name"}); objectStore.createIndex("lastSaved", "lastSaved", { unique: false }); + const objectStore2 = db.createObjectStore("projectData", {keyPath: "name"}); await promisifyTransaction(objectStore.transaction); - const objectStore2 = this.indexedDb.createObjectStore("projectData", {keyPath: "name"}); await promisifyTransaction(objectStore2.transaction); // currentVersion = 1; } @@ -244,12 +246,8 @@ export class WasmProjectDataStore extends ProjectDataStore { } async initDataStore(): Promise { const request = indexedDB.open("projectData", WasmProjectDataStore.highestVersion); - let migrationNeededVersion = -1; - request.onupgradeneeded = (event) => migrationNeededVersion = event.oldVersion; + request.onupgradeneeded = (event) => this.migrate(event); this.indexedDb = await promisifyRequest(request); - if (migrationNeededVersion !== -1) { - await this.migrate(migrationNeededVersion); - } const fetchTransaction = this.indexedDb.transaction("projects", "readonly"); const objectStore = fetchTransaction.objectStore("projects"); const data: IndexedDbProjectEntry[] = await promisifyRequest(objectStore.getAll()); @@ -260,7 +258,7 @@ export class WasmProjectDataStore extends ProjectDataStore { if (this.indexedDb === null) { throw new Error("IndexedDB not initialized"); } - const transaction = this.indexedDb.transaction(["projects", "projectData"], "readwrite"); + const transaction = this.indexedDb.transaction("projects", "readwrite"); const projectsObjectStore = transaction.objectStore("projects"); const lastModifiedTime = Date.now(); this.savedProjects[projectName] = {lastModified: lastModifiedTime}; @@ -270,21 +268,29 @@ export class WasmProjectDataStore extends ProjectDataStore { }; projectsObjectStore.put(entry); await promisifyTransaction(transaction); - const projectDataObjectStore = transaction.objectStore("projectData"); - projectDataObjectStore.put(await this.serializeFsDirToIndexedDb(item)); - await promisifyTransaction(transaction); + const projectData = await this.serializeFsDirToIndexedDb(item, projectName); + const transaction2 = this.indexedDb.transaction("projectData", "readwrite"); + const projectDataObjectStore = transaction2.objectStore("projectData"); + projectDataObjectStore.put(projectData); + await promisifyTransaction(transaction2); } async getProject(projectName: string): Promise { + console.log(1); if (this.indexedDb === null) { throw new Error("IndexedDB not initialized"); } + console.log(2); const transaction = this.indexedDb.transaction("projectData", "readonly"); const objectStore = transaction.objectStore("projectData"); + console.log(3); const data: IndexedDbProjectItem | null = (await promisifyRequest(objectStore.get(projectName))) ?? null; + console.log(data); if (data === null) { return null; } else { + const existingData = await this.basefs.readDir("/"); + await Promise.all(existingData.map(([name, isDir]) => isDir ? this.basefs.removeDirRecursive("/" + name) : this.basefs.removeFile("/" + name))); await this.deserializeIndexedDbToWasmFs(data); return new FsDir("/", null); } From 728f28afdca29de804480576e9b2b15e0bc93803 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 26 Jul 2024 15:33:35 -0400 Subject: [PATCH 17/23] Rename to make connection clearer --- src/{file_system.ts => tauri_file_system.ts} | 24 ++++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) rename src/{file_system.ts => tauri_file_system.ts} (86%) diff --git a/src/file_system.ts b/src/tauri_file_system.ts similarity index 86% rename from src/file_system.ts rename to src/tauri_file_system.ts index e9dc2d9..61c3e7f 100644 --- a/src/file_system.ts +++ b/src/tauri_file_system.ts @@ -19,7 +19,7 @@ interface FileSystem { * let copiedBytes: bigint = await fs.copy({from: "path/to/file", to: "new/path/to/file"}); * ``` */ - copy_file(props: {from: string, to: string}): Promise; + copyFile(props: {from: string, to: string}): Promise; /** * Creates a new directory in the target filesystem. @@ -154,17 +154,17 @@ interface FileSystem { * browser based or system filesystem. */ const fs = { - copy_file: get_rust_function("copy", ["from", "to"]), - createDir: get_rust_function("create_dir", ["path"]), - createDirWithParents: get_rust_function("create_dir_with_parents", ["path"]), - createFile: get_rust_function("create_file", ["path"]), - readDir: get_rust_function("read_dir", ["path"]), - readToString: get_rust_function("read_to_string", ["path"]), - removeDir: get_rust_function("remove_dir", ["path"]), - removeDirRecursive: get_rust_function("remove_dir_recursive", ["path"]), - removeFile: get_rust_function("remove_file", ["path"]), - rename: get_rust_function("rename", ["from", "to"]), - writeFile: get_rust_function("write_file", ["path", "contents"]), + copyFile: get_rust_function("copy", ["from", "to"]), + createDir: get_rust_function("create_dir", ["path"]), + createDirWithParents: get_rust_function("create_dir_with_parents", ["path"]), + createFile: get_rust_function("create_file", ["path"]), + readDir: get_rust_function("read_dir", ["path"]), + readToString: get_rust_function("read_to_string", ["path"]), + removeDir: get_rust_function("remove_dir", ["path"]), + removeDirRecursive: get_rust_function("remove_dir_recursive", ["path"]), + removeFile: get_rust_function("remove_file", ["path"]), + rename: get_rust_function("rename", ["from", "to"]), + writeFile: get_rust_function("write_file", ["path", "contents"]), } as FileSystem; export default fs; From 2428f9ac48ceec9a34276fb1fc99712c438ffe71 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 26 Jul 2024 17:27:57 -0400 Subject: [PATCH 18/23] Fix rust functions validation --- src/fsContext.ts | 21 ++------------------- src/rust_functions.ts | 27 ++++++++++++++++++++++++++- src/tauri_file_system.ts | 2 +- 3 files changed, 29 insertions(+), 21 deletions(-) diff --git a/src/fsContext.ts b/src/fsContext.ts index 5ad5961..52e47d3 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -1,6 +1,7 @@ /* eslint-disable no-unused-vars */ import { createContext } from "react"; import {ProjectDataStore} from "./projectData.ts"; +import {FileSystem} from "./tauri_file_system.ts"; export abstract class AbstractFsFile { public name: string; @@ -135,25 +136,7 @@ export function directoryname(path: string): string { return path.substring(0, path.lastIndexOf("/")); } -export interface BaseFileSystem { - copyFile(from: string, to: string): Promise; - createDir(path: string): Promise; - createDirWithParents(path: string): Promise; - createFile(path: string): Promise; - - /** - * Read a directory and return its contents - * @param path The path to read - * @returns A list of [name, isDir] tuples - */ - readDir(path: string): Promise<[string, boolean][]>; - readToString(path: string): Promise; - removeDir(path: string): Promise; - removeDirRecursive(path: string): Promise; - removeFile(path: string): Promise; - renameFile(from: string, to: string): Promise; - writeFile(path: string, contents: string): Promise; -} +type BaseFileSystem = FileSystem; export interface ContextFileSystem { copyFile(from: FsFile, toParent: FsDir, toName?: string): Promise; diff --git a/src/rust_functions.ts b/src/rust_functions.ts index 1b04876..8caef09 100644 --- a/src/rust_functions.ts +++ b/src/rust_functions.ts @@ -35,9 +35,31 @@ const callWorkerFunction = (message: Message) => { }); }; +function isValidWasmCommandString(str: string): str is ValidWasmCommandStrings { + // We could use an array, but this way if we add/remove a wasm function we will get a big error about it. + const wasmData: Record = { + "load": null, + "step": null, + "step_back": null, + "reset": null, + "stop": null, + "is_completed": null, + "get_exit_status": null, + "get_register_value": null, + "get_register_names": null, + "get_register_values": null, + "get_memory_bounds": null, + "get_memory_slice": null, + "get_word_size": null, + "receive_input": null, + "initialize_backend": null, + }; + return str in wasmData; +} + // name is the name of the function in rust (without "tauri_" or "wasm_") // shape is an array describing the keys that are expected to be defined in props -export const get_rust_function = (name: ValidWasmCommandStrings, shape?: string[]) => { +export const get_rust_function = (name: string, shape?: string[]) => { shape = shape ?? []; const shapeSet = new Set(shape); return async (props: Record) => { @@ -49,6 +71,9 @@ export const get_rust_function = (name: ValidWasmCommandStrings, shape?: string[ if (window.__TAURI_IPC__) { return invoke(`tauri_${name}`, props); } else { + if (!isValidWasmCommandString(name)) { + throw new Error(`Function '${name}' is not a valid wasm command`); + } while (! await isWasmLoaded()) { // wait } diff --git a/src/tauri_file_system.ts b/src/tauri_file_system.ts index 61c3e7f..a7559f8 100644 --- a/src/tauri_file_system.ts +++ b/src/tauri_file_system.ts @@ -7,7 +7,7 @@ import { get_rust_function } from "./rust_functions"; * * This interface is implemented by the exported `fs` object. */ -interface FileSystem { +export interface FileSystem { /** * Copies a file in the target filesystem From 1a2652744b61e1978e57b8d8513a91c0e55b407e Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 2 Aug 2024 17:00:47 -0400 Subject: [PATCH 19/23] Work on getting open folder/open project working --- rezasm-app/rezasm-wasm/src/file_system.rs | 36 ------------- rezasm-app/rezasm-wasm/src/lib.rs | 1 - src/components/FilesystemModals.tsx | 4 +- src/components/FilesystemSidebar.tsx | 63 ++++++++++++++++++++++- src/components/FsContextProvider.tsx | 35 ++++++++----- src/fsContext.ts | 15 ++++-- src/projectData.ts | 5 ++ src/wasmFs.ts | 46 ++++++++--------- 8 files changed, 122 insertions(+), 83 deletions(-) delete mode 100644 rezasm-app/rezasm-wasm/src/file_system.rs diff --git a/rezasm-app/rezasm-wasm/src/file_system.rs b/rezasm-app/rezasm-wasm/src/file_system.rs deleted file mode 100644 index 3c55e51..0000000 --- a/rezasm-app/rezasm-wasm/src/file_system.rs +++ /dev/null @@ -1,36 +0,0 @@ -use wasm_bindgen::prelude::wasm_bindgen; - -type StringResult = Result; - -#[wasm_bindgen] -pub fn wasm_copy_file(_from: &str, _to: &str) -> StringResult { todo!() } - -#[wasm_bindgen] -pub fn wasm_read_to_string(_path: &str) -> StringResult { todo!() } - -#[wasm_bindgen] -pub fn wasm_create_dir(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_read_dir(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_create_dir_with_parents(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_create_file(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_remove_file(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_rename(_from: &str, _to: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_remove_dir(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_remove_dir_recursive(_path: &str) { todo!() } - -#[wasm_bindgen] -pub fn wasm_write_file(_path: &str, _contents: &str) { todo!() } diff --git a/rezasm-app/rezasm-wasm/src/lib.rs b/rezasm-app/rezasm-wasm/src/lib.rs index d53ba2b..509a647 100644 --- a/rezasm-app/rezasm-wasm/src/lib.rs +++ b/rezasm-app/rezasm-wasm/src/lib.rs @@ -1,5 +1,4 @@ mod wasm_writer; -mod file_system; extern crate rezasm_core; extern crate rezasm_web_core; diff --git a/src/components/FilesystemModals.tsx b/src/components/FilesystemModals.tsx index 76e420d..79680df 100644 --- a/src/components/FilesystemModals.tsx +++ b/src/components/FilesystemModals.tsx @@ -181,11 +181,11 @@ export function OpenProjectModal(props: { closeModal: () => unknown }) { { - fs.projectHandler!.getProject(selectedProjectName!).then((newRoot) => { + fs.projectHandler!.closeProject().then(() => fs.projectHandler.getProject(selectedProjectName!)).then((newRoot) => { if (newRoot) { fs.setRoot(newRoot); } else { - alert("Invalid project found.") + alert("Invalid project found."); } props.closeModal(); }); diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index e38f411..5e083ce 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -1,5 +1,8 @@ import {useContext, useEffect, useMemo, useReducer, useState} from "react"; -import {AbstractFsFile, FsActionsContext, FsContext, FsDir} from "../fsContext.ts"; +import {AbstractFsFile, directoryname, FsActionsContext, FsContext, FsDir} from "../fsContext.ts"; +import {open} from "@tauri-apps/api/dialog"; +import tauri_file_system from "../tauri_file_system.ts"; +import {initEmptyFs} from "../wasmFs.ts"; export function FileSidebar(props: {file: AbstractFsFile, clickable?: boolean}) { return {props.file.name}; @@ -29,7 +32,63 @@ export default function FilesystemSidebar() { return
{rootSidebar}
- + diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index a29f162..bf31155 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -25,14 +25,20 @@ export default function FsContextProvider(props: PropsWithChildren) { if (!root || !path) { return null; } + if (path === "/") { + return root; + } const paths = path.split("/"); - if (paths[0] === root.name) { + if (paths[0] === root.name || paths[0] === "") { paths.shift(); } + console.log(paths); let current: FsDir = root; for (let num = 0; num < paths.length; num++) { + console.log(current); const path_part = paths[num]; const next = current.getChild(path_part); + console.log(next, !next, num !== paths.length, !next!.isDir); if (!next || (num !== paths.length - 1 && !next.isDir)) { return null; } @@ -41,6 +47,7 @@ export default function FsContextProvider(props: PropsWithChildren) { } current = next; } + console.log("Current: %o", current); return current; }, [root]); @@ -52,7 +59,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const copyFile: ContextFileSystem["copyFile"] = async (from: FsFile, toParent: FsDir, toName?: string) => { const fromPath = from.path(); const toFileName = toName ?? from.name; - await fsProvider!.copyFile(fromPath, joinPath(toParent, toFileName)); + await fsProvider!.copyFile({from: fromPath, to: joinPath(toParent, toFileName)}); const toFile = new FsFile(toFileName, toParent); toParent.addChild(toFile); return toFile; @@ -60,7 +67,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const createFile: ContextFileSystem["createFile"] = async(parent: FsDir, path: string) => { const targetPath = joinPath(parent, path); - await fsProvider!.createFile(targetPath); + await fsProvider!.createFile({path: targetPath}); const fileName = filename(targetPath); const newFile = new FsFile(fileName, parent); parent.addChild(newFile); @@ -69,7 +76,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const createDir: ContextFileSystem["createDir"] = async (parent: FsDir, path: string)=> { const targetPath = joinPath(parent, path); - await fsProvider!.createDir(targetPath); + await fsProvider!.createDir({path: targetPath}); const dirName = filename(targetPath); const newDir = new FsDir(dirName, parent); parent.addChild(newDir); @@ -82,7 +89,7 @@ export default function FsContextProvider(props: PropsWithChildren) { for (let i = 0; i < pieces.length; i++) { const piece = pieces[i]; if (!current.getChild(piece)) { - const part = await FsOps.createDir(current, piece); + const part = await createDir(current, piece); current.addChild(part); current = part; } else { @@ -100,7 +107,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { // console.debug("Starting: "); // console.debug(parent); - const items = await fsProvider!.readDir(parent.path()); + const items = await fsProvider!.readDir({path: parent.path()}); // console.debug(items); const map = new Map(); const dirs: FsDir[] = []; @@ -119,11 +126,11 @@ export default function FsContextProvider(props: PropsWithChildren) { }; const readToString: ContextFileSystem["readToString"] = async (file: FsFile) => { - return fsProvider!.readToString(file.path()); + return fsProvider!.readToString({path: file.path()}); }; const removeFile: ContextFileSystem["removeFile"] = async (file: FsFile) => { - await fsProvider!.removeFile(file.path()); + await fsProvider!.removeFile({path: file.path()}); file.parent.removeChild(file.name); }; @@ -131,7 +138,7 @@ export default function FsContextProvider(props: PropsWithChildren) { if (dir.parent === null) { throw new Error("Cannot remove root directory."); } - await fsProvider!.removeDir(dir.path()); + await fsProvider!.removeDir({path: dir.path()}); dir.parent.removeChild(dir.name); }; @@ -139,17 +146,17 @@ export default function FsContextProvider(props: PropsWithChildren) { if (dir.parent === null) { throw new Error("Cannot remove root directory."); } - await fsProvider!.removeDirRecursive(dir.path()); + await fsProvider!.removeDirRecursive({path: dir.path()}); dir.parent.removeChild(dir.name); }; - const renameFile: ContextFileSystem["renameFile"] = async (file: FsFile, newPath: string) => { + const rename: ContextFileSystem["rename"] = async (file: FsFile, newPath: string) => { const newName = filename(newPath); const newPathParent = getItem(directoryname(newPath)); if (!newPathParent) { throw new Error(`Parent directory of ${newPath} does not exist.`); } - await fsProvider!.renameFile(file.path(), newPath); + await fsProvider!.rename({from: file.path(), to: newPath}); file.parent.removeChild(file.name); file.name = newName; file.parent.addChild(file); @@ -157,7 +164,7 @@ export default function FsContextProvider(props: PropsWithChildren) { }; const writeFile: ContextFileSystem["writeFile"] = async (file: FsFile, contents: string) => { - await fsProvider!.writeFile(file.path(), contents); + await fsProvider!.writeFile({path: file.path(), contents}); }; return { @@ -170,7 +177,7 @@ export default function FsContextProvider(props: PropsWithChildren) { removeFile, removeDir, removeDirRecursive, - renameFile, + rename, writeFile, init: true }; diff --git a/src/fsContext.ts b/src/fsContext.ts index 52e47d3..1627b60 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -133,10 +133,13 @@ export function filename(path: string): string { } export function directoryname(path: string): string { + if (path.lastIndexOf("/") === 0) { + return "/"; + } return path.substring(0, path.lastIndexOf("/")); } -type BaseFileSystem = FileSystem; +export type BaseFileSystem = FileSystem; export interface ContextFileSystem { copyFile(from: FsFile, toParent: FsDir, toName?: string): Promise; @@ -148,7 +151,7 @@ export interface ContextFileSystem { removeDir(path: FsDir): Promise; removeDirRecursive(path: FsDir): Promise; removeFile(path: FsFile): Promise; - renameFile(from: FsFile, to: string): Promise; + rename(from: FsFile, to: string): Promise; writeFile(file: FsFile, contents: string): Promise; init: boolean; } @@ -177,18 +180,22 @@ export const DummyFsOps: ContextFileSystem = { removeDir: notDefined, removeDirRecursive: notDefined, removeFile: notDefined, - renameFile: notDefined, + rename: notDefined, writeFile: notDefined, init: false }; class DummyProjectHandler extends ProjectDataStore { async initDataStore() { - notDefined(); + notDefined(); } async saveProject() { notDefined(); } + async closeProject() { + notDefined(); + } + async getProject() { notDefined(); return null; diff --git a/src/projectData.ts b/src/projectData.ts index 50fe9ea..febc534 100644 --- a/src/projectData.ts +++ b/src/projectData.ts @@ -12,6 +12,7 @@ export abstract class ProjectDataStore { protected savedProjects: ProjectData = {}; abstract initDataStore(): Promise; abstract saveProject(root: FsDir, projectName: string): Promise; + abstract closeProject(): Promise; abstract getProject(projectName: string): Promise; public get projects(): Readonly { @@ -37,4 +38,8 @@ export class TauriProjectDataStore extends ProjectDataStore { } return new FsDir("/", null); } + + async closeProject(): Promise { + // No-op + } } diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 0f92e15..fd7c284 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -39,7 +39,7 @@ export default class WasmFs implements BaseFileSystem { } - async copyFile(from: string, to: string): Promise { + async copyFile({from, to}: {from: string, to: string}): Promise { const src = await this.getFileHandle(from); const dstParent = await this.getDirectoryHandle(WasmFs.getParentPath(to)); const dstFilename = filename(to); @@ -51,13 +51,13 @@ export default class WasmFs implements BaseFileSystem { return BigInt(0); } - async createDir(path: string): Promise { + async createDir({path}: {path: string}): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); // console.debug(`Creating directory ${folderName} in ${parentHandle.name}`); await parentHandle.getDirectoryHandle(folderName, {create: true}); } - async createDirWithParents(path: string): Promise { + async createDirWithParents({path}: {path: string}): Promise { const parts = path.split("/"); let current = this.rootDirectoryHandle; // console.debug(`Creating parts: ${JSON.stringify(parts)}`); @@ -65,13 +65,13 @@ export default class WasmFs implements BaseFileSystem { current = await current.getDirectoryHandle(part, {create: true}); } } - async createFile(path: string): Promise { + async createFile({path}: {path: string}): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); // console.debug(`Creating file ${fileName} in ${parentHandle.name}`); await parentHandle.getFileHandle(fileName, {create: true}); } - async readDir(path: string): Promise<[string, boolean][]> { + async readDir({path}: {path: string}): Promise<[string, boolean][]> { // console.debug(path); const dirHandle = await this.getDirectoryHandle(path); // console.debug(dirHandle); @@ -83,42 +83,42 @@ export default class WasmFs implements BaseFileSystem { // console.debug(entries); return entries.map((entry) => [entry.name, entry.kind === "directory"]); } - async readToString(path: string): Promise { + async readToString({path}: {path: string}): Promise { const handle = await this.getFileHandle(path); // console.debug(`Reading file ${path}`); const file = await handle.getFile(); return await file.text(); } - async removeDir(path: string): Promise { + async removeDir({path}: {path: string}): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const folderName = filename(path); // console.debug(`Removing directory ${folderName} from ${parentHandle.name}`); await parentHandle.removeEntry(folderName); this.dirHandleCache.delete(path); } - async removeDirRecursive(path: string): Promise { + async removeDirRecursive({path}: {path: string}): Promise { const dirHandle = await this.getDirectoryHandle(path); const promises: Promise[] = []; // console.debug(`Removing directory ${dirHandle.name} recursively`); for await (const value of dirHandle.values()) { const truePath = joinPath(path, value.name); - promises.push(value.kind === "directory" ? this.removeDirRecursive(truePath) : this.removeFile(truePath)); + promises.push(value.kind === "directory" ? this.removeDirRecursive({path: truePath}) : this.removeFile({path: truePath})); } await Promise.all(promises); - await this.removeDir(path); + await this.removeDir({path}); } - async removeFile(path: string): Promise { + async removeFile({path}: {path: string}): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); // console.debug(`Removing file ${fileName} from ${parentHandle.name}`); await parentHandle.removeEntry(fileName); } - async renameFile(from: string, to: string): Promise { - await this.copyFile(from, to); - await this.removeFile(from); + async rename({from, to}: {from: string, to: string}): Promise { + await this.copyFile({from, to}); + await this.removeFile({path: from}); } - async writeFile(path:string, contents:string): Promise { + async writeFile({path, contents}: {path: string, contents: string}): Promise { const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); const fileName = filename(path); // console.debug(`Writing to file ${fileName} in ${parentHandle.name}`); @@ -126,7 +126,6 @@ export default class WasmFs implements BaseFileSystem { const writable = await fileHandle.createWritable(); await writable.write(contents); await writable.close(); - return BigInt(contents.length); } } @@ -220,10 +219,10 @@ export class WasmProjectDataStore extends ProjectDataStore { await Promise.all(input.children.map(async (child) => { const path = joinPath(parentDirName, child.name); if (child.isDir) { - await this.basefs.createDir(path); + await this.basefs.createDir({path}); await this.deserializeIndexedDbToWasmFs(child, path); } else { - await this.basefs.writeFile(path, child.contents!); + await this.basefs.writeFile({path, contents: child.contents!}); } })); } @@ -275,22 +274,21 @@ export class WasmProjectDataStore extends ProjectDataStore { await promisifyTransaction(transaction2); } + async closeProject(): Promise { + const existingData = await this.basefs.readDir({path: "/"}); + await Promise.all(existingData.map(([name, isDir]) => isDir ? this.basefs.removeDirRecursive({path: "/" + name}) : this.basefs.removeFile({path: "/" +name}))); + } + async getProject(projectName: string): Promise { - console.log(1); if (this.indexedDb === null) { throw new Error("IndexedDB not initialized"); } - console.log(2); const transaction = this.indexedDb.transaction("projectData", "readonly"); const objectStore = transaction.objectStore("projectData"); - console.log(3); const data: IndexedDbProjectItem | null = (await promisifyRequest(objectStore.get(projectName))) ?? null; - console.log(data); if (data === null) { return null; } else { - const existingData = await this.basefs.readDir("/"); - await Promise.all(existingData.map(([name, isDir]) => isDir ? this.basefs.removeDirRecursive("/" + name) : this.basefs.removeFile("/" + name))); await this.deserializeIndexedDbToWasmFs(data); return new FsDir("/", null); } From 4b52b5f9b29bf87cd24c17a6b01dc039f13de31f Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Tue, 6 Aug 2024 15:14:33 -0400 Subject: [PATCH 20/23] Integrate Tauri with Wasm FS --- example/{ => example_dir}/print_sample.ez | 0 example/{ => import}/import_1.ez | 0 example/{ => import}/import_2.ez | 0 rezasm-app/rezasm-tauri/Cargo.toml | 2 +- rezasm-app/rezasm-tauri/tauri.conf.json | 11 +++++++ src/components/FilesystemSidebar.tsx | 1 - src/components/FsContextProvider.tsx | 38 ++++++++++++++--------- src/fsContext.ts | 10 +++--- src/fsShared.ts | 4 +++ src/projectData.ts | 21 ++++++++++--- src/rust_functions.ts | 6 ++-- src/tauri_file_system.ts | 9 +++++- src/wasmFs.ts | 6 +++- 13 files changed, 76 insertions(+), 32 deletions(-) rename example/{ => example_dir}/print_sample.ez (100%) rename example/{ => import}/import_1.ez (100%) rename example/{ => import}/import_2.ez (100%) create mode 100644 src/fsShared.ts diff --git a/example/print_sample.ez b/example/example_dir/print_sample.ez similarity index 100% rename from example/print_sample.ez rename to example/example_dir/print_sample.ez diff --git a/example/import_1.ez b/example/import/import_1.ez similarity index 100% rename from example/import_1.ez rename to example/import/import_1.ez diff --git a/example/import_2.ez b/example/import/import_2.ez similarity index 100% rename from example/import_2.ez rename to example/import/import_2.ez diff --git a/rezasm-app/rezasm-tauri/Cargo.toml b/rezasm-app/rezasm-tauri/Cargo.toml index 5c0f4de..7ddd3b9 100644 --- a/rezasm-app/rezasm-tauri/Cargo.toml +++ b/rezasm-app/rezasm-tauri/Cargo.toml @@ -18,7 +18,7 @@ rezasm-web-core = { path = "../../rezasm-source/rezasm-web-core" } lazy_static = "1.4.0" serde = { version = "1.0.188", features = ["derive"] } serde_json = "1.0.105" -tauri = { version = "1.4.1", features = ["shell-open"] } +tauri = { version = "1.4.1", features = [ "fs-all", "dialog-open", "shell-open"] } # this feature is used for production builds or when `devPath` points to the filesystem # DO NOT REMOVE!! diff --git a/rezasm-app/rezasm-tauri/tauri.conf.json b/rezasm-app/rezasm-tauri/tauri.conf.json index eb0ff6d..5b5dcee 100644 --- a/rezasm-app/rezasm-tauri/tauri.conf.json +++ b/rezasm-app/rezasm-tauri/tauri.conf.json @@ -1,4 +1,5 @@ { + "$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/v1.0.5/tooling/cli/schema.json", "build": { "beforeDevCommand": "npm run dev", "beforeBuildCommand": "npm run build", @@ -19,6 +20,16 @@ }, "window": { "all": false + }, + "dialog": { + "open": true + }, + "fs": { + "all": true, + "scope": [ + "$APPLOCALDATA/", + "$APPLOCALDATA/*" + ] } }, "bundle": { diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index 5e083ce..fed4f80 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -50,7 +50,6 @@ export default function FilesystemSidebar() { fs.setRoot(new FsDir(result, null)); }); } else { - fs.ops.rename(fs.getItem("/testfolder")!, "/testfolder2"); const inputElement = document.createElement("input"); inputElement.type = "file"; inputElement.webkitdirectory = true; diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index bf31155..5254cde 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -8,16 +8,15 @@ import { FsDir, FsFile, FsItem, - FsType, joinPath, parts } from "../fsContext.ts"; +import {FsType} from "../fsShared.ts"; import {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; import WasmFs, {initEmptyFs, WasmProjectDataStore} from "../wasmFs.ts"; import {ProjectDataStore, TauriProjectDataStore} from "../projectData.ts"; export default function FsContextProvider(props: PropsWithChildren) { - const [fsType, setFsType] = useState(FsType.WasmLocal); const [root, setRoot] = useState(undefined); const [fsProvider, setFsProvider] = useState(undefined); const [projectDataStore, setProjectDataStore] = useState(undefined); @@ -64,8 +63,8 @@ export default function FsContextProvider(props: PropsWithChildren) { toParent.addChild(toFile); return toFile; }; - - const createFile: ContextFileSystem["createFile"] = async(parent: FsDir, path: string) => { + + const createFile: ContextFileSystem["createFile"] = async (parent: FsDir, path: string) => { const targetPath = joinPath(parent, path); await fsProvider!.createFile({path: targetPath}); const fileName = filename(targetPath); @@ -73,8 +72,8 @@ export default function FsContextProvider(props: PropsWithChildren) { parent.addChild(newFile); return newFile; }; - - const createDir: ContextFileSystem["createDir"] = async (parent: FsDir, path: string)=> { + + const createDir: ContextFileSystem["createDir"] = async (parent: FsDir, path: string) => { const targetPath = joinPath(parent, path); await fsProvider!.createDir({path: targetPath}); const dirName = filename(targetPath); @@ -82,8 +81,8 @@ export default function FsContextProvider(props: PropsWithChildren) { parent.addChild(newDir); return newDir; }; - - const createDirWithParents: ContextFileSystem["createDirWithParents"] = async (parent: FsDir, path: string) => { + + const createDirWithParents: ContextFileSystem["createDirWithParents"] = async (parent: FsDir, path: string) => { const pieces = parts(path); let current = parent; for (let i = 0; i < pieces.length; i++) { @@ -103,7 +102,7 @@ export default function FsContextProvider(props: PropsWithChildren) { console.assert(current.path() === joinPath(parent, path), `Path ${current.path()} does not match ${joinPath(parent, path)}`); return current; }; - + const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { // console.debug("Starting: "); // console.debug(parent); @@ -120,7 +119,6 @@ export default function FsContextProvider(props: PropsWithChildren) { } } parent.children = map; - // console.debug(dirs); await Promise.all(dirs.map(readDir)); return map; }; @@ -191,24 +189,33 @@ export default function FsContextProvider(props: PropsWithChildren) { useEffect(() => { // Load dir cache on root change // console.debug(root, FsOps.init); - if (root && FsOps.init) { + if (root && FsOps.init && + (!(window.__TAURI__ && root.name === "/")) // Don't load root when on Tauri, this will brick the program since we recursively read all data. + ) { // console.debug(root); FsOps.readDir(root); } }, [root, FsOps]); useEffect(() => { - switch (fsType) { + if (!fsProvider) { + return; + } + switch (fsProvider.type) { case FsType.Tauri: { setProjectDataStore(new TauriProjectDataStore()); break; } case FsType.WasmLocal: { - if (fsProvider && fsProvider instanceof WasmFs) { - setProjectDataStore(new WasmProjectDataStore(FsOps, fsProvider)); + if (!(fsProvider instanceof WasmFs)) { + throw new Error("WasmLocal filesystem must be an instance of WasmFs"); } + setProjectDataStore(new WasmProjectDataStore(FsOps, fsProvider)); break; } + default: { + throw new Error(`Unknown filesystem type: ${fsProvider.type}`); + } } }, [FsOps, fsProvider]); @@ -223,7 +230,8 @@ export default function FsContextProvider(props: PropsWithChildren) { getItem, ops: FsOps, projectHandler: projectDataStore!, + type: fsProvider?.type, setRoot, setBaseFS: setFsProvider - }} children={props.children} />; + }} children={props.children}/>; } diff --git a/src/fsContext.ts b/src/fsContext.ts index 1627b60..841bb52 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -2,6 +2,7 @@ import { createContext } from "react"; import {ProjectDataStore} from "./projectData.ts"; import {FileSystem} from "./tauri_file_system.ts"; +import {type FsType} from "./fsShared.ts"; export abstract class AbstractFsFile { public name: string; @@ -15,7 +16,7 @@ export abstract class AbstractFsFile { } path(): string { - return this.parent ? ((this.parent.parent ? this.parent.path() : "") + "/" + this.name) : this.name; + return this.parent ? ((this.parent.path() !== "/" ? this.parent.path() : "") + "/" + this.name) : this.name; } } @@ -162,6 +163,7 @@ export interface FsContext { getItem(path: string): FsItem | null; ops: ContextFileSystem; projectHandler: ProjectDataStore; + type: FsType | undefined; setRoot: (root: FsDir) => void; setBaseFS: (base: BaseFileSystem) => void; } @@ -208,6 +210,7 @@ export const FsContext = createContext({ getItem: () => null, ops: DummyFsOps, projectHandler: new DummyProjectHandler(), + type: undefined, setRoot: notDefined, setBaseFS: notDefined }); @@ -224,8 +227,3 @@ export const FsActionsContext = createContext({ showOpenProjectModal: notDefined, showSaveProjectModal: notDefined, }); - -export enum FsType { - Tauri = 0, - WasmLocal = 1 // This is WASM but it does not directly write to the filesystem. -} diff --git a/src/fsShared.ts b/src/fsShared.ts new file mode 100644 index 0000000..c91005d --- /dev/null +++ b/src/fsShared.ts @@ -0,0 +1,4 @@ +export enum FsType { + Tauri = 1, + WasmLocal = 2 // This is WASM but it does not directly write to the filesystem. +} diff --git a/src/projectData.ts b/src/projectData.ts index febc534..d20923a 100644 --- a/src/projectData.ts +++ b/src/projectData.ts @@ -1,9 +1,11 @@ import {FsDir} from "./fsContext.ts"; import {fs} from "@tauri-apps/api"; import {BaseDirectory} from "@tauri-apps/api/fs"; +import tauri_file_system from "./tauri_file_system.ts"; export interface ProjectDataEntry { lastModified: number; // Unix timestamp + rootPath: string; } export type ProjectData = Record; @@ -22,21 +24,32 @@ export abstract class ProjectDataStore { export class TauriProjectDataStore extends ProjectDataStore { async initDataStore(): Promise { + if (!await fs.exists("", {dir: BaseDirectory.AppLocalData})) { + await fs.createDir("", {dir: BaseDirectory.AppLocalData, recursive: true}); + } if (await fs.exists("projects.json", {dir: BaseDirectory.AppLocalData})) { this.savedProjects = JSON.parse(await fs.readTextFile("projects.json", {dir: BaseDirectory.AppLocalData})); } } - async saveProject(_: FsDir, projectName: string): Promise { - this.savedProjects[projectName] = {lastModified: Date.now()}; + async saveProject(dir: FsDir, projectName: string): Promise { + this.savedProjects[projectName] = {lastModified: Date.now(), rootPath: dir.path()}; await fs.writeTextFile("projects.json", JSON.stringify(this.savedProjects), {dir: BaseDirectory.AppLocalData}); } async getProject(projectName: string): Promise { - if (!this.savedProjects[projectName]) { + const projectData = this.savedProjects[projectName]; + if (!projectData) { return null; } - return new FsDir("/", null); + try { + await tauri_file_system.readDir({path: projectData.rootPath}); + } catch (e) { + console.error(e); + throw(e); + // TODO: Handle project directory deleted + } + return new FsDir(projectData.rootPath, null); } async closeProject(): Promise { diff --git a/src/rust_functions.ts b/src/rust_functions.ts index 8caef09..1fa088e 100644 --- a/src/rust_functions.ts +++ b/src/rust_functions.ts @@ -65,11 +65,11 @@ export const get_rust_function = (name: string, shape?: string[]) => { return async (props: Record) => { props = props ?? {}; if (!setsEqual(shapeSet, new Set(Object.keys(props)))) { - throw new Error(`Function '${name} passed with unexpected shape'`); + throw new Error(`Function '${name}' passed with unexpected shape`); } // @ts-expect-error -- This is not always going to exist, but the compiler doesn't know that if (window.__TAURI_IPC__) { - return invoke(`tauri_${name}`, props); + return await invoke(`tauri_${name}`, props); } else { if (!isValidWasmCommandString(name)) { throw new Error(`Function '${name}' is not a valid wasm command`); @@ -77,7 +77,7 @@ export const get_rust_function = (name: string, shape?: string[]) => { while (! await isWasmLoaded()) { // wait } - return callWorkerFunction({command: name, argument: props, shape: shape}); + return await callWorkerFunction({command: name, argument: props, shape: shape}); } }; }; diff --git a/src/tauri_file_system.ts b/src/tauri_file_system.ts index a7559f8..ae2f2f5 100644 --- a/src/tauri_file_system.ts +++ b/src/tauri_file_system.ts @@ -1,4 +1,5 @@ -import { get_rust_function } from "./rust_functions"; +import {get_rust_function} from "./rust_functions"; +import {FsType} from "./fsShared.ts"; // TODO: comment in the exception that is thrown when an error is encountered in the functions @@ -145,6 +146,11 @@ export interface FileSystem { * ``` */ writeFile(props: {path: string, contents: string}): Promise; + + /** + * Identifies the type of FileSystem. + */ + readonly type: FsType; } /** @@ -165,6 +171,7 @@ const fs = { removeFile: get_rust_function("remove_file", ["path"]), rename: get_rust_function("rename", ["from", "to"]), writeFile: get_rust_function("write_file", ["path", "contents"]), + type: FsType.Tauri, } as FileSystem; export default fs; diff --git a/src/wasmFs.ts b/src/wasmFs.ts index fd7c284..aed02ad 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -1,7 +1,10 @@ import {BaseFileSystem, type ContextFileSystem, directoryname, filename, FsDir, joinPath} from "./fsContext.ts"; +import {FsType} from "./fsShared.ts"; import {ProjectDataEntry, ProjectDataStore} from "./projectData.ts"; export default class WasmFs implements BaseFileSystem { + readonly type = FsType.WasmLocal; + private readonly rootDirectoryHandle: FileSystemDirectoryHandle; private readonly dirHandleCache: Map; @@ -260,9 +263,10 @@ export class WasmProjectDataStore extends ProjectDataStore { const transaction = this.indexedDb.transaction("projects", "readwrite"); const projectsObjectStore = transaction.objectStore("projects"); const lastModifiedTime = Date.now(); - this.savedProjects[projectName] = {lastModified: lastModifiedTime}; + this.savedProjects[projectName] = {lastModified: lastModifiedTime, rootPath: "/"}; const entry: IndexedDbProjectEntry = { lastModified: lastModifiedTime, + rootPath: "/", name: projectName }; projectsObjectStore.put(entry); From 1190d91f25155b2bf2110396b9d7a6181b1d802e Mon Sep 17 00:00:00 2001 From: Aleks Bekker Date: Fri, 9 Aug 2024 14:20:15 -0400 Subject: [PATCH 21/23] Fix Tauri invoker bug (#103) It turns out you can use multiple handler invokers, so I combined them into one big handler. --- rezasm-app/rezasm-tauri/src/file_system.rs | 47 +++++---------- rezasm-app/rezasm-tauri/src/main.rs | 70 +++++++++++++++++----- 2 files changed, 69 insertions(+), 48 deletions(-) diff --git a/rezasm-app/rezasm-tauri/src/file_system.rs b/rezasm-app/rezasm-tauri/src/file_system.rs index d5a4043..b703432 100644 --- a/rezasm-app/rezasm-tauri/src/file_system.rs +++ b/rezasm-app/rezasm-tauri/src/file_system.rs @@ -6,7 +6,7 @@ extern crate tauri; macro_rules! void_or_error_command { ($fn_name:ident, $wrapped_fn:expr, $( $arg_name:ident : $arg_type:ty ),*) => { #[tauri::command] - fn $fn_name($( $arg_name : $arg_type),*) -> Result<(), String> { + pub fn $fn_name($( $arg_name : $arg_type),*) -> Result<(), String> { $wrapped_fn($($arg_name), *).map_err(|err| err.to_string())?; Ok(()) } @@ -17,52 +17,33 @@ macro_rules! void_or_error_command { macro_rules! return_or_error_command { ($fn_name:ident, $wrapped_fn:expr, $return_type:ty, $( $arg_name:ident : $arg_type:ty ),*) => { #[tauri::command] - fn $fn_name($( $arg_name : $arg_type),*) -> Result<$return_type, String> { + pub fn $fn_name($( $arg_name : $arg_type),*) -> Result<$return_type, String> { $wrapped_fn($($arg_name), *).map_err(|err| err.to_string()) } }; } -return_or_error_command!(tauri_copy_file, std::fs::copy, u64, from: &str, to: &str); -return_or_error_command!(tauri_read_to_string, std::fs::read_to_string, String, path: &str); +return_or_error_command!(tauri_copy_file, fs::copy, u64, from: &str, to: &str); +return_or_error_command!(tauri_read_to_string, fs::read_to_string, String, path: &str); -void_or_error_command!(tauri_create_dir, std::fs::create_dir, path: &str); -void_or_error_command!(tauri_create_dir_with_parents, std::fs::create_dir_all, path: &str); -void_or_error_command!(tauri_create_file, std::fs::File::create, path: &str); -void_or_error_command!(tauri_remove_file, std::fs::remove_file, path: &str); -void_or_error_command!(tauri_rename, std::fs::rename, from: &str, to: &str); -void_or_error_command!(tauri_write_file, std::fs::write, path: &str, contents: &str); +void_or_error_command!(tauri_create_dir, fs::create_dir, path: &str); +void_or_error_command!(tauri_create_dir_with_parents, fs::create_dir_all, path: &str); +void_or_error_command!(tauri_create_file, fs::File::create, path: &str); +void_or_error_command!(tauri_remove_file, fs::remove_file, path: &str); +void_or_error_command!(tauri_rename, fs::rename, from: &str, to: &str); +void_or_error_command!(tauri_write_file, fs::write, path: &str, contents: &str); // Can only delete empty directory -void_or_error_command!(tauri_remove_dir, std::fs::remove_dir, path: &str); +void_or_error_command!(tauri_remove_dir, fs::remove_dir, path: &str); // Deletes all contents of a (potentially) non-empty directory -void_or_error_command!(tauri_remove_dir_recursive, std::fs::remove_dir_all, path: &str); +void_or_error_command!(tauri_remove_dir_recursive, fs::remove_dir_all, path: &str); #[tauri::command] -fn tauri_read_dir(path: &str) -> Result, String> { +pub fn tauri_read_dir(path: &str) -> Result, String> { Ok(fs::read_dir(path) .map_err(|err| err.to_string())? - .into_iter() .filter_map(|entry| entry.ok().map(|e| e.path())) .filter_map(|path| Some((path.to_str()?.to_string(), path.is_dir()))) .collect()) -} - -lazy_static::lazy_static! { - /// The tauri handler containing all file system methods - pub static ref HANDLER: Box = - Box::new(tauri::generate_handler![ - tauri_copy_file, - tauri_create_dir, - tauri_create_dir_with_parents, - tauri_create_file, - tauri_read_dir, - tauri_read_to_string, - tauri_remove_dir, - tauri_remove_dir_recursive, - tauri_remove_file, - tauri_rename, - tauri_write_file, - ]); -} +} \ No newline at end of file diff --git a/rezasm-app/rezasm-tauri/src/main.rs b/rezasm-app/rezasm-tauri/src/main.rs index 7ca03cc..a1a5d4b 100644 --- a/rezasm-app/rezasm-tauri/src/main.rs +++ b/rezasm-app/rezasm-tauri/src/main.rs @@ -19,7 +19,11 @@ use rezasm_web_core::{ use tauri::{Manager, Window}; use tauri_reader::TauriReader; -use file_system::HANDLER as file_system_handler; +use crate::file_system::{ + tauri_copy_file, tauri_create_dir, tauri_create_dir_with_parents, tauri_create_file, + tauri_read_dir, tauri_read_to_string, tauri_remove_dir, tauri_remove_dir_recursive, + tauri_remove_file, tauri_rename, tauri_write_file, +}; use crate::tauri_writer::TauriWriter; use std::{ @@ -31,7 +35,7 @@ lazy_static! { static ref WINDOW: Arc>> = Arc::new(RwLock::new(None)); } -pub const WINDOW_NAME: &'static str = "main"; +pub const WINDOW_NAME: &str = "main"; pub fn get_window() -> Window { WINDOW @@ -116,20 +120,16 @@ fn tauri_get_word_size() -> usize { fn tauri_receive_input(data: &str) { let mut simulator = get_simulator_mut(); let reader = simulator.get_reader_mut(); - reader.write(data.as_bytes()).unwrap(); - reader.write(&[b'\n']).unwrap(); + reader.write_all(data.as_bytes()).unwrap(); + reader.write_all(&[b'\n']).unwrap(); } -fn main() { - register_instructions(); - initialize_simulator( - Some(ReaderCell::new(TauriReader::new())), - Some(Box::new(TauriWriter::new())), - ); +type Handler = dyn Fn(tauri::Invoke) + Send + Sync; - tauri::Builder::default() - .setup(|app| Ok(set_window(app.get_window(WINDOW_NAME).unwrap()))) - .invoke_handler(tauri::generate_handler![ +lazy_static::lazy_static! { + /// The tauri handler containing all file system methods + static ref EZASM_HANDLER: Box = + Box::new(tauri::generate_handler![ tauri_load, tauri_reset, tauri_step, @@ -144,8 +144,48 @@ fn main() { tauri_get_memory_slice, tauri_get_word_size, tauri_receive_input, - ]) - .invoke_handler(file_system_handler.as_ref()) + ]); +} + +fn main() { + let handler: Box = Box::new(tauri::generate_handler![ + tauri_load, + tauri_reset, + tauri_step, + tauri_step_back, + tauri_stop, + tauri_is_completed, + tauri_get_exit_status, + tauri_get_register_value, + tauri_get_register_names, + tauri_get_register_values, + tauri_get_memory_bounds, + tauri_get_memory_slice, + tauri_get_word_size, + tauri_receive_input, + // File system + tauri_copy_file, + tauri_create_dir, + tauri_create_dir_with_parents, + tauri_create_file, + tauri_read_dir, + tauri_read_to_string, + tauri_remove_dir, + tauri_remove_dir_recursive, + tauri_remove_file, + tauri_rename, + tauri_write_file, + ]); + + register_instructions(); + initialize_simulator( + Some(ReaderCell::new(TauriReader::new())), + Some(Box::new(TauriWriter::new())), + ); + + tauri::Builder::default() + .setup(|app| Ok(set_window(app.get_window(WINDOW_NAME).unwrap()))) + .invoke_handler(handler) .run(tauri::generate_context!()) .expect("error while running tauri application"); } From 1224862702a3a73e09d51c2221bcec1712d53d56 Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 9 Aug 2024 15:26:00 -0400 Subject: [PATCH 22/23] Fix parallelization problem --- src/components/FilesystemSidebar.tsx | 11 ++++++++++- src/components/FsContextProvider.tsx | 1 + src/wasmFs.ts | 5 +++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/components/FilesystemSidebar.tsx b/src/components/FilesystemSidebar.tsx index fed4f80..8e5a065 100644 --- a/src/components/FilesystemSidebar.tsx +++ b/src/components/FilesystemSidebar.tsx @@ -70,7 +70,16 @@ export default function FilesystemSidebar() { console.log(3); const baseFs = await initEmptyFs(); console.log(newFoldersToCreate); - await Promise.all(Array.from(newFoldersToCreate).filter(folder=>!!folder).map((folder) => baseFs.createDir({path: folder}))); + // Order folders by number of slashes -- we need parent folders to exist before child folders + // so this takes cares of that. + const orderedFolders = Array.from(newFoldersToCreate).filter(folder=>!!folder && folder !== "/").toSorted((a, b) => { + const aNumSlashes = a.split("/").length; + const bNumSlashes = b.split("/").length; + return aNumSlashes - bNumSlashes; + }); + for (const folder of orderedFolders) { + await baseFs.createDir({path: folder}); + } console.log(4); await Promise.all(filesArray.map(async (file) => { const properPath = file.webkitRelativePath.substring(file.webkitRelativePath.indexOf("/")); diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 5254cde..51a0d62 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -106,6 +106,7 @@ export default function FsContextProvider(props: PropsWithChildren) { const readDir: ContextFileSystem["readDir"] = async (parent: FsDir): Promise> => { // console.debug("Starting: "); // console.debug(parent); + console.log("Reading dir: %o", parent); const items = await fsProvider!.readDir({path: parent.path()}); // console.debug(items); const map = new Map(); diff --git a/src/wasmFs.ts b/src/wasmFs.ts index aed02ad..024e704 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -55,9 +55,10 @@ export default class WasmFs implements BaseFileSystem { } async createDir({path}: {path: string}): Promise { - const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + const parentName = WasmFs.getParentPath(path); + const parentHandle = await this.getDirectoryHandle(parentName); const folderName = filename(path); - // console.debug(`Creating directory ${folderName} in ${parentHandle.name}`); + // console.log("Creating %s in %s", folderName, parentName); await parentHandle.getDirectoryHandle(folderName, {create: true}); } async createDirWithParents({path}: {path: string}): Promise { From 3996e98556733213b26004ef97c5d28cda4c5c8a Mon Sep 17 00:00:00 2001 From: PythonCoderAS <13932583+PythonCoderAS@users.noreply.github.com> Date: Fri, 9 Aug 2024 17:18:23 -0400 Subject: [PATCH 23/23] Move FS to shared FS file --- src/components/FsContextProvider.tsx | 5 +- src/fsContext.ts | 7 +- src/fsShared.ts | 145 +++++++++++++++++++++++++ src/tauri_file_system.ts | 151 +-------------------------- src/wasmFs.ts | 6 +- 5 files changed, 153 insertions(+), 161 deletions(-) diff --git a/src/components/FsContextProvider.tsx b/src/components/FsContextProvider.tsx index 51a0d62..5272be8 100644 --- a/src/components/FsContextProvider.tsx +++ b/src/components/FsContextProvider.tsx @@ -1,5 +1,4 @@ import { - BaseFileSystem, ContextFileSystem, directoryname, DummyFsOps, @@ -11,14 +10,14 @@ import { joinPath, parts } from "../fsContext.ts"; -import {FsType} from "../fsShared.ts"; +import {FsType, FileSystem} from "../fsShared.ts"; import {PropsWithChildren, useCallback, useEffect, useMemo, useState} from "react"; import WasmFs, {initEmptyFs, WasmProjectDataStore} from "../wasmFs.ts"; import {ProjectDataStore, TauriProjectDataStore} from "../projectData.ts"; export default function FsContextProvider(props: PropsWithChildren) { const [root, setRoot] = useState(undefined); - const [fsProvider, setFsProvider] = useState(undefined); + const [fsProvider, setFsProvider] = useState(undefined); const [projectDataStore, setProjectDataStore] = useState(undefined); const getItem = useCallback((path: string) => { if (!root || !path) { diff --git a/src/fsContext.ts b/src/fsContext.ts index 841bb52..b45d5ff 100644 --- a/src/fsContext.ts +++ b/src/fsContext.ts @@ -1,8 +1,7 @@ /* eslint-disable no-unused-vars */ import { createContext } from "react"; import {ProjectDataStore} from "./projectData.ts"; -import {FileSystem} from "./tauri_file_system.ts"; -import {type FsType} from "./fsShared.ts"; +import {type FsType, FileSystem} from "./fsShared.ts"; export abstract class AbstractFsFile { public name: string; @@ -140,8 +139,6 @@ export function directoryname(path: string): string { return path.substring(0, path.lastIndexOf("/")); } -export type BaseFileSystem = FileSystem; - export interface ContextFileSystem { copyFile(from: FsFile, toParent: FsDir, toName?: string): Promise; createDir(parent: FsDir, path: string): Promise; @@ -165,7 +162,7 @@ export interface FsContext { projectHandler: ProjectDataStore; type: FsType | undefined; setRoot: (root: FsDir) => void; - setBaseFS: (base: BaseFileSystem) => void; + setBaseFS: (base: FileSystem) => void; } const notDefined = () => { diff --git a/src/fsShared.ts b/src/fsShared.ts index c91005d..5ed1b6f 100644 --- a/src/fsShared.ts +++ b/src/fsShared.ts @@ -2,3 +2,148 @@ export enum FsType { Tauri = 1, WasmLocal = 2 // This is WASM but it does not directly write to the filesystem. } + +export interface FileSystem { + + /** + * Copies a file in the target filesystem + * + * @param props a record with the `from` and `to` paths, represented by `string`s. + * @returns a promise for the number of bytes copied. + * + * @example ```typescript + * let copiedBytes: bigint = await fs.copy({from: "path/to/file", to: "new/path/to/file"}); + * ``` + */ + copyFile(props: {from: string, to: string}): Promise; + + /** + * Creates a new directory in the target filesystem. + * + * This error will not create any missing parent directories while creating the directory. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * const parent = "some/existing/path"; + * const newDirectory = "new_directory_name" + * await fs.createDir({path: `${parent}/${newDirectory}`}); + * ``` + */ + createDir(props: {path: string}): Promise; + + /** + * Creates a new directories and all required parents in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.createDirWithParents({path: "path/to/new/dir"}); + * ``` + */ + createDirWithParents(props: {path: string}): Promise; + + /** + * Creates a new file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.createFile({path: "path/to/new/dir"}); + * ``` + */ + createFile(props: {path: string}): Promise; + + /** + * Reads a directory in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns a promise containing an array of tuples that contain the relative file name followed + * by a boolean that is true iff the file is a directory. + * + * @example ```typescript + * let files: string[] = await fs.readDir({path: "path/to/new/dir"}); + * ``` + */ + readDir(props: {path: string}): Promise<[string, boolean][]>; + + /** + * Reads a whole file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns a promise for a string that contains the data from the whole file. + * + * @example ```typescript + * let fileContents: string = await fs.readToString({path: "path/to/new/dir"}); + * ``` + */ + readToString(props: {path: string}): Promise; + + /** + * Removes an empty directory from the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeDir({path: "path/to/empty/dir"}); + * ``` + */ + removeDir(props: {path: string}): Promise; + + /** + * Removes a directory and all the files it contains in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeDirRecursive({path: "path/to/target/dir"}); + * ``` + */ + removeDirRecursive(props: {path: string}): Promise; + + /** + * Removes a file in the target filesystem. + * + * @param props a record with the `path` entry that refers to the target path. + * @returns an empty promise. + * + * @example ```typescript + * await fs.removeFile({path: "path/to/target/file"}); + * ``` + */ + removeFile(props: {path: string}): Promise; + + /** + * Removes a file in the target filesystem. + * + * @param props a record with the `from` and `to` paths, represented by `string`s. + * @returns an empty promise. + * + * @example ```typescript + * await fs.rename({from: "old/path", to: "new/path"}); + * ``` + */ + rename(props: {from: string, to: string}): Promise; + + /** + * Writes a string to a file. + * + * @param props a record with the `path` file path and `contents` which holds the contents of the new file + * @returns an empty promise. + * + * @example ```typescript + * await fs.rename({path: "some/path", contents: "this line will be the only contents of the file"}); + * ``` + */ + writeFile(props: {path: string, contents: string}): Promise; + + /** + * Identifies the type of FileSystem. + */ + readonly type: FsType; +} diff --git a/src/tauri_file_system.ts b/src/tauri_file_system.ts index ae2f2f5..1387087 100644 --- a/src/tauri_file_system.ts +++ b/src/tauri_file_system.ts @@ -1,157 +1,8 @@ import {get_rust_function} from "./rust_functions"; -import {FsType} from "./fsShared.ts"; +import {FsType, FileSystem} from "./fsShared.ts"; // TODO: comment in the exception that is thrown when an error is encountered in the functions -/** - * File system interface - * - * This interface is implemented by the exported `fs` object. - */ -export interface FileSystem { - - /** - * Copies a file in the target filesystem - * - * @param props a record with the `from` and `to` paths, represented by `string`s. - * @returns a promise for the number of bytes copied. - * - * @example ```typescript - * let copiedBytes: bigint = await fs.copy({from: "path/to/file", to: "new/path/to/file"}); - * ``` - */ - copyFile(props: {from: string, to: string}): Promise; - - /** - * Creates a new directory in the target filesystem. - * - * This error will not create any missing parent directories while creating the directory. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * const parent = "some/existing/path"; - * const newDirectory = "new_directory_name" - * await fs.createDir({path: `${parent}/${newDirectory}`}); - * ``` - */ - createDir(props: {path: string}): Promise; - - /** - * Creates a new directories and all required parents in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * await fs.createDirWithParents({path: "path/to/new/dir"}); - * ``` - */ - createDirWithParents(props: {path: string}): Promise; - - /** - * Creates a new file in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * await fs.createFile({path: "path/to/new/dir"}); - * ``` - */ - createFile(props: {path: string}): Promise; - - /** - * Reads a directory in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns a promise containing an array of tuples that contain the relative file name followed - * by a boolean that is true iff the file is a directory. - * - * @example ```typescript - * let files: string[] = await fs.readDir({path: "path/to/new/dir"}); - * ``` - */ - readDir(props: {path: string}): Promise<[string, boolean][]>; - - /** - * Reads a whole file in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns a promise for a string that contains the data from the whole file. - * - * @example ```typescript - * let fileContents: string = await fs.readToString({path: "path/to/new/dir"}); - * ``` - */ - readToString(props: {path: string}): Promise; - - /** - * Removes an empty directory from the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * await fs.removeDir({path: "path/to/empty/dir"}); - * ``` - */ - removeDir(props: {path: string}): Promise; - - /** - * Removes a directory and all the files it contains in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * await fs.removeDirRecursive({path: "path/to/target/dir"}); - * ``` - */ - removeDirRecursive(props: {path: string}): Promise; - - /** - * Removes a file in the target filesystem. - * - * @param props a record with the `path` entry that refers to the target path. - * @returns an empty promise. - * - * @example ```typescript - * await fs.removeFile({path: "path/to/target/file"}); - * ``` - */ - removeFile(props: {path: string}): Promise; - - /** - * Removes a file in the target filesystem. - * - * @param props a record with the `from` and `to` paths, represented by `string`s. - * @returns an empty promise. - * - * @example ```typescript - * await fs.rename({from: "old/path", to: "new/path"}); - * ``` - */ - rename(props: {from: string, to: string}): Promise; - - /** - * Writes a string to a file. - * - * @param props a record with the `path` file path and `contents` which holds the contents of the new file - * @returns an empty promise. - * - * @example ```typescript - * await fs.rename({path: "some/path", contents: "this line will be the only contents of the file"}); - * ``` - */ - writeFile(props: {path: string, contents: string}): Promise; - - /** - * Identifies the type of FileSystem. - */ - readonly type: FsType; -} /** * File system interaction diff --git a/src/wasmFs.ts b/src/wasmFs.ts index 024e704..202d6f8 100644 --- a/src/wasmFs.ts +++ b/src/wasmFs.ts @@ -1,8 +1,8 @@ -import {BaseFileSystem, type ContextFileSystem, directoryname, filename, FsDir, joinPath} from "./fsContext.ts"; -import {FsType} from "./fsShared.ts"; +import {type ContextFileSystem, directoryname, filename, FsDir, joinPath} from "./fsContext.ts"; +import {FsType, FileSystem} from "./fsShared.ts"; import {ProjectDataEntry, ProjectDataStore} from "./projectData.ts"; -export default class WasmFs implements BaseFileSystem { +export default class WasmFs implements FileSystem { readonly type = FsType.WasmLocal; private readonly rootDirectoryHandle: FileSystemDirectoryHandle;