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/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/package.json b/package.json index 786853c..c2d1e81 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,19 +31,24 @@ "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", + "autoprefixer": "^10.4.19", "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", + "postcss": "^8.4.38", + "tailwindcss": "^3.4.4", "typescript": "^5.2.2", "vite": "^5.2.0" + }, + "engines": { + "node": ">=20" } } diff --git a/postcss.config.js b/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} 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/src/file_system.rs b/rezasm-app/rezasm-tauri/src/file_system.rs new file mode 100644 index 0000000..b703432 --- /dev/null +++ b/rezasm-app/rezasm-tauri/src/file_system.rs @@ -0,0 +1,49 @@ +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] + pub 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] + 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, 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, 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, fs::remove_dir, path: &str); + +// Deletes all contents of a (potentially) non-empty directory +void_or_error_command!(tauri_remove_dir_recursive, fs::remove_dir_all, path: &str); + +#[tauri::command] +pub fn tauri_read_dir(path: &str) -> Result, String> { + Ok(fs::read_dir(path) + .map_err(|err| err.to_string())? + .filter_map(|entry| entry.ok().map(|e| e.path())) + .filter_map(|path| Some((path.to_str()?.to_string(), path.is_dir()))) + .collect()) +} \ 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 1a48837..a1a5d4b 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,12 @@ use rezasm_web_core::{ use tauri::{Manager, Window}; use tauri_reader::TauriReader; +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::{ io::Write, @@ -28,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 @@ -113,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, @@ -141,7 +144,48 @@ fn main() { tauri_get_memory_slice, tauri_get_word_size, tauri_receive_input, - ]) + ]); +} + +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"); } 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/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..5333404 --- /dev/null +++ b/src/components/BrowserMenu.tsx @@ -0,0 +1,118 @@ +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.jsx deleted file mode 100644 index 016066c..0000000 --- a/src/components/Code.jsx +++ /dev/null @@ -1,66 +0,0 @@ -import React, {useEffect, useState} from "react"; -import RegistryView from "./RegistryView.jsx"; -import {loadWasm} from "../rust_functions.ts"; -import {Tabs, Tab} from "./Tabs.jsx"; - -import MemoryView from "./MemoryView.jsx"; -import Console from "./Console.jsx"; -import Controls from "./Controls.jsx"; -import Editor from "./Editor.jsx"; -import {useSimulator} from "./simulator.ts"; - -function Code() { - - const { - state, - error, - exitCode, - setState, - setCode, - setInstructionDelay, - registerCallback, - start, - stop, - step, - stepBack, - load, - reset, - } = useSimulator(); - const [wasmLoaded, setWasmLoaded] = useState(false); - - useEffect(() => { - loadWasm() - .then((loaded) => setWasmLoaded(loaded)) - .catch(() => setWasmLoaded(false)); - }, []); - - return ( -
-
- -
-
- -
-
- -
-
-
- - -
- -
-
- -
- -
-
-
-
- ); -} - -export default Code; diff --git a/src/components/Code.tsx b/src/components/Code.tsx new file mode 100644 index 0000000..19cf32d --- /dev/null +++ b/src/components/Code.tsx @@ -0,0 +1,78 @@ +import {useEffect, useState} from "react"; +import RegistryView from "./RegistryView.jsx"; +import {loadWasm} from "../rust_functions.ts"; +import {Tabs, Tab} from "./Tabs.jsx"; + +import MemoryView from "./MemoryView.jsx"; +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"; +import FilesystemSidebar from "./FilesystemSidebar.tsx"; +import FsContextProvider from "./FsContextProvider.tsx"; +import FsActionsProvider from "./FsActionsProvider.tsx"; + +function Code() { + + const { + state, + error, + exitCode, + setState, + setCode, + // setInstructionDelay, + registerCallback, + start, + stop, + step, + stepBack, + load, + reset, + } = useSimulator(); + const [wasmLoaded, setWasmLoaded] = useState(false); + + useEffect(() => { + loadWasm() + .then((loaded) => setWasmLoaded(Boolean(loaded))) + .catch(() => setWasmLoaded(false)); + }, []); + + return ( + + {!window.__TAURI__ && } +
+
+
+
+
+
+ +
+
+ +
+
+
+ + + +
+ +
+
+ +
+ +
+
+
+
+
+
+ ); +} + +export default 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 ? +
+
+ {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: { root: FsDir, closeModal: () => unknown }) { + const [name, setName] = useState(""); + const fs = useContext(FsContext); + return +
+ + 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(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!.closeProject().then(() => 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 new file mode 100644 index 0000000..8e5a065 --- /dev/null +++ b/src/components/FilesystemSidebar.tsx @@ -0,0 +1,114 @@ +import {useContext, useEffect, useMemo, useReducer, useState} from "react"; +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}; +} + +export function FolderSidebar(props: {folder: FsDir, hash: number}) { + // 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
+ setExpanded(!expanded)) : undefined}>{!locked && (expanded ? "▼" : "▶")} + {expanded &&
{/* We need the empty span because of how the margin spacing works. */}{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); + useEffect(() => { + const val = setInterval(() => setCounter(), 100); + return () => clearInterval(val); + }); + const rootSidebar = useMemo(() => fs.root ? : "No filesystem loaded, create a file or open a directory.", [fs.root, fs.root?.modifiedHash]); + return
+ {rootSidebar} +
+ + + + + +
+
; +} diff --git a/src/components/FsActionsProvider.tsx b/src/components/FsActionsProvider.tsx new file mode 100644 index 0000000..fecd75d --- /dev/null +++ b/src/components/FsActionsProvider.tsx @@ -0,0 +1,34 @@ +import {PropsWithChildren, useMemo, useState} from "react"; +import { FsActions, FsActionsContext, FsDir} from "../fsContext.ts"; +import {CreateFileModal, OpenProjectModal, SaveProjectModal} 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 [showOpenProjectModal, setShowOpenProjectModal] = useState(false); + const [saveProjectModalRoot, setSaveProjectModalRoot] = useState(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. + }, + showOpenProjectModal: () => { + setShowOpenProjectModal(true); + }, + showSaveProjectModal: (root: FsDir) => { + setSaveProjectModalRoot(root); + } + }), []); + 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 new file mode 100644 index 0000000..5272be8 --- /dev/null +++ b/src/components/FsContextProvider.tsx @@ -0,0 +1,237 @@ +import { + ContextFileSystem, + directoryname, + DummyFsOps, + filename, + FsContext, + FsDir, + FsFile, + FsItem, + joinPath, + parts +} from "../fsContext.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 [projectDataStore, setProjectDataStore] = useState(undefined); + const getItem = useCallback((path: string) => { + if (!root || !path) { + return null; + } + if (path === "/") { + return root; + } + const paths = path.split("/"); + 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; + } + if (num === paths.length - 1 || !next.isDir) { + return next ?? null; + } + current = next; + } + console.log("Current: %o", current); + return current; + + }, [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; + await fsProvider!.copyFile({from: fromPath, to: 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({path: 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({path: 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.getChild(piece)) { + const part = await createDir(current, piece); + current.addChild(part); + current = part; + } else { + const part = current.getChild(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> => { + // 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(); + 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({path: file.path()}); + }; + + const removeFile: ContextFileSystem["removeFile"] = async (file: FsFile) => { + await fsProvider!.removeFile({path: file.path()}); + file.parent.removeChild(file.name); + }; + + const removeDir: ContextFileSystem["removeDir"] = async (dir: FsDir) => { + if (dir.parent === null) { + throw new Error("Cannot remove root directory."); + } + await fsProvider!.removeDir({path: dir.path()}); + dir.parent.removeChild(dir.name); + }; + + const removeDirRecursive: ContextFileSystem["removeDirRecursive"] = async (dir: FsDir) => { + if (dir.parent === null) { + throw new Error("Cannot remove root directory."); + } + await fsProvider!.removeDirRecursive({path: dir.path()}); + dir.parent.removeChild(dir.name); + }; + + 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!.rename({from: file.path(), to: newPath}); + file.parent.removeChild(file.name); + file.name = newName; + file.parent.addChild(file); + return file; + }; + + const writeFile: ContextFileSystem["writeFile"] = async (file: FsFile, contents: string) => { + await fsProvider!.writeFile({path: file.path(), contents}); + }; + + return { + copyFile, + createFile, + createDir, + createDirWithParents, + readDir, + readToString, + removeFile, + removeDir, + removeDirRecursive, + rename, + writeFile, + init: true + }; + }, [fsProvider, getItem]); + + useEffect(() => { + initEmptyFs().then((fs) => setFsProvider(fs)); + setRoot(new FsDir("/", null)); + }, []); + + useEffect(() => { + // Load dir cache on root change + // console.debug(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(() => { + if (!fsProvider) { + return; + } + switch (fsProvider.type) { + case FsType.Tauri: { + setProjectDataStore(new TauriProjectDataStore()); + break; + } + case FsType.WasmLocal: { + 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]); + + useEffect(() => { + if (projectDataStore) { + projectDataStore.initDataStore(); + } + }, [projectDataStore]); + + return ; +} diff --git a/src/fsContext.ts b/src/fsContext.ts new file mode 100644 index 0000000..b45d5ff --- /dev/null +++ b/src/fsContext.ts @@ -0,0 +1,226 @@ +/* eslint-disable no-unused-vars */ +import { createContext } from "react"; +import {ProjectDataStore} from "./projectData.ts"; +import {type FsType, FileSystem} from "./fsShared.ts"; + +export abstract class AbstractFsFile { + public name: string; + public isDir: boolean; + public parent: FsDir | null; // null for root + + protected 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.parent.path() : "") + "/" + this.name) : this.name; + } +} + +export class FsFile extends AbstractFsFile { + public isDir = false as const; + public parent: FsDir; + + constructor(name: string, parent: FsDir) { + super(name, false, parent); + this.parent = parent; + } +} + +export class FsDir extends AbstractFsFile { + public isDir = true as const; + 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; + if (this.parent) { + 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.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++; + } +} + +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 { + if (path.lastIndexOf("/") === 0) { + return "/"; + } + return path.substring(0, path.lastIndexOf("/")); +} + +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; + rename(from: FsFile, to: string): Promise; + writeFile(file: FsFile, contents: string): Promise; + init: boolean; +} + + +export interface FsContext { + root: FsDir | undefined; + getItem(path: string): FsItem | null; + ops: ContextFileSystem; + projectHandler: ProjectDataStore; + type: FsType | undefined; + setRoot: (root: FsDir) => void; + setBaseFS: (base: FileSystem) => void; +} + +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, + rename: notDefined, + writeFile: notDefined, + init: false +}; + +class DummyProjectHandler extends ProjectDataStore { + async initDataStore() { + notDefined(); + } + async saveProject() { + notDefined(); + } + async closeProject() { + notDefined(); + } + + async getProject() { + notDefined(); + return null; + } +} + + +export const FsContext = createContext({ + root: undefined, + getItem: () => null, + ops: DummyFsOps, + projectHandler: new DummyProjectHandler(), + type: undefined, + setRoot: notDefined, + setBaseFS: notDefined +}); + +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/fsShared.ts b/src/fsShared.ts new file mode 100644 index 0000000..5ed1b6f --- /dev/null +++ b/src/fsShared.ts @@ -0,0 +1,149 @@ +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/projectData.ts b/src/projectData.ts new file mode 100644 index 0000000..d20923a --- /dev/null +++ b/src/projectData.ts @@ -0,0 +1,58 @@ +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; + +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 { + return this.savedProjects; + } +} + +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(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 { + const projectData = this.savedProjects[projectName]; + if (!projectData) { + return 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 { + // No-op + } +} diff --git a/src/rust_functions.ts b/src/rust_functions.ts index 1b04876..1fa088e 100644 --- a/src/rust_functions.ts +++ b/src/rust_functions.ts @@ -35,24 +35,49 @@ 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) => { 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`); + } 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/styles.css b/src/styles.css index 084d478..929bbca 100644 --- a/src/styles.css +++ b/src/styles.css @@ -6,7 +6,7 @@ /* leave this until we rewrite the primary gui*/ :root { - font-family: "JetBrains Mono"; + font-family: "JetBrains Mono", monospace; font-weight: 400; color: #0f0f0f; diff --git a/src/tauri_file_system.ts b/src/tauri_file_system.ts new file mode 100644 index 0000000..1387087 --- /dev/null +++ b/src/tauri_file_system.ts @@ -0,0 +1,28 @@ +import {get_rust_function} from "./rust_functions"; +import {FsType, FileSystem} from "./fsShared.ts"; + +// TODO: comment in the exception that is thrown when an error is encountered in the functions + + +/** + * File system interaction + * + * Depending on the build target (WASM/Tauri), this object may either modify the local + * browser based or system filesystem. + */ +const fs = { + 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"]), + type: FsType.Tauri, +} as FileSystem; + +export default fs; diff --git a/src/wasmFs.ts b/src/wasmFs.ts new file mode 100644 index 0000000..202d6f8 --- /dev/null +++ b/src/wasmFs.ts @@ -0,0 +1,301 @@ +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 FileSystem { + readonly type = FsType.WasmLocal; + + 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 { + // 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 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 { + const parentHandle = await this.getDirectoryHandle(WasmFs.getParentPath(path)); + const basename = filename(path); + return await parentHandle.getFileHandle(basename); + } + + + 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); + // 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()); + await writable.close(); + return BigInt(0); + } + + async createDir({path}: {path: string}): Promise { + const parentName = WasmFs.getParentPath(path); + const parentHandle = await this.getDirectoryHandle(parentName); + const folderName = filename(path); + // console.log("Creating %s in %s", folderName, parentName); + await parentHandle.getDirectoryHandle(folderName, {create: true}); + } + async createDirWithParents({path}: {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}); + } + } + 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}: {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}: {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}: {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}: {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({path: truePath}) : this.removeFile({path: truePath})); + } + await Promise.all(promises); + await this.removeDir({path}); + } + 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 rename({from, to}: {from: string, to: string}): Promise { + await this.copyFile({from, to}); + await this.removeFile({path: from}); + } + + 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}`); + const fileHandle = await parentHandle.getFileHandle(fileName, {create: true}); + const writable = await fileHandle.createWritable(); + await writable.write(contents); + await writable.close(); + + } +} + +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, contents: child.contents!}); + } + })); + } + + 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 = db.createObjectStore("projects", {keyPath: "name"}); + objectStore.createIndex("lastSaved", "lastSaved", { unique: false }); + const objectStore2 = db.createObjectStore("projectData", {keyPath: "name"}); + await promisifyTransaction(objectStore.transaction); + await promisifyTransaction(objectStore2.transaction); + // currentVersion = 1; + } + + } + async initDataStore(): Promise { + const request = indexedDB.open("projectData", WasmProjectDataStore.highestVersion); + request.onupgradeneeded = (event) => this.migrate(event); + this.indexedDb = await promisifyRequest(request); + 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", "readwrite"); + const projectsObjectStore = transaction.objectStore("projects"); + const lastModifiedTime = Date.now(); + this.savedProjects[projectName] = {lastModified: lastModifiedTime, rootPath: "/"}; + const entry: IndexedDbProjectEntry = { + lastModified: lastModifiedTime, + rootPath: "/", + name: projectName + }; + projectsObjectStore.put(entry); + 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 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 { + 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); + } + } +} diff --git a/tailwind.config.js b/tailwind.config.js index 1d13c5f..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/App.tsx"], + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], theme: { extend: {}, }, 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,