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"; import { navigate } from "../router"; 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 ; if (IMAGE.test(e.name)) return ; if (/\.(wav|mp3|ogg|flac|m4a|xm|mod|s3m|it)$/i.test(e.name)) return ; if (/\.(glb|gltf|stl|obj|step|stp|igs|iges)$/i.test(e.name)) return ; if (/\.(pdf|docx|pptx|xlsx|xls|ods)$/i.test(e.name)) return ; if (/\.(md|txt)$/i.test(e.name)) return ; return ; } /** Embedded in a chat tab: own start folder, no page header, folder kept by the caller. */ export interface FilesEmbed { startDir: string; onDirChange?: (dir: string) => void; } export function Files({ onMenu, embed }: { onMenu?: () => void; embed?: FilesEmbed }) { const t = useT(); const roots = useFetch<{ path: string; name: string }[]>("/fs/roots").data ?? []; const [dir, setDir] = useState(() => embed?.startDir ?? localStorage.getItem("codecraft.filesDir") ?? ""); const [listing, setListing] = useState(); const [hidden, setHidden] = useState(false); const [error, setError] = useState(); const [pane, setPane] = useState({ kind: "none" }); const [tab, setTab] = useState<"files" | "git">("files"); const [git, setGit] = useState(); 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(null); const load = useCallback( async (path: string) => { try { const l = await get(`/files/list?path=${encodeURIComponent(path)}&hidden=${hidden}`); setListing(l); setDir(l.path); if (embed) embed.onDirChange?.(l.path); else 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("/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(); 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); } }; // "Edit file" links from other pages (e.g. automations) open the file directly. useEffect(() => { const target = embed ? null : sessionStorage.getItem("codecraft.openFile"); if (!target) return; sessionStorage.removeItem("codecraft.openFile"); setDir(target.slice(0, target.lastIndexOf("/")) || "/"); void openFile(target); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); 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) => { 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); }); }; /** New chat with the folder as working directory (default model and mode). */ const chatHere = async (e: Entry) => { try { const s = await post<{ id: string }>("/sessions", { cwd: e.path }); navigate(`/s/${s.id}`); } catch (err) { toast(err); } }; 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 = (
(e.preventDefault(), setDragOver(true))} onDragLeave={() => setDragOver(false)} onDrop={(e) => { e.preventDefault(); setDragOver(false); void upload(e.dataTransfer.files); }} >
{roots.length > 1 && ( )} setSearch(e.target.value)} /> e.target.files && upload(e.target.files)} />
{crumbs.map((c, i) => { const path = i === 0 ? c : `${crumbs[0]}/${crumbs.slice(1, i + 1).join("/")}`; return ( {i > 0 && " / "} (e.preventDefault(), load(path))}> {i === 0 ? root?.name : c} ); })}
{error &&
{error}
}
{hits ? ( hits.length === 0 ? (
{t("files.noHits")}
) : ( hits.map((h) => ( )) ) ) : ( <> {listing?.parent && ( )} {listing?.entries.map((e) => { const mark = gitMarks.get(e.path); const active = pane.kind === "file" && pane.file.path === e.path; return (
{e.is_dir && ( )} {!e.is_dir && ( {t("files.download")} )}
); })} {listing?.entries.length === 0 &&
{t("files.emptyFolder")}
} )}
{t("files.dropHint")}
); const right = (() => { if (pane.kind === "diff") { return (
{pane.title}
{pane.diff ? :
{t("git.noDiff")}
}
); } if (pane.kind === "none") return
{t("files.selectFile")}
; const f = pane.file; const isImage = IMAGE.test(f.path); const isMd = /\.md$/i.test(f.path); return (
{baseName(f.path)} {dirty && } {(isMd || isImage) && !f.preview && ( )} {!f.binary && ( )}
{f.preview ? ( ) : isImage && preview ? (
{baseName(f.path)}
) : f.binary ? (
{f.tooLarge ? t("files.tooLarge") : t("files.binary")}
) : isMd && preview ? (
) : (
}> setPane((p) => (p.kind === "file" ? { kind: "file", file: { ...p.file, content: v } } : p))} onSave={() => void save()} /> )} ); })(); return ( <>
{!embed && ( )}
{!embed &&

{t("nav.files")}

}
{listing?.path}
{listing?.repo && (
)}
{tab === "git" && listing?.repo ? ( (setPane({ kind: "diff", title, diff }), setMobileEditor(true))} openFile={openFile} /> ) : ( list )} {!listing?.repo && listing && (
)}
{right}
{pane.kind === "file" && pane.file.mtime &&
{fmtDate(pane.file.mtime)}
} ); }