diff --git a/page/package-lock.json b/page/package-lock.json index 51c380ce..69568032 100644 --- a/page/package-lock.json +++ b/page/package-lock.json @@ -11,6 +11,7 @@ "@fontsource/inter": "^5.2.5", "@irori/idbfs": "^0.5.0", "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-context-menu": "^2.2.12", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", @@ -36,7 +37,8 @@ "react-window": "^1.8.11", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", - "tw-animate-css": "^1.2.8" + "tw-animate-css": "^1.2.8", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.22.0", @@ -1356,6 +1358,34 @@ } } }, + "node_modules/@radix-ui/react-context-menu": { + "version": "2.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context-menu/-/react-context-menu-2.2.12.tgz", + "integrity": "sha512-5UFKuTMX8F2/KjHvyqu9IYT8bEtDSCJwwIx1PghBo4jh9S6jJVsceq9xIjqsOVcxsynGwV5eaqPE3n/Cu+DrSA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-menu": "2.1.12", + "@radix-ui/react-primitive": "2.1.0", + "@radix-ui/react-use-callback-ref": "1.1.1", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-dialog": { "version": "1.1.11", "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.11.tgz", @@ -5265,6 +5295,19 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, + "node_modules/vaul": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz", + "integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-dialog": "^1.1.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/vite": { "version": "6.3.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.3.tgz", diff --git a/page/package.json b/page/package.json index 374fc448..b428945a 100644 --- a/page/package.json +++ b/page/package.json @@ -13,6 +13,7 @@ "@fontsource/inter": "^5.2.5", "@irori/idbfs": "^0.5.0", "@radix-ui/react-checkbox": "^1.2.3", + "@radix-ui/react-context-menu": "^2.2.12", "@radix-ui/react-dialog": "^1.1.11", "@radix-ui/react-dropdown-menu": "^2.1.12", "@radix-ui/react-label": "^2.1.4", @@ -38,7 +39,8 @@ "react-window": "^1.8.11", "tailwind-merge": "^3.2.0", "tailwindcss": "^4.1.4", - "tw-animate-css": "^1.2.8" + "tw-animate-css": "^1.2.8", + "vaul": "^1.1.2" }, "devDependencies": { "@eslint/js": "^9.22.0", diff --git a/page/src/FilesystemExplorer.tsx b/page/src/FilesystemExplorer.tsx new file mode 100644 index 00000000..411604fb --- /dev/null +++ b/page/src/FilesystemExplorer.tsx @@ -0,0 +1,79 @@ +import React from "react"; +import { Folder, FolderElement, FolderElementType } from "./components/folder"; +import { Filesystem } from "./filesystem"; + +export interface FilesystemExplorerProps { + filesystem: Filesystem; +} +export interface FilesystemExplorerState { + path: string[]; +} + +function getFolderElements(filesystem: Filesystem, path: string[]) { + const fullPath = "/root/filesys/" + path.join("/"); + const files = filesystem.readDir(fullPath); + + return files + .filter((f) => { + if (f == ".") { + return false; + } + + if (path.length == 0 && f == "..") { + return false; + } + + return true; + }) + .map((f) => { + const element: FolderElement = { + name: f, + type: filesystem.isFolder(`${fullPath}/${f}`) + ? FolderElementType.Folder + : FolderElementType.File, + }; + + return element; + }); +} + +export class FilesystemExplorer extends React.Component< + FilesystemExplorerProps, + FilesystemExplorerState +> { + constructor(props: FilesystemExplorerProps) { + super(props); + + this._onElementSelect = this._onElementSelect.bind(this); + + this.state = { + path: [], + }; + } + + _onElementSelect(element: FolderElement) { + if (element.type != FolderElementType.Folder) { + return; + } + + this.setState((s) => { + const path = [...s.path]; + + if (element.name == "..") { + path.pop(); + } else { + path.push(element.name); + } + + return { + path, + }; + }); + } + + render() { + const elements = getFolderElements(this.props.filesystem, this.state.path); + + return ; + } +} diff --git a/page/src/Playground.tsx b/page/src/Playground.tsx index f9b98885..ac1e691a 100644 --- a/page/src/Playground.tsx +++ b/page/src/Playground.tsx @@ -4,7 +4,7 @@ import { Output } from "@/components/output"; import { Separator } from "@/components/ui/separator"; import { Emulator, UserFile, EmulationState } from "./emulator"; -import { setupFilesystem } from "./filesystem"; +import { Filesystem, setupFilesystem } from "./filesystem"; import "./App.css"; import { @@ -38,6 +38,18 @@ import { } from "@/components/ui/dropdown-menu"; import { Button } from "@/components/ui/button"; +import { + Drawer, + DrawerClose, + DrawerContent, + DrawerDescription, + DrawerFooter, + DrawerHeader, + DrawerTitle, + DrawerTrigger, +} from "@/components/ui/drawer"; +import { FilesystemExplorer } from "./FilesystemExplorer"; + function selectAndReadFile(): Promise { return new Promise((resolve, reject) => { const fileInput = document.createElement("input"); @@ -75,8 +87,23 @@ export function Playground() { const output = useRef(null); const [settings, setSettings] = useState(createDefaultSettings()); const [emulator, setEmulator] = useState(null); + const [filesystem, setFilesystem] = useState(null); + const [filesystemPromise, setFilesystemPromise] = + useState | null>(null); const [, forceUpdate] = useReducer((x) => x + 1, 0); + if (!filesystemPromise) { + const promise = new Promise((resolve) => { + setupFilesystem((current, total, file) => { + logLine(`Processing filesystem (${current}/${total}): ${file}`); + }).then(resolve); + }); + + promise.then(setFilesystem); + + setFilesystemPromise(promise); + } + function logLine(line: string) { output.current?.logLine(line); } @@ -103,7 +130,7 @@ export function Playground() { logLine("Starting emulation..."); - await setupFilesystem((current, total, file) => { + const fs = await setupFilesystem((current, total, file) => { logLine(`Processing filesystem (${current}/${total}): ${file}`); }); @@ -111,7 +138,7 @@ export function Playground() { new_emulator.onTerminate().then(() => setEmulator(null)); setEmulator(new_emulator); - new_emulator.start(settings, userFile); + new_emulator.start(settings, userFile, fs); } async function loadAndRunUserFile() { @@ -186,6 +213,31 @@ export function Playground() { + + {!filesystem ? ( + <> + ) : ( + + + + + + + {/*Filesystem Explorer + Description + + + */} + + + + + + + )} +
void; + +export interface FolderProps { + elements: FolderElement[]; + clickHandler: ClickHandler; + //deleteHandler: (element: FolderElement) => void; + //renameHandler: (element: FolderElement, name: string) => void; +} + +function elementComparator(e1: FolderElement, e2: FolderElement) { + if (e1.type != e2.type) { + return e1.type - e2.type; + } + + return e1.name.localeCompare(e2.name); +} + +function renderIcon(element: FolderElement) { + let className = "w-10 h-10"; + switch (element.type) { + case FolderElementType.File: + return ; + case FolderElementType.Folder: + return element.name == ".." ? ( + + ) : ( + + ); + default: + return <>; + } +} + +function renderElement(element: FolderElement, clickHandler: ClickHandler) { + return ( +
clickHandler(element)} + className="folder-element select-none flex flex-col gap-4 items-center text-center p-4 m-4 w-30 h-25 rounded-lg border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50" + > + {renderIcon(element)} + + {element.name} + +
+ ); +} + +function renderElementWithContext( + element: FolderElement, + clickHandler: ClickHandler, +) { + if (element.name == "..") { + return renderElement(element, clickHandler); + } + + return ( + + + {renderElement(element, clickHandler)} + + + Rename + Delete + + + ); +} + +export function Folder(props: FolderProps) { + return ( + + + +
+ {props.elements + .sort(elementComparator) + .map((e) => renderElementWithContext(e, props.clickHandler))} +
+
+ + Create new folder + +
+
+ ); +} diff --git a/page/src/components/ui/context-menu.tsx b/page/src/components/ui/context-menu.tsx new file mode 100644 index 00000000..f7cd5022 --- /dev/null +++ b/page/src/components/ui/context-menu.tsx @@ -0,0 +1,250 @@ +import * as React from "react"; +import * as ContextMenuPrimitive from "@radix-ui/react-context-menu"; +import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"; + +import { cn } from "@/lib/utils"; + +function ContextMenu({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuTrigger({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuPortal({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSub({ + ...props +}: React.ComponentProps) { + return ; +} + +function ContextMenuRadioGroup({ + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuSubTrigger({ + className, + inset, + children, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + {children} + + + ); +} + +function ContextMenuSubContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuContent({ + className, + ...props +}: React.ComponentProps) { + return ( + + + + ); +} + +function ContextMenuItem({ + className, + inset, + variant = "default", + ...props +}: React.ComponentProps & { + inset?: boolean; + variant?: "default" | "destructive"; +}) { + return ( + + ); +} + +function ContextMenuCheckboxItem({ + className, + children, + checked, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuRadioItem({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + + + + + {children} + + ); +} + +function ContextMenuLabel({ + className, + inset, + ...props +}: React.ComponentProps & { + inset?: boolean; +}) { + return ( + + ); +} + +function ContextMenuSeparator({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function ContextMenuShortcut({ + className, + ...props +}: React.ComponentProps<"span">) { + return ( + + ); +} + +export { + ContextMenu, + ContextMenuTrigger, + ContextMenuContent, + ContextMenuItem, + ContextMenuCheckboxItem, + ContextMenuRadioItem, + ContextMenuLabel, + ContextMenuSeparator, + ContextMenuShortcut, + ContextMenuGroup, + ContextMenuPortal, + ContextMenuSub, + ContextMenuSubContent, + ContextMenuSubTrigger, + ContextMenuRadioGroup, +}; diff --git a/page/src/components/ui/drawer.tsx b/page/src/components/ui/drawer.tsx new file mode 100644 index 00000000..7e2126e4 --- /dev/null +++ b/page/src/components/ui/drawer.tsx @@ -0,0 +1,130 @@ +import * as React from "react"; +import { Drawer as DrawerPrimitive } from "vaul"; + +import { cn } from "@/lib/utils"; + +function Drawer({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerTrigger({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerPortal({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerClose({ + ...props +}: React.ComponentProps) { + return ; +} + +function DrawerOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerContent({ + className, + children, + ...props +}: React.ComponentProps) { + return ( + + + +
+ {children} + + + ); +} + +function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ); +} + +function DrawerTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +function DrawerDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ); +} + +export { + Drawer, + DrawerPortal, + DrawerOverlay, + DrawerTrigger, + DrawerClose, + DrawerContent, + DrawerHeader, + DrawerFooter, + DrawerTitle, + DrawerDescription, +}; diff --git a/page/src/emulator.ts b/page/src/emulator.ts index 3bc7f45a..35647fa8 100644 --- a/page/src/emulator.ts +++ b/page/src/emulator.ts @@ -5,7 +5,7 @@ import * as flatbuffers from "flatbuffers"; import * as fbDebugger from "@/fb/debugger"; import * as fbDebuggerEvent from "@/fb/debugger/event"; -import { storeFile } from "./filesystem"; +import { Filesystem } from "./filesystem"; type LogHandler = (lines: string[]) => void; @@ -80,6 +80,7 @@ export class Emulator { async start( settings: Settings = createDefaultSettings(), userFile: UserFile | null = null, + fs: Filesystem, ) { var file = "c:/test-sample.exe"; if (userFile) { @@ -87,7 +88,7 @@ export class Emulator { const canonicalName = filename?.toLowerCase(); file = "c:/" + canonicalName; - await storeFile("root/filesys/c/" + canonicalName, userFile.data); + await fs.storeFile("root/filesys/c/" + canonicalName, userFile.data); } this._setState(EmulationState.Running); diff --git a/page/src/filesystem.ts b/page/src/filesystem.ts index 0b264a27..317df299 100644 --- a/page/src/filesystem.ts +++ b/page/src/filesystem.ts @@ -1,65 +1,6 @@ -import { parseZipFile, FileEntry, ProgressHandler } from "./zip-file"; +import { parseZipFile, ProgressHandler } from "./zip-file"; import idbfsModule, { MainModule } from "@irori/idbfs"; -function openDatabase(): Promise { - return new Promise((resolve, reject) => { - const request = indexedDB.open("cacheDB", 1); - - request.onerror = (event: Event) => { - reject(event); - }; - - request.onsuccess = (event: Event) => { - resolve((event as any).target.result as IDBDatabase); - }; - - request.onupgradeneeded = (event: Event) => { - const db = (event as any).target.result as IDBDatabase; - if (!db.objectStoreNames.contains("cacheStore")) { - db.createObjectStore("cacheStore", { keyPath: "id" }); - } - }; - }); -} - -async function saveData(id: string, data: any) { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(["cacheStore"], "readwrite"); - const objectStore = transaction.objectStore("cacheStore"); - const request = objectStore.put({ id: id, data: data }); - - request.onsuccess = () => { - resolve("Data saved successfully"); - }; - - request.onerror = (event) => { - reject("Save error: " + (event as any).target.errorCode); - }; - }); -} - -async function getData(id: string) { - const db = await openDatabase(); - return new Promise((resolve, reject) => { - const transaction = db.transaction(["cacheStore"], "readonly"); - const objectStore = transaction.objectStore("cacheStore"); - const request = objectStore.get(id); - - request.onsuccess = (event) => { - if ((event as any).target.result) { - resolve((event as any).target.result.data); - } else { - resolve(null); - } - }; - - request.onerror = (event) => { - reject("Retrieve error: " + (event as any).target.errorCode); - }; - }); -} - function fetchFilesystemZip() { return fetch("./root.zip?1", { method: "GET", @@ -97,11 +38,42 @@ async function initializeIDBFS() { return idbfs; } +export class Filesystem { + private idbfs: MainModule; + + constructor(idbfs: MainModule) { + this.idbfs = idbfs; + } + + async storeFile(file: string, data: ArrayBuffer) { + const buffer = new Uint8Array(data); + this.idbfs.FS.writeFile(file, buffer); + await this.sync(); + } + + async sync() { + await synchronizeIDBFS(this.idbfs, false); + } + + readDir(dir: string): string[] { + return this.idbfs.FS.readdir(dir); + } + + stat(file: string) { + return this.idbfs.FS.stat(file, false); + } + + isFolder(file: string) { + return (this.stat(file).mode & 0x4000) != 0; + } +} + export async function setupFilesystem(progressHandler: ProgressHandler) { const idbfs = await initializeIDBFS(); + const fs = new Filesystem(idbfs); if (idbfs.FS.analyzePath("/root/api-set.bin", false).exists) { - return; + return fs; } const filesystem = await fetchFilesystem(progressHandler); @@ -119,12 +91,7 @@ export async function setupFilesystem(progressHandler: ProgressHandler) { } }); - await synchronizeIDBFS(idbfs, false); -} + await fs.sync(); -export async function storeFile(file: string, data: ArrayBuffer) { - const idbfs = await initializeIDBFS(); - const buffer = new Uint8Array(data); - idbfs.FS.writeFile(file, buffer); - await synchronizeIDBFS(idbfs, false); + return fs; }