mirror of
https://github.com/momo5502/emulator.git
synced 2026-01-21 04:33:56 +00:00
684 lines
18 KiB
TypeScript
684 lines
18 KiB
TypeScript
import React from "react";
|
|
import {
|
|
Folder,
|
|
FolderElement,
|
|
FolderElementType,
|
|
trimFilename,
|
|
} 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";
|
|
import { parsePeIcon } from "./pe-icon-parser";
|
|
|
|
export interface FilesystemExplorerProps {
|
|
filesystem: Filesystem;
|
|
runFile: (file: string) => void;
|
|
resetFilesys: () => void;
|
|
path: string[];
|
|
iconCache: Map<string, string | null>;
|
|
}
|
|
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();
|
|
});
|
|
}
|
|
|
|
function getPeIcon(
|
|
filesystem: Filesystem,
|
|
file: string,
|
|
cache: Map<string, string | null>,
|
|
) {
|
|
if (!file || !file.endsWith(".exe")) {
|
|
return null;
|
|
}
|
|
|
|
const cachedValue = cache.get(file);
|
|
if (cachedValue) {
|
|
return cachedValue;
|
|
}
|
|
|
|
const data = filesystem.readFile(file);
|
|
const icon = parsePeIcon(data);
|
|
cache.set(file, icon);
|
|
|
|
return icon;
|
|
}
|
|
|
|
interface BreadcrumbElement {
|
|
node: React.ReactNode;
|
|
targetPath: string[];
|
|
}
|
|
|
|
function isGoodPath(path: any) {
|
|
return typeof path === "string" && path.length > 0;
|
|
}
|
|
|
|
function trimLeadingSlash(path: string) {
|
|
if (path.startsWith("/")) {
|
|
return path.substring(1);
|
|
}
|
|
|
|
return path;
|
|
}
|
|
|
|
function getFileName(file: File) {
|
|
const fileObj = file as any;
|
|
const properties = ["relativePath", "webkitRelativePath", "name"];
|
|
|
|
for (let i = 0; i < properties.length; ++i) {
|
|
const prop = properties[i];
|
|
|
|
if (prop in fileObj) {
|
|
const relativePath = fileObj[prop];
|
|
if (isGoodPath(relativePath)) {
|
|
return trimLeadingSlash(relativePath);
|
|
}
|
|
}
|
|
}
|
|
|
|
return file.name;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
function downloadData(
|
|
data: Uint8Array,
|
|
filename: string,
|
|
mimeType: string = "application/octet-stream",
|
|
) {
|
|
const buffer = data.buffer.slice(
|
|
data.byteOffset,
|
|
data.byteOffset + data.byteLength,
|
|
) as ArrayBuffer;
|
|
const blob = new Blob([buffer], { type: mimeType });
|
|
const url = URL.createObjectURL(blob);
|
|
|
|
const link = document.createElement("a");
|
|
link.href = url;
|
|
link.download = filename;
|
|
link.click();
|
|
|
|
URL.revokeObjectURL(url);
|
|
}
|
|
|
|
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: "" });
|
|
|
|
this._removeFromCache(file);
|
|
this._removeFromCache(newFile);
|
|
|
|
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) => {
|
|
const name = getFileName(f.file);
|
|
return {
|
|
name: makeFullPathWithState(this.state, name.toLowerCase()),
|
|
data: f.data,
|
|
};
|
|
});
|
|
|
|
fileData.forEach((d) => {
|
|
this._removeFromCache(d.name);
|
|
});
|
|
|
|
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 {trimFilename(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"
|
|
className="fancy rounded-lg"
|
|
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 {trimFilename(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 className="break-all">
|
|
{makeWindowsPathWithState(this.state, this.state.removeFile)}
|
|
</b>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button
|
|
variant="destructive"
|
|
className="fancy rounded-lg"
|
|
onClick={() => {
|
|
const file = makeFullPathWithState(
|
|
this.state,
|
|
this.state.removeFile,
|
|
);
|
|
this.setState({ removeFile: "" });
|
|
this._removeFromCache(file);
|
|
this.props.filesystem
|
|
.unlink(file)
|
|
.then(() => this.forceUpdate());
|
|
}}
|
|
>
|
|
Delete
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="fancy rounded-lg"
|
|
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"
|
|
className="fancy rounded-lg"
|
|
onClick={() => {
|
|
this.setState({ resetFilesys: false });
|
|
this.props.iconCache.clear();
|
|
this.props.resetFilesys();
|
|
}}
|
|
>
|
|
Reset
|
|
</Button>
|
|
<Button
|
|
variant="secondary"
|
|
className="fancy rounded-lg"
|
|
onClick={() => {
|
|
this.setState({ resetFilesys: false });
|
|
}}
|
|
>
|
|
Cancel
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
_renderBreadcrumbElements() {
|
|
const elements = generateBreadcrumbElements(this.state.path);
|
|
|
|
return 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>,
|
|
<BreadcrumbSeparator key={`breadcrumb-separator-${index}`} />,
|
|
];
|
|
});
|
|
}
|
|
|
|
_renderBreadCrumb() {
|
|
return (
|
|
<Breadcrumb>
|
|
<BreadcrumbList>{this._renderBreadcrumbElements()}</BreadcrumbList>
|
|
</Breadcrumb>
|
|
);
|
|
}
|
|
|
|
_removeFromCache(file: string) {
|
|
this.props.iconCache.delete(file);
|
|
}
|
|
|
|
_downloadFile(file: string) {
|
|
const fullPath = makeFullPathWithState(this.state, file);
|
|
const data = this.props.filesystem.readFile(fullPath);
|
|
downloadData(data, file);
|
|
}
|
|
|
|
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"
|
|
size="sm"
|
|
className="fancy rounded-lg"
|
|
>
|
|
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 })
|
|
}
|
|
downloadElementHandler={(e) => this._downloadFile(e.name)}
|
|
addFilesHandler={this._onAddFiles}
|
|
iconReader={(e) =>
|
|
getPeIcon(
|
|
this.props.filesystem,
|
|
makeFullPathWithState(this.state, e.name),
|
|
this.props.iconCache,
|
|
)
|
|
}
|
|
/>
|
|
</div>
|
|
)}
|
|
</Dropzone>
|
|
</>
|
|
);
|
|
}
|
|
}
|