Files
codecraft/web/src/components/Files.tsx
T
Richard ZinkandClaude Opus 5.5 a0d0e707f2 Add Web Push, guided start screen, SVG icons, chunked uploads, rename
- Web Push (VAPID + RFC 8291, pure Rust): permission/question/finished
  notifications while no app window is visible; settings with test button
- Guided new-session flow: task, craft target (website/app/game/old
  software/fresh), clean room or deep, description, model, folder
  (new, existing or cloned git repository); originals upload to original/
- Craft phase bar (craft_phase tool) for study/specify/build/check/ready
- Chunked uploads (8 MB) up to server.max_upload_mb (default 500 MB)
- All UI glyphs and emojis replaced by themeable SVG icons
- Rename dialog with manual name or model-generated suggestion

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-08 21:03:26 +01:00

459 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Icon } from "./Icon";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { del, get, post, put } from "../api";
import { useFetch } from "../hooks";
import { fmtCompact, fmtDate, useT } from "../i18n";
import { Spinner } from "./Activity";
import { Diff } from "./Diff";
import { Markdown } from "./Markdown";
import { GitPanel, type GitStatus } from "./GitPanel";
import { FilePreview, previewKind, type PreviewKind } from "./FilePreview";
import { confirmDialog, promptDialog, toast } from "../dialogs";
const CodeEditor = lazy(() => import("./CodeEditor").then((m) => ({ default: m.CodeEditor })));
interface Entry {
name: string;
path: string;
is_dir: boolean;
size: number;
mtime?: number;
}
interface Listing {
path: string;
parent: string | null;
entries: Entry[];
repo: string | null;
}
interface OpenFile {
path: string;
content: string;
saved: string;
mtime?: number;
binary: boolean;
tooLarge?: boolean;
preview?: PreviewKind;
}
/** What the right pane shows. */
type Pane = { kind: "file"; file: OpenFile } | { kind: "diff"; title: string; diff: string } | { kind: "none" };
const IMAGE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
const rawUrl = (p: string, download = false) => `/api/files/raw?path=${encodeURIComponent(p)}${download ? "&download=true" : ""}`;
const baseName = (p: string) => p.split("/").filter(Boolean).pop() ?? p;
const dirName = (p: string) => p.replace(/\/[^/]+\/?$/, "") || "/";
function icon(e: Entry) {
if (e.is_dir) return <Icon name="folder" className="ficon dir" />;
if (IMAGE.test(e.name)) return <Icon name="image" className="ficon" />;
if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return <Icon name="music" className="ficon" />;
if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return <Icon name="cube" className="ficon" />;
if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return <Icon name="file-doc" className="ficon" />;
if (/\.(md|txt)$/i.test(e.name)) return <Icon name="file-text" className="ficon" />;
return <Icon name="file" className="ficon" />;
}
export function Files({ onMenu }: { onMenu: () => void }) {
const t = useT();
const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? [];
const [dir, setDir] = useState<string>(() => localStorage.getItem("codecraft.filesDir") ?? "");
const [listing, setListing] = useState<Listing>();
const [hidden, setHidden] = useState(false);
const [error, setError] = useState<string>();
const [pane, setPane] = useState<Pane>({ kind: "none" });
const [tab, setTab] = useState<"files" | "git">("files");
const [git, setGit] = useState<GitStatus>();
const [search, setSearch] = useState("");
const [hits, setHits] = useState<{ path: string; is_dir: boolean }[]>();
const [preview, setPreview] = useState(false);
const [dragOver, setDragOver] = useState(false);
const [mobileEditor, setMobileEditor] = useState(false);
const uploadRef = useRef<HTMLInputElement>(null);
const load = useCallback(
async (path: string) => {
try {
const l = await get<Listing>(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`);
setListing(l);
setDir(l.path);
localStorage.setItem("codecraft.filesDir", l.path);
setError(undefined);
} catch (e: any) {
setError(e.message);
if (roots[0] && path !== roots[0].path) void load(roots[0].path);
}
},
[hidden, roots],
);
useEffect(() => {
if (roots.length) void load(dir || roots[0].path);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [roots.length, hidden]);
const refreshGit = useCallback(async () => {
if (!listing?.repo) return setGit(undefined);
try {
setGit(await post<GitStatus>("/git", { path: listing.repo, action: "status" }));
} catch {
setGit(undefined);
}
}, [listing?.repo]);
useEffect(() => void refreshGit(), [refreshGit]);
// Git state per file name in the current directory.
const gitMarks = useMemo(() => {
const m = new Map<string, string>();
if (!git || !listing) return m;
for (const f of git.files) {
const abs = `${git.repo}/${f.path}`;
const code = f.index === "?" ? "U" : (f.index.trim() || f.worktree.trim());
// Mark the file itself and every parent folder inside the listing.
if (dirName(abs) === listing.path) m.set(abs, code);
for (const e of listing.entries) if (e.is_dir && abs.startsWith(e.path + "/")) m.set(e.path, "•");
}
return m;
}, [git, listing]);
const dirty = pane.kind === "file" && pane.file.content !== pane.file.saved;
const confirmDiscard = async () => !dirty || (await confirmDialog({ message: t("files.discardChanges"), danger: true, confirmLabel: t("files.discard") }));
const openFile = async (path: string) => {
if (!(await confirmDiscard())) return;
const kind = previewKind(path);
// Previewable files (except SVG, which can also be edited) are not loaded as text.
if (kind && !/\.svg$/i.test(path)) {
setPane({ kind: "file", file: { path, content: "", saved: "", binary: true, preview: kind } });
setMobileEditor(true);
return;
}
try {
const r = await get<{ content?: string; binary: boolean; mtime?: number; too_large?: boolean }>(`/files/read?path=${encodeURIComponent(path)}`);
setPane({ kind: "file", file: { path, content: r.content ?? "", saved: r.content ?? "", mtime: r.mtime, binary: r.binary, tooLarge: r.too_large } });
setPreview(/\.(md|svg)$/i.test(path));
setMobileEditor(true);
} catch (e: any) {
toast(e.message);
}
};
const save = useCallback(
async (force = false) => {
if (pane.kind !== "file") return;
const f = pane.file;
try {
const r = await put<{ mtime: number }>("/files/write", { path: f.path, content: f.content, expected_mtime: f.mtime, force });
setPane({ kind: "file", file: { ...f, saved: f.content, mtime: r.mtime } });
void refreshGit();
} catch (e: any) {
if (e.status === 409 && (await confirmDialog({ message: t("files.conflict"), danger: true, confirmLabel: t("files.overwrite") }))) return save(true);
toast(e.message);
}
},
[pane, refreshGit, t],
);
const act = async (fn: () => Promise<unknown>) => {
try {
await fn();
if (listing) await load(listing.path);
void refreshGit();
} catch (e: any) {
toast(e.message);
}
};
const newEntry = async (isDir: boolean) => {
const name = await promptDialog({ message: isDir ? t("files.newFolderName") : t("files.newFileName") });
if (!name || !listing) return;
const path = `${listing.path}/${name}`;
void act(async () => {
await post("/files/create", { path, dir: isDir });
if (!isDir) await openFile(path);
});
};
const rename = async (e: Entry) => {
const name = await promptDialog({ message: t("files.renameTo"), defaultValue: e.name });
if (name && name !== e.name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}` }));
};
const duplicate = async (e: Entry) => {
const name = await promptDialog({ message: t("files.copyTo"), defaultValue: `${e.name.replace(/(\.[^.]*)?$/, " copy$1")}` });
if (name) void act(() => post("/files/move", { from: e.path, to: `${dirName(e.path)}/${name}`, copy: true }));
};
const remove = async (e: Entry) => {
if (await confirmDialog({ message: t(e.is_dir ? "files.deleteFolderConfirm" : "files.deleteConfirm", { name: e.name }), danger: true, confirmLabel: t("common.delete") })) {
void act(() => del(`/files/delete?path=${encodeURIComponent(e.path)}`));
if (pane.kind === "file" && pane.file.path.startsWith(e.path)) setPane({ kind: "none" });
}
};
const upload = async (files: FileList | File[]) => {
if (!listing) return;
for (const f of [...files]) {
const res = await fetch(`/api/files/upload?path=${encodeURIComponent(`${listing.path}/${f.name}`)}`, { method: "PUT", body: f, credentials: "same-origin" });
if (!res.ok) toast(`${f.name}: HTTP ${res.status}${res.status === 413 ? " – too large for the reverse proxy" : ""}`);
}
await load(listing.path);
void refreshGit();
};
useEffect(() => {
if (!search.trim() || !listing) return setHits(undefined);
const h = setTimeout(() => {
get<{ path: string; is_dir: boolean }[]>(`/files/search?path=${encodeURIComponent(listing.path)}&q=${encodeURIComponent(search)}`).then(setHits).catch(() => setHits([]));
}, 250);
return () => clearTimeout(h);
}, [search, listing]);
// Warn before leaving with unsaved changes.
useEffect(() => {
const h = (e: BeforeUnloadEvent) => dirty && e.preventDefault();
window.addEventListener("beforeunload", h);
return () => window.removeEventListener("beforeunload", h);
}, [dirty]);
const root = roots.find((r) => listing?.path.startsWith(r.path));
const crumbs = listing && root ? [root.path, ...listing.path.slice(root.path.length).split("/").filter(Boolean)] : [];
const list = (
<div
className={`files-list ${dragOver ? "drag" : ""}`}
onDragOver={(e) => (e.preventDefault(), setDragOver(true))}
onDragLeave={() => setDragOver(false)}
onDrop={(e) => {
e.preventDefault();
setDragOver(false);
void upload(e.dataTransfer.files);
}}
>
<div className="files-toolbar">
{roots.length > 1 && (
<select value={root?.path ?? ""} onChange={(e) => { const v = e.target.value; void confirmDiscard().then((ok): unknown => ok && load(v)); }} aria-label={t("images.folder")}>
{roots.map((r) => (
<option key={r.path} value={r.path}>
{r.name}
</option>
))}
</select>
)}
<input className="files-search" placeholder={t("files.search")} value={search} onChange={(e) => setSearch(e.target.value)} />
<button className="small" title={t("files.newFile")} onClick={() => newEntry(false)}>
<Icon name="plus" className="tiny" /> <Icon name="file" />
</button>
<button className="small" title={t("files.newFolder")} onClick={() => newEntry(true)}>
<Icon name="plus" className="tiny" /> <Icon name="folder" />
</button>
<button className="small" title={t("files.upload")} onClick={() => uploadRef.current?.click()}>
<Icon name="upload" />
</button>
<input ref={uploadRef} type="file" multiple hidden onChange={(e) => e.target.files && upload(e.target.files)} />
<button className={`small ${hidden ? "active" : ""}`} title={t("files.showHidden")} onClick={() => setHidden(!hidden)}>
.*
</button>
</div>
<div className="crumbs">
{crumbs.map((c, i) => {
const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`;
return (
<span key={path}>
{i > 0 && " / "}
<a href="#" onClick={(e) => (e.preventDefault(), load(path))}>
{i === 0 ? root?.name : c}
</a>
</span>
);
})}
</div>
{error && <div className="notice err">{error}</div>}
<div className="entries" role="list">
{hits ? (
hits.length === 0 ? (
<div className="empty">{t("files.noHits")}</div>
) : (
hits.map((h) => (
<button key={h.path} className="entry" onClick={() => (h.is_dir ? load(h.path) : openFile(h.path))}>
<Icon name={h.is_dir ? "folder" : "file"} className="ficon" />
<span className="name">{h.path.slice((listing?.path.length ?? 0) + 1)}</span>
</button>
))
)
) : (
<>
{listing?.parent && (
<button className="entry" onClick={() => load(listing.parent!)}>
<Icon name="folder-up" className="ficon" />
<span className="name">..</span>
</button>
)}
{listing?.entries.map((e) => {
const mark = gitMarks.get(e.path);
const active = pane.kind === "file" && pane.file.path === e.path;
return (
<div key={e.path} className={`entry ${active ? "active" : ""}`} role="listitem">
<button className="entry-main" onClick={() => (e.is_dir ? load(e.path) : openFile(e.path))}>
<span>{icon(e)}</span>
<span className="name">{e.name}</span>
{mark && <span className={`git-mark m-${mark === "•" ? "dot" : mark}`}>{mark === "•" ? <Icon name="dot" className="tiny" /> : mark}</span>}
{!e.is_dir && <span className="size">{fmtCompact(e.size)}B</span>}
</button>
<details className="entry-menu">
<summary aria-label={t("files.actions")}><Icon name="more" /></summary>
<div className="menu">
<button onClick={() => rename(e)}>{t("files.rename")}</button>
<button onClick={() => duplicate(e)}>{t("files.duplicate")}</button>
{!e.is_dir && (
<a className="button" href={rawUrl(e.path, true)}>
{t("files.download")}
</a>
)}
<button className="danger" onClick={() => remove(e)}>
{t("common.delete")}
</button>
</div>
</details>
</div>
);
})}
{listing?.entries.length === 0 && <div className="empty">{t("files.emptyFolder")}</div>}
</>
)}
</div>
<div className="drop-hint">{t("files.dropHint")}</div>
</div>
);
const right = (() => {
if (pane.kind === "diff") {
return (
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => setMobileEditor(false)}>
<Icon name="arrow-left" />
</button>
<b className="grow">{pane.title}</b>
</div>
<div className="pane-scroll">{pane.diff ? <Diff diff={pane.diff} /> : <div className="empty">{t("git.noDiff")}</div>}</div>
</div>
);
}
if (pane.kind === "none") return <div className="editor-pane empty">{t("files.selectFile")}</div>;
const f = pane.file;
const isImage = IMAGE.test(f.path);
const isMd = /\.md$/i.test(f.path);
return (
<div className="editor-pane">
<div className="editor-bar">
<button className="ghost small mobile-only" onClick={() => void confirmDiscard().then((ok): unknown => ok && setMobileEditor(false))}>
<Icon name="arrow-left" />
</button>
<b className="grow" title={f.path}>
{baseName(f.path)}
{dirty && <span className="dirty"> <Icon name="dot" className="tiny" /></span>}
</b>
{(isMd || isImage) && !f.preview && (
<button className="small" onClick={() => setPreview(!preview)}>
{preview ? t("files.edit") : t("files.preview")}
</button>
)}
<a className="button small" href={rawUrl(f.path, true)}>
<Icon name="download" />
</a>
{!f.binary && (
<button className="small primary" disabled={!dirty} onClick={() => save()}>
{t("common.save")}
</button>
)}
</div>
{f.preview ? (
<FilePreview path={f.path} kind={f.preview} />
) : isImage && preview ? (
<div className="pane-scroll image-preview">
<img src={rawUrl(f.path)} alt={baseName(f.path)} />
</div>
) : f.binary ? (
<div className="empty">{f.tooLarge ? t("files.tooLarge") : t("files.binary")}</div>
) : isMd && preview ? (
<div className="pane-scroll" style={{ padding: 16 }}>
<Markdown text={f.content} />
</div>
) : (
<Suspense fallback={<div className="empty"><Spinner /></div>}>
<CodeEditor
key={f.path}
value={f.content}
filename={baseName(f.path)}
onChange={(v) => setPane((p) => (p.kind === "file" ? { kind: "file", file: { ...p.file, content: v } } : p))}
onSave={() => void save()}
/>
</Suspense>
)}
</div>
);
})();
return (
<>
<div className="topbar">
<button className="ghost menu-btn" onClick={onMenu} aria-label={t("nav.menu")}>
<Icon name="menu" />
</button>
<div className="grow">
<h1>{t("nav.files")}</h1>
<div className="path">{listing?.path}</div>
</div>
{listing?.repo && (
<div className="mode-tabs" role="tablist">
<button className={tab === "files" ? "active" : ""} onClick={() => setTab("files")}>
{t("files.tabFiles")}
</button>
<button className={tab === "git" ? "active" : ""} onClick={() => setTab("git")}>
Git{git && git.files.length > 0 ? ` (${git.files.length})` : ""}
</button>
</div>
)}
</div>
<div className={`files-layout ${mobileEditor ? "show-editor" : ""}`}>
<div className="files-side">
{tab === "git" && listing?.repo ? (
<GitPanel
repo={listing.repo}
status={git}
refresh={refreshGit}
showDiff={(title, diff) => (setPane({ kind: "diff", title, diff }), setMobileEditor(true))}
openFile={openFile}
/>
) : (
list
)}
{!listing?.repo && listing && (
<div className="git-init">
<button className="small" onClick={() => act(() => post("/git", { path: listing.path, action: "init" }))}>
{t("git.init")}
</button>
<button
className="small"
onClick={async () => {
const url = await promptDialog({ message: t("git.cloneUrl"), placeholder: "https://…/repo.git" });
if (!url) return;
const name = await promptDialog({ message: t("git.cloneFolder"), defaultValue: baseName(url).replace(/\.git$/, "") });
if (name) void act(() => post("/git", { path: `${listing.path}/${name}`, action: "clone", url }));
}}
>
{t("git.clone")}
</button>
</div>
)}
</div>
<div className="files-main">{right}</div>
</div>
{pane.kind === "file" && pane.file.mtime && <div className="files-status">{fmtDate(pane.file.mtime)}</div>}
</>
);
}