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(); const [allowFolders, setAllowFolders] = useState(false); const allowLocal = useRef(false); const selected = useRef<(v: FileSelectionRef) => void>(); const _select = useCallback( async (options: FilePickerOptions): Promise => { 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 (
); }; 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([]); const [chosen, setFile] = useState({ 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(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(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 ( ); };