mirror of
https://github.com/momo5502/emulator.git
synced 2026-01-18 11:13:57 +00:00
Filesystem support (#249)
This commit is contained in:
@@ -1,3 +1,3 @@
|
||||
Module['preRun'] = () => {
|
||||
ENV = process.env;
|
||||
};
|
||||
Module["preRun"] = () => {
|
||||
ENV = process.env;
|
||||
};
|
||||
|
||||
84
page/package-lock.json
generated
84
page/package-lock.json
generated
@@ -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",
|
||||
@@ -30,13 +31,15 @@
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap-icons": "^1.11.5",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"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 +1359,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",
|
||||
@@ -3044,6 +3075,15 @@
|
||||
"node": ">=10"
|
||||
}
|
||||
},
|
||||
"node_modules/attr-accept": {
|
||||
"version": "2.2.5",
|
||||
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz",
|
||||
"integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=4"
|
||||
}
|
||||
},
|
||||
"node_modules/balanced-match": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
|
||||
@@ -3630,6 +3670,18 @@
|
||||
"node": ">=16.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/file-selector": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz",
|
||||
"integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.7.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12"
|
||||
}
|
||||
},
|
||||
"node_modules/fill-range": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
|
||||
@@ -4617,6 +4669,23 @@
|
||||
"react": "^19.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-dropzone": {
|
||||
"version": "14.3.8",
|
||||
"resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz",
|
||||
"integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"attr-accept": "^2.2.4",
|
||||
"file-selector": "^2.1.0",
|
||||
"prop-types": "^15.8.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.13"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">= 16.8 || 18.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/react-fast-compare": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz",
|
||||
@@ -5265,6 +5334,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",
|
||||
|
||||
@@ -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",
|
||||
@@ -32,13 +33,15 @@
|
||||
"react": "^19.0.0",
|
||||
"react-bootstrap-icons": "^1.11.5",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-dropzone": "^14.3.8",
|
||||
"react-helmet": "^6.1.0",
|
||||
"react-resizable-panels": "^2.1.9",
|
||||
"react-router-dom": "^7.5.3",
|
||||
"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",
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { ThemeProvider } from "@/components/theme-provider";
|
||||
import { TooltipProvider } from "@/components/ui/tooltip";
|
||||
import { HashRouter, Route, Routes, Navigate } from "react-router-dom";
|
||||
import { Playground } from "./Playground";
|
||||
import { LandingPage } from "./LandingPage";
|
||||
import { Playground } from "./playground";
|
||||
import { LandingPage } from "./landing-page";
|
||||
|
||||
import "@fontsource/inter/100.css";
|
||||
import "@fontsource/inter/200.css";
|
||||
@@ -18,13 +19,15 @@ import "./App.css";
|
||||
function App() {
|
||||
return (
|
||||
<ThemeProvider defaultTheme="dark" storageKey="vite-ui-theme">
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/playground" element={<Playground />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
<TooltipProvider>
|
||||
<HashRouter>
|
||||
<Routes>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="/" element={<LandingPage />} />
|
||||
<Route path="/playground" element={<Playground />} />
|
||||
</Routes>
|
||||
</HashRouter>
|
||||
</TooltipProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,201 +0,0 @@
|
||||
import { useState, useRef, useReducer } from "react";
|
||||
import { Output } from "@/components/output";
|
||||
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
import { Emulator, UserFile, EmulationState } from "./emulator";
|
||||
import { setupFilesystem } from "./filesystem";
|
||||
|
||||
import "./App.css";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { createDefaultSettings } from "./settings";
|
||||
import { SettingsMenu } from "@/components/settings-menu";
|
||||
|
||||
import {
|
||||
PlayFill,
|
||||
StopFill,
|
||||
GearFill,
|
||||
PauseFill,
|
||||
FileEarmarkCheckFill,
|
||||
ImageFill,
|
||||
} from "react-bootstrap-icons";
|
||||
import { StatusIndicator } from "@/components/status-indicator";
|
||||
import { Header } from "./Header";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuGroup,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
function selectAndReadFile(): Promise<UserFile> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".exe";
|
||||
|
||||
fileInput.addEventListener("change", function (event) {
|
||||
const file = (event as any).target.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function (e: ProgressEvent<FileReader>) {
|
||||
const arrayBuffer = e.target?.result;
|
||||
resolve({
|
||||
name: file.name,
|
||||
data: arrayBuffer as ArrayBuffer,
|
||||
});
|
||||
};
|
||||
|
||||
reader.onerror = function (e: ProgressEvent<FileReader>) {
|
||||
reject(new Error("Error reading file: " + e.target?.error));
|
||||
};
|
||||
|
||||
reader.readAsArrayBuffer(file);
|
||||
} else {
|
||||
reject(new Error("No file selected"));
|
||||
}
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
export function Playground() {
|
||||
const output = useRef<Output>(null);
|
||||
const [settings, setSettings] = useState(createDefaultSettings());
|
||||
const [emulator, setEmulator] = useState<Emulator | null>(null);
|
||||
const [, forceUpdate] = useReducer((x) => x + 1, 0);
|
||||
|
||||
function logLine(line: string) {
|
||||
output.current?.logLine(line);
|
||||
}
|
||||
|
||||
function logLines(lines: string[]) {
|
||||
output.current?.logLines(lines);
|
||||
}
|
||||
|
||||
function isEmulatorPaused() {
|
||||
return emulator && emulator.getState() == EmulationState.Paused;
|
||||
}
|
||||
|
||||
function toggleEmulatorState() {
|
||||
if (isEmulatorPaused()) {
|
||||
emulator?.resume();
|
||||
} else {
|
||||
emulator?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
async function createEmulator(userFile: UserFile | null = null) {
|
||||
emulator?.stop();
|
||||
output.current?.clear();
|
||||
|
||||
logLine("Starting emulation...");
|
||||
|
||||
await setupFilesystem((current, total, file) => {
|
||||
logLine(`Processing filesystem (${current}/${total}): ${file}`);
|
||||
});
|
||||
|
||||
const new_emulator = new Emulator(logLines, (_) => forceUpdate());
|
||||
new_emulator.onTerminate().then(() => setEmulator(null));
|
||||
setEmulator(new_emulator);
|
||||
|
||||
new_emulator.start(settings, userFile);
|
||||
}
|
||||
|
||||
async function loadAndRunUserFile() {
|
||||
const fileBuffer = await selectAndReadFile();
|
||||
await createEmulator(fileBuffer);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Playground - Sogen"
|
||||
description="Playground to test and run Sogen, the Windows user space emulator, right in your browser."
|
||||
/>
|
||||
<div className="h-[100dvh] flex flex-col">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b p-2 overflow-y-auto">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button size="sm" className="fancy">
|
||||
<PlayFill /> Run
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent className="w-56">
|
||||
<DropdownMenuLabel>Run Application</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup>
|
||||
<DropdownMenuItem onClick={() => createEmulator()}>
|
||||
<ImageFill className="mr-2" />
|
||||
<span>Select Sample</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => loadAndRunUserFile()}>
|
||||
<FileEarmarkCheckFill className="mr-2" />
|
||||
<span>Select your .exe</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuGroup>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
<Button
|
||||
disabled={!emulator}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="fancy"
|
||||
onClick={() => emulator?.stop()}
|
||||
>
|
||||
<StopFill /> Stop
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!emulator}
|
||||
variant="secondary"
|
||||
className="fancy"
|
||||
onClick={toggleEmulatorState}
|
||||
>
|
||||
{isEmulatorPaused() ? (
|
||||
<>
|
||||
<PlayFill /> Resume
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PauseFill /> Pause
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="secondary" className="fancy">
|
||||
<GearFill /> Settings
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<SettingsMenu settings={settings} onChange={setSettings} />
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="text-right flex-1">
|
||||
<StatusIndicator
|
||||
state={emulator ? emulator.getState() : EmulationState.Stopped}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-2 p-2 overflow-auto">
|
||||
<Output ref={output} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
158
page/src/components/folder.tsx
Normal file
158
page/src/components/folder.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import {
|
||||
FolderFill,
|
||||
FolderSymlinkFill,
|
||||
FileEarmark,
|
||||
FiletypeExe,
|
||||
FileEarmarkBinary,
|
||||
} from "react-bootstrap-icons";
|
||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuLabel,
|
||||
} from "@/components/ui/context-menu";
|
||||
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
|
||||
export enum FolderElementType {
|
||||
Folder = 0,
|
||||
File,
|
||||
}
|
||||
|
||||
export interface FolderElement {
|
||||
name: string;
|
||||
type: FolderElementType;
|
||||
}
|
||||
|
||||
type ClickHandler = (element: FolderElement) => void;
|
||||
type CreateFolderHandler = () => void;
|
||||
type RemoveElementHandler = (element: FolderElement) => void;
|
||||
type RenameElementHandler = (element: FolderElement) => void;
|
||||
type AddFilesHandler = () => void;
|
||||
|
||||
export interface FolderProps {
|
||||
elements: FolderElement[];
|
||||
clickHandler: ClickHandler;
|
||||
createFolderHandler: CreateFolderHandler;
|
||||
removeElementHandler: RemoveElementHandler;
|
||||
renameElementHandler: RenameElementHandler;
|
||||
addFilesHandler: AddFilesHandler;
|
||||
}
|
||||
|
||||
function elementComparator(e1: FolderElement, e2: FolderElement) {
|
||||
if (e1.type != e2.type) {
|
||||
return e1.type - e2.type;
|
||||
}
|
||||
|
||||
return e1.name.localeCompare(e2.name);
|
||||
}
|
||||
|
||||
function getIcon(element: FolderElement, className: string = "") {
|
||||
switch (element.type) {
|
||||
case FolderElementType.File:
|
||||
if (element.name.endsWith(".dll")) {
|
||||
return <FileEarmarkBinary className={className} />;
|
||||
}
|
||||
if (element.name.endsWith(".exe")) {
|
||||
return <FiletypeExe className={className} />;
|
||||
}
|
||||
return <FileEarmark className={className} />;
|
||||
case FolderElementType.Folder:
|
||||
return element.name == ".." ? (
|
||||
<FolderSymlinkFill className={className} />
|
||||
) : (
|
||||
<FolderFill className={className} />
|
||||
);
|
||||
default:
|
||||
return <></>;
|
||||
}
|
||||
}
|
||||
|
||||
function renderIcon(element: FolderElement) {
|
||||
let className = "w-6 h-6 flex-1";
|
||||
return getIcon(element, className);
|
||||
}
|
||||
|
||||
function renderElement(element: FolderElement, clickHandler: ClickHandler) {
|
||||
return (
|
||||
<div
|
||||
onClick={() => clickHandler(element)}
|
||||
className="folder-element select-none flex flex-col gap-2 items-center text-center text-xs p-2 m-2 w-25 h-18 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)}
|
||||
<span className="whitespace-nowrap text-ellipsis overflow-hidden w-20">
|
||||
{element.name}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function renderElementWithContext(element: FolderElement, props: FolderProps) {
|
||||
if (element.name == "..") {
|
||||
return renderElement(element, props.clickHandler);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<Tooltip delayDuration={700}>
|
||||
<TooltipTrigger asChild>
|
||||
{renderElement(element, props.clickHandler)}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{element.name}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuLabel inset>{element.name}</ContextMenuLabel>
|
||||
<ContextMenuSeparator />
|
||||
<ContextMenuItem onClick={() => props.renameElementHandler(element)}>
|
||||
Rename
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={() => props.removeElementHandler(element)}>
|
||||
Delete
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
|
||||
function renderElementWrapper(element: FolderElement, props: FolderProps) {
|
||||
return (
|
||||
<div key={`folder-element-${element.name}`}>
|
||||
{renderElementWithContext(element, props)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Folder(props: FolderProps) {
|
||||
return (
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>
|
||||
<ScrollArea className="h-[50dvh]">
|
||||
<div className="folder flex flex-wrap h-full">
|
||||
{props.elements
|
||||
.sort(elementComparator)
|
||||
.map((e) => renderElementWrapper(e, props))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem onClick={props.createFolderHandler}>
|
||||
Create new Folder
|
||||
</ContextMenuItem>
|
||||
<ContextMenuItem onClick={props.addFilesHandler}>
|
||||
Add Files
|
||||
</ContextMenuItem>
|
||||
</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
);
|
||||
}
|
||||
@@ -24,8 +24,10 @@ export class SettingsMenu extends React.Component<SettingsMenuProps, Settings> {
|
||||
this.setState(() => settings);
|
||||
}
|
||||
|
||||
componentDidUpdate() {
|
||||
this.props.onChange(this.state);
|
||||
componentDidUpdate(_: SettingsMenuProps, oldSettings: Settings) {
|
||||
if (JSON.stringify(oldSettings) !== JSON.stringify(this.state)) {
|
||||
this.props.onChange(this.state);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
250
page/src/components/ui/context-menu.tsx
Normal file
250
page/src/components/ui/context-menu.tsx
Normal file
@@ -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<typeof ContextMenuPrimitive.Root>) {
|
||||
return <ContextMenuPrimitive.Root data-slot="context-menu" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Trigger>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Trigger data-slot="context-menu-trigger" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Group>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Group data-slot="context-menu-group" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Portal>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal data-slot="context-menu-portal" {...props} />
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSub({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Sub>) {
|
||||
return <ContextMenuPrimitive.Sub data-slot="context-menu-sub" {...props} />;
|
||||
}
|
||||
|
||||
function ContextMenuRadioGroup({
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioGroup>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioGroup
|
||||
data-slot="context-menu-radio-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubTrigger({
|
||||
className,
|
||||
inset,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubTrigger> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubTrigger
|
||||
data-slot="context-menu-sub-trigger"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<ChevronRightIcon className="ml-auto" />
|
||||
</ContextMenuPrimitive.SubTrigger>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSubContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.SubContent>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.SubContent
|
||||
data-slot="context-menu-sub-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Content>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Portal>
|
||||
<ContextMenuPrimitive.Content
|
||||
data-slot="context-menu-content"
|
||||
className={cn(
|
||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-context-menu-content-available-height) min-w-[8rem] origin-(--radix-context-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
</ContextMenuPrimitive.Portal>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuItem({
|
||||
className,
|
||||
inset,
|
||||
variant = "default",
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Item> & {
|
||||
inset?: boolean;
|
||||
variant?: "default" | "destructive";
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Item
|
||||
data-slot="context-menu-item"
|
||||
data-inset={inset}
|
||||
data-variant={variant}
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuCheckboxItem({
|
||||
className,
|
||||
children,
|
||||
checked,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.CheckboxItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.CheckboxItem
|
||||
data-slot="context-menu-checkbox-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
checked={checked}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CheckIcon className="size-4" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.CheckboxItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuRadioItem({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.RadioItem>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.RadioItem
|
||||
data-slot="context-menu-radio-item"
|
||||
className={cn(
|
||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||
<ContextMenuPrimitive.ItemIndicator>
|
||||
<CircleIcon className="size-2 fill-current" />
|
||||
</ContextMenuPrimitive.ItemIndicator>
|
||||
</span>
|
||||
{children}
|
||||
</ContextMenuPrimitive.RadioItem>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuLabel({
|
||||
className,
|
||||
inset,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Label> & {
|
||||
inset?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Label
|
||||
data-slot="context-menu-label"
|
||||
data-inset={inset}
|
||||
className={cn(
|
||||
"text-foreground px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuSeparator({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof ContextMenuPrimitive.Separator>) {
|
||||
return (
|
||||
<ContextMenuPrimitive.Separator
|
||||
data-slot="context-menu-separator"
|
||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ContextMenuShortcut({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"span">) {
|
||||
return (
|
||||
<span
|
||||
data-slot="context-menu-shortcut"
|
||||
className={cn(
|
||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
ContextMenu,
|
||||
ContextMenuTrigger,
|
||||
ContextMenuContent,
|
||||
ContextMenuItem,
|
||||
ContextMenuCheckboxItem,
|
||||
ContextMenuRadioItem,
|
||||
ContextMenuLabel,
|
||||
ContextMenuSeparator,
|
||||
ContextMenuShortcut,
|
||||
ContextMenuGroup,
|
||||
ContextMenuPortal,
|
||||
ContextMenuSub,
|
||||
ContextMenuSubContent,
|
||||
ContextMenuSubTrigger,
|
||||
ContextMenuRadioGroup,
|
||||
};
|
||||
133
page/src/components/ui/dialog.tsx
Normal file
133
page/src/components/ui/dialog.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import * as React from "react";
|
||||
import * as DialogPrimitive from "@radix-ui/react-dialog";
|
||||
import { XIcon } from "lucide-react";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function Dialog({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Root>) {
|
||||
return <DialogPrimitive.Root data-slot="dialog" {...props} />;
|
||||
}
|
||||
|
||||
function DialogTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Trigger>) {
|
||||
return <DialogPrimitive.Trigger data-slot="dialog-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DialogPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Portal>) {
|
||||
return <DialogPrimitive.Portal data-slot="dialog-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DialogClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Close>) {
|
||||
return <DialogPrimitive.Close data-slot="dialog-close" {...props} />;
|
||||
}
|
||||
|
||||
function DialogOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Overlay>) {
|
||||
return (
|
||||
<DialogPrimitive.Overlay
|
||||
data-slot="dialog-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Content>) {
|
||||
return (
|
||||
<DialogPortal data-slot="dialog-portal">
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 sm:max-w-lg",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="ring-offset-background focus:ring-ring data-[state=open]:bg-accent data-[state=open]:text-muted-foreground absolute top-4 right-4 rounded-xs opacity-70 transition-opacity hover:opacity-100 focus:ring-2 focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4">
|
||||
<XIcon />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-header"
|
||||
className={cn("flex flex-col gap-2 text-center sm:text-left", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="dialog-footer"
|
||||
className={cn(
|
||||
"flex flex-col-reverse gap-2 sm:flex-row sm:justify-end",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Title>) {
|
||||
return (
|
||||
<DialogPrimitive.Title
|
||||
data-slot="dialog-title"
|
||||
className={cn("text-lg leading-none font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DialogDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DialogPrimitive.Description>) {
|
||||
return (
|
||||
<DialogPrimitive.Description
|
||||
data-slot="dialog-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
};
|
||||
130
page/src/components/ui/drawer.tsx
Normal file
130
page/src/components/ui/drawer.tsx
Normal file
@@ -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<typeof DrawerPrimitive.Root>) {
|
||||
return <DrawerPrimitive.Root data-slot="drawer" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerTrigger({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Trigger>) {
|
||||
return <DrawerPrimitive.Trigger data-slot="drawer-trigger" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerPortal({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Portal>) {
|
||||
return <DrawerPrimitive.Portal data-slot="drawer-portal" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerClose({
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Close>) {
|
||||
return <DrawerPrimitive.Close data-slot="drawer-close" {...props} />;
|
||||
}
|
||||
|
||||
function DrawerOverlay({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Overlay>) {
|
||||
return (
|
||||
<DrawerPrimitive.Overlay
|
||||
data-slot="drawer-overlay"
|
||||
className={cn(
|
||||
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 fixed inset-0 z-50 bg-black/50",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerContent({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Content>) {
|
||||
return (
|
||||
<DrawerPortal data-slot="drawer-portal">
|
||||
<DrawerOverlay />
|
||||
<DrawerPrimitive.Content
|
||||
data-slot="drawer-content"
|
||||
className={cn(
|
||||
"group/drawer-content bg-background fixed z-50 flex h-auto flex-col",
|
||||
"data-[vaul-drawer-direction=top]:inset-x-0 data-[vaul-drawer-direction=top]:top-0 data-[vaul-drawer-direction=top]:mb-24 data-[vaul-drawer-direction=top]:max-h-[80vh] data-[vaul-drawer-direction=top]:rounded-b-lg data-[vaul-drawer-direction=top]:border-b",
|
||||
"data-[vaul-drawer-direction=bottom]:inset-x-0 data-[vaul-drawer-direction=bottom]:bottom-0 data-[vaul-drawer-direction=bottom]:mt-24 data-[vaul-drawer-direction=bottom]:max-h-[80vh] data-[vaul-drawer-direction=bottom]:rounded-t-lg data-[vaul-drawer-direction=bottom]:border-t",
|
||||
"data-[vaul-drawer-direction=right]:inset-y-0 data-[vaul-drawer-direction=right]:right-0 data-[vaul-drawer-direction=right]:w-3/4 data-[vaul-drawer-direction=right]:border-l data-[vaul-drawer-direction=right]:sm:max-w-sm",
|
||||
"data-[vaul-drawer-direction=left]:inset-y-0 data-[vaul-drawer-direction=left]:left-0 data-[vaul-drawer-direction=left]:w-3/4 data-[vaul-drawer-direction=left]:border-r data-[vaul-drawer-direction=left]:sm:max-w-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<div className="bg-muted mx-auto mt-4 hidden h-2 w-[100px] shrink-0 rounded-full group-data-[vaul-drawer-direction=bottom]/drawer-content:block" />
|
||||
{children}
|
||||
</DrawerPrimitive.Content>
|
||||
</DrawerPortal>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerHeader({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-header"
|
||||
className={cn("flex flex-col gap-1.5 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerFooter({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="drawer-footer"
|
||||
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerTitle({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Title>) {
|
||||
return (
|
||||
<DrawerPrimitive.Title
|
||||
data-slot="drawer-title"
|
||||
className={cn("text-foreground font-semibold", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function DrawerDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<typeof DrawerPrimitive.Description>) {
|
||||
return (
|
||||
<DrawerPrimitive.Description
|
||||
data-slot="drawer-description"
|
||||
className={cn("text-muted-foreground text-sm", className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Drawer,
|
||||
DrawerPortal,
|
||||
DrawerOverlay,
|
||||
DrawerTrigger,
|
||||
DrawerClose,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerFooter,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
};
|
||||
@@ -1,5 +1,3 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as TooltipPrimitive from "@radix-ui/react-tooltip";
|
||||
|
||||
|
||||
@@ -5,15 +5,10 @@ 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;
|
||||
|
||||
export interface UserFile {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
export enum EmulationState {
|
||||
Stopped,
|
||||
Paused,
|
||||
@@ -77,19 +72,7 @@ export class Emulator {
|
||||
this.worker.onmessage = this._onMessage.bind(this);
|
||||
}
|
||||
|
||||
async start(
|
||||
settings: Settings = createDefaultSettings(),
|
||||
userFile: UserFile | null = null,
|
||||
) {
|
||||
var file = "c:/test-sample.exe";
|
||||
if (userFile) {
|
||||
const filename = userFile.name.split("/").pop()?.split("\\").pop();
|
||||
const canonicalName = filename?.toLowerCase();
|
||||
file = "c:/" + canonicalName;
|
||||
|
||||
await storeFile("root/filesys/c/" + canonicalName, userFile.data);
|
||||
}
|
||||
|
||||
async start(settings: Settings = createDefaultSettings(), file: string) {
|
||||
this._setState(EmulationState.Running);
|
||||
this.worker.postMessage({
|
||||
message: "run",
|
||||
|
||||
572
page/src/filesystem-explorer.tsx
Normal file
572
page/src/filesystem-explorer.tsx
Normal file
@@ -0,0 +1,572 @@
|
||||
import React from "react";
|
||||
import { Folder, FolderElement, FolderElementType } from "./components/folder";
|
||||
import { Filesystem } from "./filesystem";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "./components/ui/button";
|
||||
import { Input } from "./components/ui/input";
|
||||
import { DialogDescription } from "@radix-ui/react-dialog";
|
||||
|
||||
import Dropzone from "react-dropzone";
|
||||
import {
|
||||
Breadcrumb,
|
||||
BreadcrumbItem,
|
||||
BreadcrumbLink,
|
||||
BreadcrumbList,
|
||||
BreadcrumbPage,
|
||||
BreadcrumbSeparator,
|
||||
} from "@/components/ui/breadcrumb";
|
||||
|
||||
import { HouseFill } from "react-bootstrap-icons";
|
||||
|
||||
export interface FilesystemExplorerProps {
|
||||
filesystem: Filesystem;
|
||||
runFile: (file: string) => void;
|
||||
resetFilesys: () => void;
|
||||
path: string[];
|
||||
}
|
||||
export interface FilesystemExplorerState {
|
||||
path: string[];
|
||||
createFolder: boolean;
|
||||
resetFilesys: boolean;
|
||||
errorText: string;
|
||||
removeFile: string;
|
||||
renameFile: string;
|
||||
}
|
||||
|
||||
function makeFullPath(path: string[]) {
|
||||
return "/root/filesys/" + path.join("/");
|
||||
}
|
||||
|
||||
function makeFullPathAndJoin(path: string[], element: string) {
|
||||
return makeFullPath([...path, element]);
|
||||
}
|
||||
|
||||
function makeFullPathWithState(
|
||||
state: FilesystemExplorerState,
|
||||
element: string,
|
||||
) {
|
||||
return makeFullPathAndJoin(state.path, element);
|
||||
}
|
||||
|
||||
function relativePathToWindowsPath(fullPath: string) {
|
||||
if (fullPath.length == 0) {
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
const drive = fullPath.substring(0, 1);
|
||||
const rest = fullPath.substring(1);
|
||||
|
||||
return `${drive}:${rest}`;
|
||||
}
|
||||
|
||||
function makeRelativePathWithState(
|
||||
state: FilesystemExplorerState,
|
||||
element: string,
|
||||
) {
|
||||
return [...state.path, element].join("/");
|
||||
}
|
||||
|
||||
function makeWindowsPathWithState(
|
||||
state: FilesystemExplorerState,
|
||||
element: string,
|
||||
) {
|
||||
const fullPath = makeRelativePathWithState(state, element);
|
||||
return relativePathToWindowsPath(fullPath);
|
||||
}
|
||||
|
||||
function getFolderElements(filesystem: Filesystem, path: string[]) {
|
||||
const fullPath = makeFullPath(path);
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
interface FileWithData {
|
||||
file: File;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
function readFile(file: File): Promise<FileWithData> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
if (reader.readyState === FileReader.DONE) {
|
||||
resolve({
|
||||
file,
|
||||
data: reader.result as ArrayBuffer,
|
||||
});
|
||||
}
|
||||
};
|
||||
reader.onerror = reject;
|
||||
reader.readAsArrayBuffer(file);
|
||||
});
|
||||
}
|
||||
|
||||
async function readFiles(files: FileList | File[]): Promise<FileWithData[]> {
|
||||
const promises = [];
|
||||
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
promises.push(readFile(files[i]));
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
function selectFiles(): Promise<FileList> {
|
||||
return new Promise((resolve) => {
|
||||
const fileInput = document.createElement("input");
|
||||
fileInput.type = "file";
|
||||
fileInput.accept = ".exe";
|
||||
|
||||
fileInput.addEventListener("change", function (event) {
|
||||
const files = (event as any).target.files as FileList;
|
||||
resolve(files);
|
||||
});
|
||||
|
||||
fileInput.click();
|
||||
});
|
||||
}
|
||||
|
||||
interface BreadcrumbElement {
|
||||
node: React.ReactNode;
|
||||
targetPath: string[];
|
||||
}
|
||||
|
||||
function generateBreadcrumbElements(path: string[]): BreadcrumbElement[] {
|
||||
const elements = path.map((p, index) => {
|
||||
const e: BreadcrumbElement = {
|
||||
node: p,
|
||||
targetPath: path.slice(0, index + 1),
|
||||
};
|
||||
|
||||
return e;
|
||||
});
|
||||
elements.unshift({
|
||||
node: <HouseFill />,
|
||||
targetPath: [],
|
||||
});
|
||||
|
||||
return elements;
|
||||
}
|
||||
|
||||
export class FilesystemExplorer extends React.Component<
|
||||
FilesystemExplorerProps,
|
||||
FilesystemExplorerState
|
||||
> {
|
||||
constructor(props: FilesystemExplorerProps) {
|
||||
super(props);
|
||||
|
||||
this._onAddFiles = this._onAddFiles.bind(this);
|
||||
this._uploadFiles = this._uploadFiles.bind(this);
|
||||
this._onElementSelect = this._onElementSelect.bind(this);
|
||||
|
||||
this.state = {
|
||||
path: this.props.path,
|
||||
createFolder: false,
|
||||
resetFilesys: false,
|
||||
errorText: "",
|
||||
removeFile: "",
|
||||
renameFile: "",
|
||||
};
|
||||
}
|
||||
|
||||
_showError(errorText: string) {
|
||||
this.setState({ errorText });
|
||||
}
|
||||
|
||||
_onElementSelect(element: FolderElement) {
|
||||
if (element.type != FolderElementType.Folder) {
|
||||
if (element.name.endsWith(".exe")) {
|
||||
const file = makeWindowsPathWithState(this.state, element.name);
|
||||
this.props.runFile(file);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState((s) => {
|
||||
const path = [...s.path];
|
||||
|
||||
if (element.name == "..") {
|
||||
path.pop();
|
||||
} else {
|
||||
path.push(element.name);
|
||||
}
|
||||
|
||||
return {
|
||||
path,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async _onFileRename(file: string, newFile: string) {
|
||||
const oldPath = makeFullPathWithState(this.state, file);
|
||||
const newPath = makeFullPathWithState(this.state, newFile);
|
||||
|
||||
this.setState({ renameFile: "" });
|
||||
|
||||
await this.props.filesystem.rename(oldPath, newPath);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
async _onAddFiles() {
|
||||
const files = await selectFiles();
|
||||
await this._uploadFiles(files);
|
||||
}
|
||||
|
||||
async _onFolderCreate(name: string) {
|
||||
this.setState({ createFolder: false });
|
||||
|
||||
name = name.toLowerCase();
|
||||
|
||||
if (name.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (name.includes("/") || name.includes("\\")) {
|
||||
this._showError("Folder must not contain special characters");
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.path.length == 0 && name.length > 1) {
|
||||
this._showError("Drives must be a single letter");
|
||||
return;
|
||||
}
|
||||
|
||||
const fullPath = makeFullPathWithState(this.state, name);
|
||||
await this.props.filesystem.createFolder(fullPath);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
async _uploadFiles(files: FileList | File[]) {
|
||||
if (files.length == 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.state.path.length == 0) {
|
||||
this._showError("Files must be within a drive");
|
||||
return;
|
||||
}
|
||||
|
||||
const fileData = (await readFiles(files)).map((f) => {
|
||||
return {
|
||||
name: makeFullPathWithState(this.state, f.file.name.toLowerCase()),
|
||||
data: f.data,
|
||||
};
|
||||
});
|
||||
|
||||
await this.props.filesystem.storeFiles(fileData);
|
||||
this.forceUpdate();
|
||||
}
|
||||
|
||||
_renderCreateFolderDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.createFolder}
|
||||
onOpenChange={(open) => this.setState({ createFolder: open })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
const folderName = (e.target as any).elements.name.value;
|
||||
this._onFolderCreate(folderName);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create new folder</DialogTitle>
|
||||
<DialogDescription className="hidden">
|
||||
Create new folder
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input id="name" defaultValue="New Folder" />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="fancy rounded-lg">
|
||||
Create
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" className="fancy rounded-lg">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_renderRenameDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.renameFile.length > 0}
|
||||
onOpenChange={(open) => (open ? {} : this.setState({ renameFile: "" }))}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<form
|
||||
onSubmit={(e) => {
|
||||
const newName = (e.target as any).elements.name.value;
|
||||
this._onFileRename(this.state.renameFile, newName);
|
||||
e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Rename {this.state.renameFile}</DialogTitle>
|
||||
<DialogDescription className="hidden">
|
||||
Rename {this.state.renameFile}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
<Input id="name" defaultValue={this.state.renameFile} />
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button type="submit" className="fancy rounded-lg">
|
||||
Rename
|
||||
</Button>
|
||||
<DialogClose asChild>
|
||||
<Button variant="secondary" className="fancy rounded-lg">
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_renderErrorDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.errorText.length > 0}
|
||||
onOpenChange={(open) => (open ? {} : this.setState({ errorText: "" }))}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Error</DialogTitle>
|
||||
<DialogDescription className="hidden">
|
||||
Error: {this.state.errorText}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">{this.state.errorText}</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => this.setState({ errorText: "" })}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_renderRemoveDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.removeFile.length > 0}
|
||||
onOpenChange={(open) => (open ? {} : this.setState({ removeFile: "" }))}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete {this.state.removeFile}?</DialogTitle>
|
||||
<DialogDescription className="hidden">
|
||||
Delete {this.state.removeFile}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
Are you sure you want to delete{" "}
|
||||
<b>
|
||||
{makeRelativePathWithState(this.state, this.state.removeFile)}
|
||||
</b>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
const file = makeFullPathWithState(
|
||||
this.state,
|
||||
this.state.removeFile,
|
||||
);
|
||||
this.setState({ removeFile: "" });
|
||||
this.props.filesystem
|
||||
.unlink(file)
|
||||
.then(() => this.forceUpdate());
|
||||
}}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
this.setState({ removeFile: "" });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_renderResetDialog() {
|
||||
return (
|
||||
<Dialog
|
||||
open={this.state.resetFilesys}
|
||||
onOpenChange={(open) => this.setState({ resetFilesys: open })}
|
||||
>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Reset filesystem</DialogTitle>
|
||||
<DialogDescription className="hidden">
|
||||
Reset filesystem
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="py-4">
|
||||
Are you sure you want to reset the filesystem?
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => {
|
||||
this.setState({ resetFilesys: false });
|
||||
this.props.resetFilesys();
|
||||
}}
|
||||
>
|
||||
Ok
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => {
|
||||
this.setState({ resetFilesys: false });
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
_renderBreadcrumbElements() {
|
||||
const elements = generateBreadcrumbElements(this.state.path);
|
||||
|
||||
const nodes = elements.map((e, index) => {
|
||||
if (index == this.state.path.length) {
|
||||
return (
|
||||
<BreadcrumbItem key={`breadcrumb-item-${index}`}>
|
||||
<BreadcrumbPage key={`breadcrumb-page-${index}`}>
|
||||
{e.node}
|
||||
</BreadcrumbPage>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
}
|
||||
|
||||
const navigate = () => this.setState({ path: e.targetPath });
|
||||
return (
|
||||
<BreadcrumbItem key={`breadcrumb-item-${index}`}>
|
||||
<BreadcrumbLink key={`breadcrumb-link-${index}`} onClick={navigate}>
|
||||
{e.node}
|
||||
</BreadcrumbLink>
|
||||
</BreadcrumbItem>
|
||||
);
|
||||
});
|
||||
|
||||
return [
|
||||
...nodes.map((n, index) => [
|
||||
n,
|
||||
<BreadcrumbSeparator key={`breadcrumb-separator-${index}`} />,
|
||||
]),
|
||||
].slice(0, -1);
|
||||
}
|
||||
|
||||
_renderBreadCrumb() {
|
||||
return (
|
||||
<Breadcrumb>
|
||||
<BreadcrumbList>{this._renderBreadcrumbElements()}</BreadcrumbList>
|
||||
</Breadcrumb>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
const elements = getFolderElements(this.props.filesystem, this.state.path);
|
||||
|
||||
return (
|
||||
<>
|
||||
{this._renderCreateFolderDialog()}
|
||||
{this._renderRenameDialog()}
|
||||
{this._renderErrorDialog()}
|
||||
{this._renderRemoveDialog()}
|
||||
{this._renderResetDialog()}
|
||||
|
||||
<div className="flex flex-row w-full items-center gap-3">
|
||||
<div className="whitespace-nowrap">{this._renderBreadCrumb()}</div>
|
||||
<div className="flex-1 text-right">
|
||||
<Button
|
||||
onClick={() => this.setState({ resetFilesys: true })}
|
||||
variant="destructive"
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dropzone onDrop={this._uploadFiles} noClick={true}>
|
||||
{({ getRootProps, getInputProps }) => (
|
||||
<div {...getRootProps()}>
|
||||
<input {...getInputProps()} />
|
||||
<Folder
|
||||
elements={elements}
|
||||
clickHandler={this._onElementSelect}
|
||||
createFolderHandler={() =>
|
||||
this.setState({ createFolder: true })
|
||||
}
|
||||
removeElementHandler={(e) =>
|
||||
this.setState({ removeFile: e.name })
|
||||
}
|
||||
renameElementHandler={(e) =>
|
||||
this.setState({ renameFile: e.name })
|
||||
}
|
||||
addFilesHandler={this._onAddFiles}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Dropzone>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<IDBDatabase> {
|
||||
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,131 @@ async function initializeIDBFS() {
|
||||
return idbfs;
|
||||
}
|
||||
|
||||
export interface FileWithData {
|
||||
name: string;
|
||||
data: ArrayBuffer;
|
||||
}
|
||||
|
||||
function deleteDatabase(dbName: string) {
|
||||
return new Promise<void>((resolve, reject) => {
|
||||
const request = indexedDB.deleteDatabase(dbName);
|
||||
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
|
||||
request.onerror = () => {
|
||||
reject(new Error(`Error deleting database ${dbName}.`));
|
||||
};
|
||||
|
||||
request.onblocked = () => {
|
||||
reject(new Error(`Deletion of database ${dbName} blocked.`));
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function filterPseudoDir(e: string) {
|
||||
return e != "." && e != "..";
|
||||
}
|
||||
|
||||
export class Filesystem {
|
||||
private idbfs: MainModule;
|
||||
|
||||
constructor(idbfs: MainModule) {
|
||||
this.idbfs = idbfs;
|
||||
}
|
||||
|
||||
_storeFile(file: FileWithData) {
|
||||
if (file.name.includes("/")) {
|
||||
const folder = file.name.split("/").slice(0, -1).join("/");
|
||||
this._createFolder(folder);
|
||||
}
|
||||
|
||||
const buffer = new Uint8Array(file.data);
|
||||
this.idbfs.FS.writeFile(file.name, buffer);
|
||||
}
|
||||
|
||||
async storeFiles(files: FileWithData[]) {
|
||||
files.forEach((f) => {
|
||||
this._storeFile(f);
|
||||
});
|
||||
|
||||
await this.sync();
|
||||
}
|
||||
|
||||
_unlinkRecursive(element: string) {
|
||||
if (!this.isFolder(element)) {
|
||||
this.idbfs.FS.unlink(element);
|
||||
return;
|
||||
}
|
||||
|
||||
this.readDir(element) //
|
||||
.filter(filterPseudoDir)
|
||||
.forEach((e) => {
|
||||
this._unlinkRecursive(`${element}/${e}`);
|
||||
});
|
||||
|
||||
this.idbfs.FS.rmdir(element);
|
||||
}
|
||||
|
||||
async rename(oldFile: string, newFile: string) {
|
||||
this.idbfs.FS.rename(oldFile, newFile);
|
||||
await this.sync();
|
||||
}
|
||||
|
||||
async unlink(file: string) {
|
||||
this._unlinkRecursive(file);
|
||||
await this.sync();
|
||||
}
|
||||
|
||||
_createFolder(folder: string) {
|
||||
this.idbfs.FS.mkdirTree(folder, 0o777);
|
||||
}
|
||||
|
||||
async createFolder(folder: string) {
|
||||
this._createFolder(folder);
|
||||
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;
|
||||
}
|
||||
|
||||
async delete() {
|
||||
this.readDir("/root") //
|
||||
.filter(filterPseudoDir) //
|
||||
.forEach((e) => {
|
||||
try {
|
||||
this._unlinkRecursive(e);
|
||||
} catch (_) {}
|
||||
});
|
||||
|
||||
await this.sync();
|
||||
|
||||
try {
|
||||
await deleteDatabase("/root");
|
||||
} catch (e) {}
|
||||
}
|
||||
}
|
||||
|
||||
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 +180,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;
|
||||
}
|
||||
|
||||
257
page/src/playground.tsx
Normal file
257
page/src/playground.tsx
Normal file
@@ -0,0 +1,257 @@
|
||||
import React from "react";
|
||||
|
||||
import { Output } from "@/components/output";
|
||||
|
||||
import { Emulator, EmulationState } from "./emulator";
|
||||
import { Filesystem, setupFilesystem } from "./filesystem";
|
||||
|
||||
import "./App.css";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
|
||||
import { createDefaultSettings, Settings } from "./settings";
|
||||
import { SettingsMenu } from "@/components/settings-menu";
|
||||
|
||||
import { PlayFill, StopFill, GearFill, PauseFill } from "react-bootstrap-icons";
|
||||
import { StatusIndicator } from "@/components/status-indicator";
|
||||
import { Header } from "./Header";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerDescription,
|
||||
DrawerFooter,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
} from "@/components/ui/drawer";
|
||||
import { FilesystemExplorer } from "./filesystem-explorer";
|
||||
|
||||
interface PlaygroundProps {}
|
||||
interface PlaygroundState {
|
||||
settings: Settings;
|
||||
filesystemPromise: Promise<Filesystem> | null;
|
||||
filesystem: Filesystem | null;
|
||||
emulator: Emulator | null;
|
||||
drawerOpen: boolean;
|
||||
}
|
||||
|
||||
export class Playground extends React.Component<
|
||||
PlaygroundProps,
|
||||
PlaygroundState
|
||||
> {
|
||||
private output: React.RefObject<Output | null>;
|
||||
|
||||
constructor(props: PlaygroundProps) {
|
||||
super(props);
|
||||
|
||||
this.output = React.createRef();
|
||||
|
||||
this.start = this.start.bind(this);
|
||||
this.resetFilesys = this.resetFilesys.bind(this);
|
||||
this.createEmulator = this.createEmulator.bind(this);
|
||||
this.toggleEmulatorState = this.toggleEmulatorState.bind(this);
|
||||
|
||||
this.state = {
|
||||
settings: createDefaultSettings(),
|
||||
filesystemPromise: null,
|
||||
filesystem: null,
|
||||
emulator: null,
|
||||
drawerOpen: false,
|
||||
};
|
||||
}
|
||||
|
||||
async resetFilesys() {
|
||||
if (!this.state.filesystem) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.state.filesystem.delete();
|
||||
|
||||
this.setState({
|
||||
filesystemPromise: null,
|
||||
filesystem: null,
|
||||
drawerOpen: false,
|
||||
});
|
||||
|
||||
this.output.current?.clear();
|
||||
}
|
||||
|
||||
initFilesys() {
|
||||
if (this.state.filesystemPromise) {
|
||||
return this.state.filesystemPromise;
|
||||
}
|
||||
|
||||
const promise = new Promise<Filesystem>((resolve) => {
|
||||
this.logLine("Loading filesystem...");
|
||||
setupFilesystem((current, total, file) => {
|
||||
this.logLine(`Processing filesystem (${current}/${total}): ${file}`);
|
||||
}).then(resolve);
|
||||
});
|
||||
|
||||
promise.then((filesystem) => this.setState({ filesystem }));
|
||||
this.setState({ filesystemPromise: promise });
|
||||
|
||||
return promise;
|
||||
}
|
||||
|
||||
setDrawerOpen(drawerOpen: boolean) {
|
||||
this.setState({ drawerOpen });
|
||||
}
|
||||
|
||||
async start() {
|
||||
await this.initFilesys();
|
||||
this.setDrawerOpen(true);
|
||||
}
|
||||
|
||||
logLine(line: string) {
|
||||
this.output.current?.logLine(line);
|
||||
}
|
||||
|
||||
logLines(lines: string[]) {
|
||||
this.output.current?.logLines(lines);
|
||||
}
|
||||
|
||||
isEmulatorPaused() {
|
||||
return (
|
||||
this.state.emulator &&
|
||||
this.state.emulator.getState() == EmulationState.Paused
|
||||
);
|
||||
}
|
||||
|
||||
toggleEmulatorState() {
|
||||
if (this.isEmulatorPaused()) {
|
||||
this.state.emulator?.resume();
|
||||
} else {
|
||||
this.state.emulator?.pause();
|
||||
}
|
||||
}
|
||||
|
||||
async createEmulator(userFile: string) {
|
||||
this.state.emulator?.stop();
|
||||
this.output.current?.clear();
|
||||
|
||||
this.setDrawerOpen(false);
|
||||
|
||||
this.logLine("Starting emulation...");
|
||||
|
||||
if (this.state.filesystemPromise) {
|
||||
await this.state.filesystemPromise;
|
||||
}
|
||||
|
||||
const new_emulator = new Emulator(
|
||||
(l) => this.logLines(l),
|
||||
(_) => this.forceUpdate(),
|
||||
);
|
||||
new_emulator.onTerminate().then(() => this.setState({ emulator: null }));
|
||||
|
||||
this.setState({ emulator: new_emulator });
|
||||
|
||||
new_emulator.start(this.state.settings, userFile);
|
||||
}
|
||||
|
||||
render() {
|
||||
return (
|
||||
<>
|
||||
<Header
|
||||
title="Playground - Sogen"
|
||||
description="Playground to test and run Sogen, the Windows user space emulator, right in your browser."
|
||||
/>
|
||||
<div className="h-[100dvh] flex flex-col">
|
||||
<header className="flex shrink-0 items-center gap-2 border-b p-2 overflow-y-auto">
|
||||
<Button size="sm" className="fancy" onClick={this.start}>
|
||||
<PlayFill /> <span>Start</span>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
disabled={!this.state.emulator}
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="fancy"
|
||||
onClick={() => this.state.emulator?.stop()}
|
||||
>
|
||||
<StopFill /> <span className="hidden sm:inline">Stop</span>
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={!this.state.emulator}
|
||||
variant="secondary"
|
||||
className="fancy"
|
||||
onClick={this.toggleEmulatorState}
|
||||
>
|
||||
{this.isEmulatorPaused() ? (
|
||||
<>
|
||||
<PlayFill /> <span className="hidden sm:inline">Resume</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PauseFill /> <span className="hidden sm:inline">Pause</span>
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button size="sm" variant="secondary" className="fancy">
|
||||
<GearFill />{" "}
|
||||
<span className="hidden sm:inline">Settings</span>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent>
|
||||
<SettingsMenu
|
||||
settings={this.state.settings}
|
||||
onChange={(s) => this.setState({ settings: s })}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{!this.state.filesystem ? (
|
||||
<></>
|
||||
) : (
|
||||
<Drawer
|
||||
open={this.state.drawerOpen}
|
||||
onOpenChange={(o) => this.setState({ drawerOpen: o })}
|
||||
>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle className="hidden">
|
||||
Filesystem Explorer
|
||||
</DrawerTitle>
|
||||
<DrawerDescription className="hidden">
|
||||
Filesystem Explorer
|
||||
</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<DrawerFooter>
|
||||
<FilesystemExplorer
|
||||
filesystem={this.state.filesystem}
|
||||
runFile={this.createEmulator}
|
||||
resetFilesys={this.resetFilesys}
|
||||
path={["c"]}
|
||||
/>
|
||||
</DrawerFooter>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)}
|
||||
|
||||
<div className="text-right flex-1">
|
||||
<StatusIndicator
|
||||
state={
|
||||
this.state.emulator
|
||||
? this.state.emulator.getState()
|
||||
: EmulationState.Stopped
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
<div className="flex flex-1 flex-col gap-2 p-2 overflow-auto">
|
||||
<Output ref={this.output} />
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -126,6 +126,7 @@ CALL :collect d3d10.dll
|
||||
CALL :collect d3d10core.dll
|
||||
CALL :collect cabinet.dll
|
||||
CALL :collect msacm32.dll
|
||||
CALL :collect coloradapterclient.dll
|
||||
|
||||
CALL :collect locale.nls
|
||||
CALL :collect c_1252.nls
|
||||
|
||||
Reference in New Issue
Block a user