Files
nand2tetris/web-ide-main/web/src/shell/file_select.tsx
T
2026-04-09 14:14:56 +02:00

335 lines
8.7 KiB
TypeScript

import { FileSystem, Stats } from "@davidsouther/jiffies/lib/esm/fs";
import { t, Trans } from "@lingui/macro";
import { useDialog } from "@nand2tetris/components/dialog";
import { sortFiles } from "@nand2tetris/components/file_utils";
import { BaseContext } from "@nand2tetris/components/stores/base.context.js";
import type JSZip from "jszip";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { AppContext } from "../App.context";
import { Icon } from "../pico/icon";
import "./file_select.scss";
import { newZip } from "./zip";
export const Selected = "file selected";
export interface FilePickerOptions {
suffix?: string | string[];
allowFolders?: boolean;
}
export interface LocalFile {
name: string;
content: string;
}
export function isPath(obj: unknown): obj is Path {
return (obj as Path).path != undefined && (obj as Path).isDir != undefined;
}
export interface Path {
path: string;
isDir: boolean;
}
// Selecting a file from the file picker would always return a Path on which we can use fs.readFile / fs.scandir later.
// In the case of local files, we have to load them on selection, and will return either a LocalFile or LocalFile[] in case of file/folder respectively.
export type FileSelectionRef = Path | LocalFile | LocalFile[];
export function useFilePicker() {
const dialog = useDialog();
const [suffix, setSuffix] = useState<string[]>();
const [allowFolders, setAllowFolders] = useState(false);
const allowLocal = useRef(false);
const selected = useRef<(v: FileSelectionRef) => void>();
const _select = useCallback(
async (options: FilePickerOptions): Promise<FileSelectionRef> => {
if (typeof options.suffix === "string") {
options.suffix = [options.suffix];
}
setSuffix(options.suffix);
setAllowFolders(options.allowFolders ?? false);
dialog.open();
return new Promise((resolve) => {
selected.current = resolve;
});
},
[dialog, selected],
);
const select = async (options: FilePickerOptions) => {
allowLocal.current = false;
return (await _select(options)) as Path;
};
const selectAllowLocal = async (options: FilePickerOptions) => {
allowLocal.current = true;
return _select(options);
};
return {
...dialog,
select,
selectAllowLocal,
[Selected]: selected,
suffix: suffix,
allowFolders,
allowLocal: allowLocal.current,
};
}
const FileEntry = ({
onClick,
onDoubleClick,
stats,
highlighted = false,
disabled = false,
}: {
stats: Stats;
highlighted?: boolean;
disabled?: boolean;
onClick?: () => void;
onDoubleClick?: () => void;
}) => {
const onClickCB = (event: { detail: number }) => {
if (event.detail == 1) {
onClick?.();
} else if (event.detail == 2) {
onDoubleClick?.();
}
};
return (
<div>
<button
className={`flex row justify-start outline ${
highlighted ? "" : "secondary"
}`}
style={{
textAlign: "left",
color: disabled ? "var(--disabled)" : undefined,
}}
onClick={onClickCB}
>
<Icon name={stats.isDirectory() ? "folder" : "draft"} />
{stats.name}
</button>
</div>
);
};
async function buildZip(zip: JSZip, fs: FileSystem, cwd: string) {
for (const entry of await fs.scandir(cwd)) {
if (entry.isDirectory()) {
const folder = zip.folder(entry.name);
if (folder) {
await buildZip(folder, fs, `${cwd}/${entry.name}`);
}
} else {
zip.file(entry.name, await fs.readFile(`${cwd}/${entry.name}`));
}
}
}
function isFileValid(filename: string, validSuffixes: string[]) {
return validSuffixes
.map((suffix) => filename.endsWith(suffix))
.reduce((p1, p2) => p1 || p2, false);
}
export const FilePicker = () => {
const { fs, setStatus, localFsRoot } = useContext(BaseContext);
const { filePicker } = useContext(AppContext);
const [files, setFiles] = useState<Stats[]>([]);
const [chosen, setFile] = useState<Path>({ path: fs.cwd(), isDir: true });
const cwd = fs.cwd();
const getFiles = (files: Stats[]) => {
return fs.cwd() != "/"
? [
{ isFile: () => false, isDirectory: () => true, name: ".." },
...sortFiles(files),
]
: sortFiles(files);
};
useEffect(() => {
if (!localFsRoot && fs.cwd() == "/") {
cd("projects");
}
setFile({ path: fs.cwd(), isDir: true });
}, [fs]);
useEffect(() => {
fs.scandir(fs.cwd()).then((files) => {
setFiles(getFiles(files));
});
}, [fs, cwd, setFiles]);
const cd = useCallback(
(dir: string) => {
fs.cd(dir);
fs.scandir(fs.cwd()).then((files) => {
setFiles(getFiles(files));
});
},
[fs, setFile, setFiles],
);
const select = useCallback(
(basename: string, isDir: boolean) => {
setFile({
path: `${fs.cwd() == "/" ? "" : fs.cwd()}/${basename}`,
isDir,
});
},
[setFile, fs],
);
const confirm = useCallback(() => {
filePicker.close();
filePicker[Selected].current?.(chosen);
}, [chosen, filePicker, setStatus]);
const loadRef = useRef<HTMLInputElement>(null);
const loadLocal = () => {
loadRef.current?.click();
};
const onLoadLocal = async () => {
if (
!loadRef.current ||
!loadRef.current.files ||
loadRef.current.files.length == 0
) {
return;
}
const files: LocalFile[] = [];
for (const file of loadRef.current.files) {
files.push({
name: file.name,
content: await file.text(),
});
}
filePicker[Selected].current?.(files.length == 1 ? files[0] : files);
filePicker.close();
};
const downloadRef = useRef<HTMLAnchorElement>(null);
const downloadFolder = async () => {
if (!downloadRef.current) {
return;
}
const zip = await newZip();
await buildZip(zip, fs, chosen.path);
const blob = await zip.generateAsync({ type: "blob" });
const url = URL.createObjectURL(blob);
downloadRef.current.href = url;
downloadRef.current.download = chosen.path.split("/").pop() ?? chosen.path;
downloadRef.current.click();
URL.revokeObjectURL(url);
};
const chosenFileName = useMemo(() => {
return chosen.path.split("/").pop();
}, [chosen]);
return (
<dialog open={filePicker.isOpen}>
<input
type="file"
ref={loadRef}
onChange={onLoadLocal}
style={{ display: "none" }}
webkitdirectory={filePicker.allowFolders ? "true" : undefined}
></input>
<article className="file-select flex">
<header>
<p>
<Trans>{t`Choose file`}</Trans>
</p>
<a
style={{ color: "rgba(0, 0, 0, 0)" }}
className="close"
href="#root"
onClick={(e) => {
e.preventDefault();
filePicker.close();
}}
>
{t`close`}
</a>
</header>
<main>
<a ref={downloadRef} style={{ display: "none" }} />
<div>
<b>{fs.cwd()}</b>
</div>
<div className="flex wrap files-container">
{files.map((file) => (
<FileEntry
key={file.name}
stats={file}
highlighted={file.name === chosenFileName}
onClick={() => select(file.name, file.isDirectory())}
onDoubleClick={() => {
if (file.isDirectory()) {
cd(file.name);
}
}}
disabled={
!(file.name == "..") &&
file.name.includes(".") &&
filePicker.suffix != undefined &&
!isFileValid(file.name, filePicker.suffix)
}
/>
))}
</div>
</main>
<footer>
<button
disabled={
!chosen ||
chosen.path == ".." ||
(filePicker.suffix != undefined &&
chosen.path.includes(".") &&
!isFileValid(chosen.path, filePicker.suffix)) ||
(!filePicker.allowFolders && !chosen.path.includes("."))
}
onClick={confirm}
>
{t`Select`}
</button>
{!localFsRoot && filePicker.allowLocal && (
<button onClick={loadLocal}>Select local file</button>
)}
{!localFsRoot && (
<button
onClick={downloadFolder}
data-tooltip={t`Download all files in this folder into a zip`}
disabled={chosen.path == "" || chosen.path.includes(".")}
>
{t`Download`}
</button>
)}
</footer>
</article>
</dialog>
);
};