/** * @file FileExplorer.tsx * @description The workspace file manager, laid out like the Linux desktop's * (GNOME Files): a sidebar of places (every workspace the user can see, and * the Trash), a header bar (Back / Forward / Up, a clickable path bar, * search, grid ↔ list view, labelled **New** and **Upload** menus), and an * icon grid (image thumbnails) or a detail list. Hidden (dot) files are * hidden until Ctrl+H, as there. Selecting items brings up a labelled action * bar (Download, Copy, Cut, Rename, Move to Trash); everything is also on the * right-click menu and the usual keys (Enter, Backspace / Alt+↑, Alt+← / →, * F2, Delete, Ctrl+A / C / X / V / H / F). * * `FileBrowser` is the manager itself — the `/files` page (`FilesPage`) * renders it full size, `FileExplorer` in a large dialog opened from a * workspace's Files tab. Behaviour: delete → the account's Trash (no confirm: * restorable 7 days); Copy / Cut then Paste in the same or ANOTHER workspace * (the clipboard outlives the dialog); upload files or whole folders (picker * or dropped from the desktop), streamed with progress, ≤ 500 MB a batch; * drag items onto a folder to move them (Ctrl: copy); download a file as * itself, anything else as one zip; a taken name asks Replace / Keep both / * Skip. Server side: `/api/lanes/:id/fs/*` (server/lib/file-explorer.js). * @author Nguyễn Ngọc Trí Vĩ */ import { useCallback, useEffect, useId, useMemo, useRef, useState } from "react"; import type { DragEvent as ReactDragEvent, KeyboardEvent as ReactKeyboardEvent, MouseEvent as ReactMouseEvent, ReactNode, } from "react"; import { createPortal } from "react-dom"; import { useTranslation } from "react-i18next"; import { ArrowLeft, ArrowRight, ArrowUp, ChevronDown, ChevronRight, ClipboardPaste, Copy, Download, EllipsisVertical, Eye, File as FileIcon, FileArchive, FileAudio, FileCode, FileImage, FileJson, FileSpreadsheet, FileText, FileVideo, Folder, FolderGit2, FolderSymlink, House, LayoutGrid, Link2, List, Pencil, Plus, RotateCcw, Scissors, Search, Trash2, Upload, UserRound, X, } from "lucide-react"; import { api, gitTarget, type FsConflict, type FsEntry, type FsListing, type FsTrashItem, type FsHome, type FsPlace, isHomePlace, } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; import { toast } from "../../lib/toast"; import { formatBytes } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; /** One upload batch at most (the server caps one file the same). */ export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; const MIME = "application/x-agenthub-fx"; const VIEW_KEY = "agenthub.files.view"; const HIDDEN_KEY = "agenthub.files.hidden"; type Clip = { place: FsPlace; paths: string[]; cut: boolean }; // The clipboard outlives the dialog and a workspace switch, like a desktop's. let clipboard: Clip | null = null; /** The workspace id of a place, or null for a home. */ const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane); /** A workspace's own folder (not a session worktree or an extra folder), or a home. */ const wholePlace = (t: FsPlace) => isHomePlace(t) || typeof t === "number"; const samePlace = (a: FsPlace, b: FsPlace) => JSON.stringify(a) === JSON.stringify(b); const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name); const errText = (e: unknown) => (e instanceof Error ? e.message : String(e)); const errCode = (e: unknown) => (e as { code?: string })?.code; const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir"; const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase(); const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]); const CODE = new Set([ "js", "jsx", "ts", "tsx", "mjs", "cjs", "py", "go", "rs", "java", "kt", "c", "h", "cpp", "cs", "rb", "php", "sh", "css", "scss", "html", "vue", "svelte", "sql", "yml", "yaml", "toml", "xml", ]); function readPref(key: string, fallback: string) { try { return localStorage.getItem(key) ?? fallback; } catch { return fallback; } } function writePref(key: string, value: string) { try { localStorage.setItem(key, value); } catch { /* a per-browser convenience only */ } } /** Every file under a dropped folder entry, with its path inside the drop. */ async function walkEntry( entry: FileSystemEntry, prefix: string, out: { file: File; rel: string }[] ) { if (entry.isFile) { const file = await new Promise((ok, no) => (entry as FileSystemFileEntry).file(ok, no)); out.push({ file, rel: `${prefix}${entry.name}` }); return; } const reader = (entry as FileSystemDirectoryEntry).createReader(); // readEntries answers in pages until it answers []. for (;;) { const page = await new Promise((ok, no) => reader.readEntries(ok, no)); if (!page.length) break; for (const child of page) await walkEntry(child, `${prefix}${entry.name}/`, out); } } /** The icon a desktop would show for this entry. */ function EntryIcon({ entry, size }: { entry: FsEntry; size: "lg" | "sm" }) { const cls = size === "lg" ? "h-12 w-12" : "h-4 w-4"; if (entry.type === "dir") return ; if (entry.type === "linkdir") return ; if (entry.type === "link") return ; const e = ext(entry.name); const Icon = IMAGE.has(e) ? FileImage : ["zip", "gz", "tgz", "tar", "rar", "7z", "bz2", "xz"].includes(e) ? FileArchive : ["json", "jsonc"].includes(e) ? FileJson : ["csv", "xls", "xlsx", "ods", "tsv"].includes(e) ? FileSpreadsheet : ["mp4", "mov", "webm", "mkv", "avi"].includes(e) ? FileVideo : ["mp3", "wav", "ogg", "flac", "m4a"].includes(e) ? FileAudio : ["md", "txt", "rst", "log", "pdf", "doc", "docx"].includes(e) ? FileText : CODE.has(e) ? FileCode : FileIcon; return ; } type Ask = { names: string[]; many: boolean; resolve: (r: { choice: FsConflict | null; all: boolean }) => void; }; /** The file manager; fills its parent. `onClose` adds a close button (the dialog). */ export function FileBrowser({ laneId, initialHome, session, source, initialPath = "", onChanged, onClose, onNavigate, }: { /** A workspace to open on; else `initialHome`. */ laneId?: number | null; /** A home to open on (`me`, or another user's for an admin). */ initialHome?: string | null; session?: string | null; source?: number | null; initialPath?: string; /** Called after anything changed files, so a tree elsewhere can re-read. */ onChanged?: () => void; onClose?: () => void; /** The page keeps `?lane=` / `?home=` and `&path=` in the URL. */ onNavigate?: (place: FsPlace, path: string) => void; }) { const { t } = useTranslation(["lanes"]); const placeOf = (): FsPlace => laneId != null ? gitTarget(laneId, session, source) : { home: initialHome || "me" }; const [target, setTarget] = useState(placeOf); const [homes, setHomes] = useState([]); const [path, setPathState] = useState(initialPath); const [history, setHistory] = useState<{ back: string[]; fwd: string[] }>({ back: [], fwd: [] }); const [listing, setListing] = useState(null); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [selected, setSelected] = useState>(new Set()); const anchor = useRef(null); const [view, setView] = useState<"files" | "trash">("files"); const [layout, setLayout] = useState<"grid" | "list">(() => readPref(VIEW_KEY, "grid") === "list" ? "list" : "grid" ); const [showHidden, setShowHidden] = useState(() => readPref(HIDDEN_KEY, "0") === "1"); const [query, setQuery] = useState(null); const [trash, setTrash] = useState([]); const [busy, setBusy] = useState(false); const [renaming, setRenaming] = useState<{ path: string; value: string } | null>(null); const [creating, setCreating] = useState<{ kind: "dir" | "file"; value: string } | null>(null); const [ask, setAsk] = useState(null); const [askAll, setAskAll] = useState(false); const [progress, setProgress] = useState<{ name: string; done: number; total: number; sent: number; bytes: number; } | null>(null); const [lanes, setLanes] = useState([]); const [clip, setClip] = useState(clipboard); const [dropping, setDropping] = useState(false); const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null); const fileInput = useRef(null); const folderInput = useRef(null); const ctx = useContextMenu(); // A new caller target (another workspace's Files tab, the page's URL) — // not the echo of our own `go()` through the page's URL, which would wipe // Back's history on every folder opened. useEffect(() => { const next = placeOf(); if (samePlace(next, target) && initialPath === path) return; setTarget(next); setPathState(initialPath); setHistory({ back: [], fwd: [] }); setSelected(new Set()); setView("files"); // eslint-disable-next-line react-hooks/exhaustive-deps }, [laneId, initialHome, session, source, initialPath]); useEffect(() => { api.lanes .list() .then((r) => setLanes(r.lanes)) .catch(() => setLanes([])); api.files .homes() .then((r) => setHomes(r.homes)) .catch(() => setHomes([])); }, []); // Another user's home (an admin's view) is read-only. const ro = isHomePlace(target) && !!homes.find((h) => h.id === target.home)?.readOnly; /** Go to `p` in workspace `tgt`, remembering where we were for Back. */ const go = (p: string, tgt: FsPlace = target) => { const sameLane = samePlace(tgt, target); if (sameLane && p === path && view === "files") return; setHistory((h) => ({ back: sameLane ? [...h.back, path] : [], fwd: [] })); if (!sameLane) setTarget(tgt); setPathState(p); setView("files"); setSelected(new Set()); setQuery(null); onNavigate?.(tgt, p); }; const back = () => { const prev = history.back[history.back.length - 1]; if (prev === undefined) return; setHistory((h) => ({ back: h.back.slice(0, -1), fwd: [path, ...h.fwd] })); setPathState(prev); setSelected(new Set()); onNavigate?.(target, prev); }; const forward = () => { const next = history.fwd[0]; if (next === undefined) return; setHistory((h) => ({ back: [...h.back, path], fwd: h.fwd.slice(1) })); setPathState(next); setSelected(new Set()); onNavigate?.(target, next); }; const load = useCallback(async () => { setError(null); setLoading(true); try { if (view === "trash") setTrash((await api.files.trash(target)).items); else setListing(await api.files.list(target, path)); } catch (e) { // A dashboard that does not know /fs yet (not restarted) answers 404. setError( (e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e) ); if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState(""); } finally { setLoading(false); } }, [target, path, view, t]); useEffect(() => { void load(); }, [load]); const changed = () => { void load(); onChanged?.(); }; const entries = useMemo(() => { const all = listing?.entries ?? []; const q = query?.trim().toLowerCase(); return all.filter( (e) => (showHidden || !e.name.startsWith(".")) && (!q || e.name.toLowerCase().includes(q)) ); }, [listing, showHidden, query]); const selectedEntries = entries.filter((e) => selected.has(e.path)); /** A choice for taken names: Replace / Keep both / Skip (null = cancel). */ const askConflict = (names: string[], many: boolean) => new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => { setAskAll(false); setAsk({ names, many, resolve }); }); const answer = (choice: FsConflict | null) => { ask?.resolve({ choice, all: askAll }); setAsk(null); }; /** Run `op` once; on EEXISTS ask, then run it again with the choice. */ const withConflict = async (op: (c: FsConflict) => Promise): Promise => { try { return await op("fail"); } catch (e) { if (errCode(e) !== "EEXISTS") throw e; const { choice } = await askConflict((e as { names?: string[] }).names ?? [], false); if (!choice) return null; return op(choice); } }; const run = async (fn: () => Promise, done?: string) => { setBusy(true); try { await fn(); if (done) toast.success(done); } catch (e) { toast.error(errCode(e) === "EUPGRADE" ? t("fx.needsUpgrade") : errText(e)); } finally { setBusy(false); changed(); } }; // ── Actions ───────────────────────────────────────────────────────────── const openEntry = (e: FsEntry) => { if (isFolder(e)) go(e.path); else download([e]); }; const goUp = () => { if (listing?.parent != null) go(listing.parent); }; const download = (items: FsEntry[]) => { if (!items.length) return; const one = items.length === 1 && !isFolder(items[0]!) ? items[0]! : null; const a = document.createElement("a"); a.href = one ? api.files.downloadUrl(target, one.path) : api.files.zipUrl( target, items.map((i) => i.path) ); a.download = one ? one.name : ""; a.click(); }; const removeItems = async (items: FsEntry[]) => { if (!items.length) return; await run( () => api.files.remove( target, items.map((i) => i.path) ), t("fx.trashed", { count: items.length }) ); setSelected(new Set()); }; const startRename = (e: FsEntry) => setRenaming({ path: e.path, value: e.name }); const commitRename = async () => { if (!renaming) return; const entry = entries.find((e) => e.path === renaming.path); const name = renaming.value.trim(); setRenaming(null); if (!entry || !name || name === entry.name) return; await run(() => api.files.rename(target, entry.path, name)); }; const startCreate = (kind: "dir" | "file") => { setMenu(null); setCreating({ kind, value: kind === "dir" ? t("fx.untitledFolder") : t("fx.untitledFile") }); }; const commitCreate = async () => { if (!creating) return; const name = creating.value.trim(); const kind = creating.kind; setCreating(null); if (!name) return; await run(() => kind === "dir" ? api.files.mkdir(target, join(path, name)) : api.files.newFile(target, join(path, name)) ); }; const putClip = (items: FsEntry[], cut: boolean) => { if (!items.length) return; // From another user's home (read-only) a cut is a copy. clipboard = { place: target, paths: items.map((i) => i.path), cut: cut && !ro }; setClip(clipboard); toast.info(t(cut ? "fx.cutDone" : "fx.copiedDone", { count: items.length })); }; const paste = async (into = path) => { setMenu(null); const c = clipboard; if (!c || ro) return; const sameLane = samePlace(c.place, target); if (!sameLane && (!wholePlace(c.place) || (isHomePlace(c.place) && isHomePlace(target)))) { toast.error(t("fx.crossOnlyWorkspace")); return; } await run(async () => { if (sameLane && c.cut) { await withConflict((conflict) => api.files.move(target, c.paths, into, conflict)); } else { const done = await withConflict((conflict) => api.files.copy(target, c.paths, into, { ...(sameLane ? {} : isHomePlace(c.place) ? { fromHome: c.place.home } : { fromLane: laneOf(c.place)! }), conflict, }) ); // A cut into another workspace: copied, then the originals trashed. if (done && c.cut) await api.files.remove(c.place, c.paths); } if (c.cut) { clipboard = null; setClip(null); } }, t("fx.pasted")); }; const moveInto = async (paths: string[], dest: string, copy: boolean) => { const ok = paths.filter((p) => p !== dest && !dest.startsWith(`${p}/`)); if (!ok.length) return; await run(() => withConflict((conflict) => copy ? api.files.copy(target, ok, dest, { conflict }) : api.files.move(target, ok, dest, conflict) ) ); }; /** Upload `files` (each with its path inside the pick/drop) under `dir`. */ const uploadAll = async (files: { file: File; rel: string }[], dir = path) => { setMenu(null); if (!files.length) return; const bytes = files.reduce((n, f) => n + f.file.size, 0); if (bytes > FX_BATCH_MAX_BYTES) { toast.error( t("fx.tooBig", { size: formatBytes(bytes), max: formatBytes(FX_BATCH_MAX_BYTES) }) ); return; } setBusy(true); let sent = 0; let saved = 0; let forAll: FsConflict | null = null; try { for (let i = 0; i < files.length; i++) { const { file, rel } = files[i]!; const dest = join(dir, rel); setProgress({ name: rel, done: i, total: files.length, sent, bytes }); const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => setProgress({ name: rel, done: i, total: files.length, sent: sent + n, bytes }) ); try { const r = await send(forAll ?? "fail").catch(async (e) => { if (errCode(e) !== "EEXISTS") throw e; const a = await askConflict([rel], files.length > 1); if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" }); if (a.all) forAll = a.choice; return send(a.choice); }); if (!r.skipped) saved++; } catch (e) { if (errCode(e) === "ECANCEL") break; toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) })); } sent += file.size; } } finally { setProgress(null); setBusy(false); if (saved) toast.success(t("fx.uploaded", { count: saved })); changed(); } }; const onDrop = async (e: ReactDragEvent, dir = path) => { e.preventDefault(); e.stopPropagation(); setDropping(false); if (ro) { toast.error(t("fx.readOnlyHint")); return; } const inner = e.dataTransfer.getData(MIME); if (inner) { await moveInto(JSON.parse(inner) as string[], dir, e.ctrlKey || e.metaKey); return; } const out: { file: File; rel: string }[] = []; const dropped = [...e.dataTransfer.items] .map((i) => i.webkitGetAsEntry?.()) .filter(Boolean) as FileSystemEntry[]; if (dropped.length) { for (const en of dropped) await walkEntry(en, "", out); } else { for (const f of e.dataTransfer.files) out.push({ file: f, rel: f.name }); } await uploadAll(out, dir); }; // ── Selection & keys ──────────────────────────────────────────────────── const click = (e: ReactMouseEvent, entry: FsEntry) => { e.stopPropagation(); const next = new Set(selected); if (e.shiftKey && anchor.current) { const a = entries.findIndex((x) => x.path === anchor.current); const b = entries.findIndex((x) => x.path === entry.path); const [lo, hi] = a < b ? [a, b] : [b, a]; for (const x of entries.slice(lo, hi + 1)) next.add(x.path); } else if (e.ctrlKey || e.metaKey) { if (next.has(entry.path)) next.delete(entry.path); else next.add(entry.path); anchor.current = entry.path; } else { next.clear(); next.add(entry.path); anchor.current = entry.path; } setSelected(next); }; const toggleHidden = () => { setShowHidden((v) => { writePref(HIDDEN_KEY, v ? "0" : "1"); return !v; }); setMenu(null); }; const setLayoutPref = (l: "grid" | "list") => { setLayout(l); writePref(VIEW_KEY, l); }; const onKey = (e: ReactKeyboardEvent) => { if (renaming || creating || ask) return; const mod = e.ctrlKey || e.metaKey; const k = e.key.toLowerCase(); // Another user's home is read-only: no key that writes. if (ro && (e.key === "Delete" || e.key === "F2" || (mod && (k === "x" || k === "v")))) return; if (e.altKey && e.key === "ArrowLeft") back(); else if (e.altKey && e.key === "ArrowRight") forward(); else if (e.altKey && e.key === "ArrowUp") goUp(); else if (mod && k === "h") toggleHidden(); else if (view !== "files") return; else if (e.key === "Delete") void removeItems(selectedEntries); else if (e.key === "F2" && selectedEntries.length === 1) startRename(selectedEntries[0]!); else if (e.key === "Enter" && selectedEntries.length === 1) openEntry(selectedEntries[0]!); else if (e.key === "Backspace") goUp(); else if (mod && k === "a") setSelected(new Set(entries.map((x) => x.path))); else if (mod && k === "c") putClip(selectedEntries, false); else if (mod && k === "x") putClip(selectedEntries, true); else if (mod && k === "v") void paste(); else if (mod && k === "f") setQuery((q) => q ?? ""); else return; e.preventDefault(); e.stopPropagation(); }; const homeName = (who: string) => who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who); const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!)); const laneName = (id: number) => { const l = lanes.find((x) => x.id === id); return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`; }; const crumbs = path ? path.split("/") : []; const menuItem = ctx.menu?.item ?? null; const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : []; const close = (fn: () => void) => () => { ctx.close(); fn(); }; const itemProps = (e: FsEntry) => ({ draggable: !renaming, onDragStart: (ev: ReactDragEvent) => { const paths = selected.has(e.path) ? [...selected] : [e.path]; ev.dataTransfer.setData(MIME, JSON.stringify(paths)); // Into a terminal or the prompt: the path as text. ev.dataTransfer.setData("text/plain", e.path); ev.dataTransfer.effectAllowed = "copyMove"; }, onDragOver: isFolder(e) ? (ev: ReactDragEvent) => ev.preventDefault() : undefined, onDrop: isFolder(e) ? (ev: ReactDragEvent) => void onDrop(ev, e.path) : undefined, onClick: (ev: ReactMouseEvent) => click(ev, e), onDoubleClick: () => openEntry(e), onContextMenu: (ev: ReactMouseEvent) => { ev.preventDefault(); ev.stopPropagation(); if (!selected.has(e.path)) setSelected(new Set([e.path])); ctx.open(ev, e); }, }); const nameBox = (e: FsEntry, center: boolean) => renaming?.path === e.path ? ( setRenaming({ ...renaming, value: v })} onDone={() => void commitRename()} onCancel={() => setRenaming(null)} /> ) : null; const placeClass = (active: boolean) => `flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${ active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2" }`; return (
setMenu(null)} data-testid="file-explorer" > {/* ── Places ── */}
{/* ── Header bar ── */}
{/* Path bar — or the search field in its place, like GNOME Files. */} {query !== null ? (
setQuery(e.target.value)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Escape") setQuery(null); }} placeholder={t("fx.searchHere")} aria-label={t("fx.searchHere")} className="min-w-0 flex-1 bg-transparent text-sm outline-none" />
) : ( )} {view === "files" && ( <> setQuery((q) => (q === null ? "" : null))} >
setLayoutPref("grid")} > setLayoutPref("list")} >
{ro ? ( {t("fx.readOnly")} ) : ( <> } open={menu === "new"} onToggle={() => setMenu(menu === "new" ? null : "new")} disabled={busy} > } onClick={() => startCreate("dir")} > {t("fx.newFolder")} } onClick={() => startCreate("file")} > {t("fx.newFile")} } primary open={menu === "upload"} onToggle={() => setMenu(menu === "upload" ? null : "upload")} disabled={busy} > } onClick={() => fileInput.current?.click()} > {t("fx.uploadFiles")} } onClick={() => folderInput.current?.click()} > {t("fx.uploadFolder")} )} } open={menu === "more"} onToggle={() => setMenu(menu === "more" ? null : "more")} > void paste()} disabled={!clip || ro}> {clip ? t("fx.pasteFrom", { count: clip.paths.length, workspace: placeName(clip.place), }) : t("fx.paste")} { setMenu(null); setSelected(new Set(entries.map((x) => x.path))); }} > {t("fx.selectAll")} {showHidden ? "✓ " : ""} {t("fx.showHidden")} { setMenu(null); void load(); }} > {t("fx.refresh")} )} {onClose && ( )} { const files = [...(e.target.files ?? [])].map((f) => ({ file: f, rel: f.name })); e.target.value = ""; void uploadAll(files); }} /> )} onChange={(e) => { const files = [...(e.target.files ?? [])].map((f) => ({ file: f, rel: f.webkitRelativePath || f.name, })); e.target.value = ""; void uploadAll(files); }} />
{view === "files" ? (
{ e.preventDefault(); setSelected(new Set()); ctx.open(e, null); }} onDragOver={(e) => { e.preventDefault(); if (!e.dataTransfer.types.includes(MIME)) setDropping(true); }} onDragLeave={(e) => { if (e.currentTarget === e.target) setDropping(false); }} onDrop={(e) => void onDrop(e)} onClick={() => setSelected(new Set())} className="relative min-h-0 flex-1 overflow-y-auto outline-none" > {error && (
{error}
)} {layout === "grid" ? (
{creating && (
{creating.kind === "dir" ? ( ) : ( )} setCreating({ ...creating, value: v })} onDone={() => void commitCreate()} onCancel={() => setCreating(null)} />
)} {entries.map((e) => (
{e.type === "file" && !isHomePlace(target) && IMAGE.has(ext(e.name)) && (e.size ?? 0) < 5 * 1024 * 1024 ? ( , e.path )} alt="" loading="lazy" draggable={false} className="h-12 w-12 rounded object-cover" /> ) : ( )} {nameBox(e, true) ?? ( {e.name} )}
))}
) : ( {creating && ( )} {entries.map((e) => ( ))}
{t("fx.name")} {t("fx.size")} {t("fx.modified")}
setCreating({ ...creating, value: v })} onDone={() => void commitCreate()} onCancel={() => setCreating(null)} />
{nameBox(e, false) ?? ( {e.name} )} {e.size != null ? formatBytes(e.size) : isFolder(e) ? "—" : ""} {new Date(e.mtime).toLocaleString()}
)} {listing && !entries.length && !creating && !error && (

{query ? t("fx.noMatch") : isHomePlace(target) && !path ? t("fx.homeEmpty") : t("fx.empty")}

)} {loading && !listing &&

{t("fx.loading")}

} {dropping && (

{t("fx.dropHere", { folder: crumbs[crumbs.length - 1] ?? placeName(target), })}

)}
) : ( void run(() => api.files.restore(target, id), t("fx.restored"))} onPurge={(id) => void run(() => api.files.purge(target, id))} /> )} {/* ── Selection action bar / status bar ── */} {view === "files" && selected.size > 0 ? (
{t("fx.selectedCount", { count: selected.size })} } onClick={() => download(selectedEntries)} > {t("fx.download")} } onClick={() => putClip(selectedEntries, false)} > {t("fx.copy")} {!ro && ( } onClick={() => putClip(selectedEntries, true)} > {t("fx.cut")} )} {!ro && selectedEntries.length === 1 && ( } onClick={() => startRename(selectedEntries[0]!)} > {t("fx.rename")} )} {!ro && ( } disabled={busy} onClick={() => void removeItems(selectedEntries)} > {t("fx.delete")} )}
) : (
{view === "files" ? t("fx.itemsCount", { count: entries.length }) : t("fx.trashHint")} {clip && view === "files" && ( )} {t("fx.dropHint")}
)} {progress && (

{t("fx.uploading", { done: progress.done + 1, total: progress.total })}

{progress.name}

)}
{ctx.menu && ( {menuItem ? ( <> openEntry(menuItem))}> {isFolder(menuItem) ? t("fx.open") : t("fx.download")} {(isFolder(menuItem) || menuTargets.length > 1) && ( download(menuTargets))}> {t("fx.downloadZip")} )} {!ro && ( putClip(menuTargets, true))}> {t("fx.cut")} )} putClip(menuTargets, false))}> {t("fx.copy")} {!ro && isFolder(menuItem) && clip && ( void paste(menuItem.path))}> {t("fx.pasteInto")} )} {!ro && menuTargets.length === 1 && ( startRename(menuItem))}>{t("fx.rename")} )} void copyText(menuItem.path))}> {t("fx.copyPath")} {!ro && ( <> void removeItems(menuTargets))}> {t("fx.delete")} )} ) : ( <> {!ro && ( <> startCreate("dir"))}> {t("fx.newFolder")} startCreate("file"))}>{t("fx.newFile")} fileInput.current?.click())}> {t("fx.uploadFiles")} folderInput.current?.click())}> {t("fx.uploadFolder")} )} {!ro && clip && ( <> void paste())}>{t("fx.paste")} )} setSelected(new Set(entries.map((x) => x.path))))}> {t("fx.selectAll")} {showHidden ? "✓ " : ""} {t("fx.showHidden")} )} )} {ask && (

{t("fx.conflictTitle")}

{t("fx.conflictBody", { names: ask.names.slice(0, 5).join(", ") })}

{ask.many && ( )}
)}
); } /** * The file manager in a large dialog (a workspace's Files tab opens it). * Portalled to `document.body`: opened from inside the narrow Changes panel, * whose ancestors make their own stacking context (and may be a containing * block for `position: fixed`), it was boxed into that panel and drawn * clipped over the file tree. */ export function FileExplorer({ open, onClose, ...rest }: { open: boolean; onClose: () => void; laneId: number; session?: string | null; source?: number | null; initialPath?: string; onChanged?: () => void; }) { const { t } = useTranslation(["lanes"]); const panelRef = useRef(null); const titleId = useId(); useDialog(open, panelRef, onClose); if (!open) return null; return createPortal(
e.stopPropagation()} >

{t("fx.title")}

, document.body ); } function HeadButton({ label, onClick, disabled, pressed, children, }: { label: string; onClick: () => void; disabled?: boolean; pressed?: boolean; children: ReactNode; }) { return ( ); } function DropButton({ label, icon, open, onToggle, disabled, primary, iconOnly, children, }: { label: string; icon: ReactNode; open: boolean; onToggle: () => void; disabled?: boolean; primary?: boolean; iconOnly?: boolean; children: ReactNode; }) { return (
e.stopPropagation()}> {open && (
{children}
)}
); } function DropItem({ icon, onClick, disabled, children, }: { icon?: ReactNode; onClick: () => void; disabled?: boolean; children: ReactNode; }) { return ( ); } function ActionButton({ icon, onClick, disabled, danger, children, }: { icon: ReactNode; onClick: () => void; disabled?: boolean; danger?: boolean; children: ReactNode; }) { return ( ); } function InlineName({ value, center, onChange, onDone, onCancel, }: { value: string; center?: boolean; onChange: (v: string) => void; onDone: () => void; onCancel: () => void; }) { const { t } = useTranslation(["lanes"]); const ref = useRef(null); useEffect(() => { const el = ref.current; if (!el) return; el.focus(); // Select the stem, like a desktop rename: "name" of "name.ext". const dot = value.lastIndexOf("."); el.setSelectionRange(0, dot > 0 ? dot : value.length); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( onChange(e.target.value)} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") onDone(); if (e.key === "Escape") onCancel(); }} onBlur={onDone} className={`w-full rounded border border-accent bg-surface-0 px-1.5 py-0.5 text-xs outline-none ${ center ? "text-center" : "" }`} /> ); } function TrashView({ items, laneName, onRestore, onPurge, }: { items: FsTrashItem[]; laneName: (id: number) => string; onRestore: (id: string) => void; onPurge: (id: string) => void; }) { const { t } = useTranslation(["lanes"]); return (
{t("fx.trashHint")}
{items.map((i) => ( ))}
{t("fx.name")} {t("fx.from")} {t("fx.deletedAt")}
{i.isDir ? ( ) : ( )} {i.path} {i.laneId != null ? laneName(i.laneId) : ""} {new Date(i.deletedAt).toLocaleString()}
{!items.length && (

{t("fx.trashEmpty")}

)}
); }