/** * @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. Deleting forever asks first when it empties the Trash or touches * another user's. An admin's Trash opens on one folder per user (with its * item count); each folder is THAT user's Trash, to restore from or purge * without waiting the 7 days. * The sidebar nests each workspace under its owner's home (a chevron drops * them down, remembered per browser); one with no owner home stays in a flat * Workspaces list. * Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/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, type GitSource, isHomePlace, } from "../../lib/api"; import type { Lane } from "../../lib/types"; import { useDialog } from "../../hooks/useDialog"; import { toast } from "../../lib/toast"; import { confirmDialog } from "../../lib/confirm"; import { formatBytes } from "../../lib/format"; import { copyText } from "../../lib/clipboard"; import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu"; import { CloneHereDialog } from "./CloneHereDialog"; import { isSubmitEnter } from "../../lib/keys"; import { useAuth } from "../AuthGate"; /** One upload batch at most (the server caps one file the same). */ export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024; /** Files up to this size travel together, many per request (`uploadBatch`): * their cost is per FILE (a request, a path walk, a sudo), not per byte. */ const SMALL_FILE = 4 * 1024 * 1024; const PACK_BYTES = 32 * 1024 * 1024; const PACK_FILES = 1000; /** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */ const PACK_NAME_BYTES = 400 * 1024; /** Small files in packs (each ≥ 2 files) and the rest, one by one. */ export function packUploads(files: T[]) { const packs: T[][] = []; const singles: T[] = []; let cur: T[] = []; let bytes = 0; let names = 0; for (const f of files) { if (f.file.size > SMALL_FILE) { singles.push(f); continue; } const n = f.rel.length * 3 + 64; if ( cur.length && (cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES) ) { packs.push(cur); cur = []; bytes = 0; names = 0; } cur.push(f); bytes += f.file.size; names += n; } if (cur.length) packs.push(cur); for (const p of packs.filter((p) => p.length < 2)) singles.push(...p); return { packs: packs.filter((p) => p.length >= 2), singles }; } const MIME = "application/x-agenthub-fx"; const VIEW_KEY = "agenthub.files.view"; const HIDDEN_KEY = "agenthub.files.hidden"; /** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */ const HOMES_OPEN_KEY = "agenthub.files.homesOpen"; /** A sidebar user row; `noHome`: it only groups an owner's workspaces, the * viewer may not open that home (an analyst's view of other users). */ type UserRow = FsHome & { noHome?: boolean }; 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; /** A menu item's handler that closes its menu first. */ const pick = (m: { close: () => void }, fn: () => void) => () => { m.close(); fn(); }; /** 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?: GitSource | 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([]); // An admin's Trash opens on its root: one folder per user's Trash, counted // from `GET /api/homes/trash` (a Trash that could not be read: its error). const [trashRoot, setTrashRoot] = useState(false); const [trashErrors, setTrashErrors] = 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 [cloning, setCloning] = useState(false); 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 { state: auth } = useAuth(); const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null; const [openHomes, setOpenHomes] = useState>(() => { try { const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]')); return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []); } catch { return new Set(["me"]); } }); 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(); // The sidebar's places and the path bar's crumbs have their own menus. const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>(); const crumbCtx = 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; // Clone into the open folder: the absolute path of a workspace's own folder // or of the user's own home (never a session worktree, an extra folder or // another user's home); the server checks the folder again. const cloneRoot = isHomePlace(target) ? target.home === "me" ? (homes.find((h) => h.id === "me")?.dir ?? null) : null : typeof target === "number" ? (lanes.find((l) => l.id === target)?.cwd ?? null) : null; const cloneParent = cloneRoot && view === "files" ? path ? `${cloneRoot.replace(/\/+$/, "")}/${path}` : cloneRoot : null; /** 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" && trashRoot) { const out = await api.files.allTrash(); setTrash(out.items); // A user whose trash could not be read is said so on their folder. setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message]))); } else 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(""); if (view === "trash") setTrash([]); } finally { setLoading(false); } }, [target, path, view, trashRoot, 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 done = 0; let saved = 0; let forAll: FsConflict | null = null; // false once the server says a batch cannot be written here (an old helper). let batchable = true; const show = (name: string, extra = 0) => setProgress({ name, done, total: files.length, sent: sent + extra, bytes }); /** One file; false = the user cancelled. */ const one = async ({ file, rel }: { file: File; rel: string }) => { const dest = join(dir, rel); show(rel); const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n)); 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") return false; toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) })); } sent += file.size; done++; return true; }; /** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */ const pack = async (b: { file: File; rel: string }[]) => { const label = `${b[0]!.rel} (+${b.length - 1})`; const size = b.reduce((n, f) => n + f.file.size, 0); show(label); // Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it. const choose = async (taken: string[]) => { if (forAll) return forAll; if (!taken.length) return "fail" as const; const a = await askConflict(taken, files.length > 1); if (a.all && a.choice) forAll = a.choice; return a.choice; }; try { const c = await api.files.uploadCheck( target, dir, b.map((f) => f.rel) ); if (!c.batch) { batchable = false; return "fallback"; } let policy = await choose(c.taken); if (!policy) return "cancel"; const send = (p: FsConflict) => api.files.uploadBatch(target, dir, b, p, (n) => show(label, n)); const r = await send(policy).catch(async (e) => { // Taken meanwhile: ask about those, then once more. if (errCode(e) !== "EEXISTS") throw e; policy = await choose((e as { names?: string[] }).names ?? []); if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" }); return send(policy); }); saved += r.saved; } catch (e) { const code = errCode(e); if (code === "ECANCEL") return "cancel"; // Refused before a byte was written (a bad name, an old helper…): // each file then gets its own answer. if (code === "EUPGRADE") batchable = false; if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) { return "fallback"; } toast.error(t("fx.uploadFailed", { name: label, message: errText(e) })); } sent += size; done += b.length; return "ok"; }; try { const { packs, singles } = packUploads(files); let go = true; for (const b of packs) { const r = batchable ? await pack(b) : "fallback"; if (r === "cancel") go = false; if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false; if (!go) break; } for (const f of singles) if (!go || !(await one(f))) break; } 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 || cloning) 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); /** Delete forever — asked first when it is all of it or someone else's. */ const purge = async (id: string) => { if (id === "all" || ro) { const item = trash.find((i) => i.id === id); const ok = await confirmDialog({ title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"), message: id === "all" ? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel }) : t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }), confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"), }); if (!ok) return; } const item = trash.find((i) => i.id === id); await run(() => api.files.purge(target, id, item?.store), t("fx.purged")); }; /** Empty one user's whole Trash from the Trash root — asked first. */ const emptyTrashOf = async (home: string, count: number) => { const ok = await confirmDialog({ title: t("fx.emptyTrashTitle"), message: t("fx.emptyTrashBody", { count, where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }), }), confirmLabel: t("fx.emptyTrash"), }); if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged")); }; const restoreItem = (id: string) => { const item = trash.find((i) => i.id === id); void run(() => api.files.restore(target, id, item?.store), t("fx.restored")); }; /** * Open a Trash. Every user has ONE recycle bin — their home's, over every * place they deleted in — so `home` opens that user's ("me": the caller's); * null (no home to open) falls back to the trash of the place shown, and * "*" opens the root an admin starts on: one folder per user's Trash. */ const openTrash = (home: string | null) => { if (home === "*") { setTrashRoot(true); setView("trash"); setSelected(new Set()); return; } setTrashRoot(false); if (home && !(isHomePlace(target) && target.home === home)) { setTarget({ home }); setPathState(""); setHistory({ back: [], fwd: [] }); onNavigate?.({ home }, ""); } setView("trash"); setSelected(new Set()); }; // Someone else's Trash can be opened: the Trash starts on its folders. const manyTrashes = homes.some((h) => h.id !== "me"); const openTrashes = () => openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null); 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}`; }; // A home's Trash is its USER's recycle bin, named after them: another // user's shows THAT user's (an admin may restore from or purge it; the rest // of the home stays read-only). A workspace's (no home to open) is the // trash of whoever owns its folder. const trashLabel = trashRoot ? t("fx.trash") : !isHomePlace(target) ? t("fx.trashAt", { name: laneName(laneOf(target)!) }) : target.home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(target.home) }); 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; // Each workspace drops down under its owner: the caller's own under My // home, another user's under theirs (`owner_name` = that home's id). An // owner whose home this viewer may not open (an analyst's view of other // users) still gets a row, one that only groups. A workspace with no owner // stays in the flat Workspaces list. Which rows are open is per browser. const { lanesOfHome, userRows } = useMemo(() => { const ids = new Set(homes.map((h) => h.id)); const map = new Map(); const extra = new Map(); for (const l of lanes) { const home = myId != null && l.owner_id === myId && ids.has("me") ? "me" : l.owner_name && l.owner_name !== "me" ? l.owner_name : null; if (!home) continue; map.set(home, [...(map.get(home) ?? []), l]); if (!ids.has(home) && !extra.has(home)) { extra.set(home, { id: home, label: l.owner_display_name || home, readOnly: true, noHome: true, }); } } const rows: UserRow[] = [ ...homes.filter((h) => h.id !== "me"), ...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)), ]; return { lanesOfHome: map, userRows: rows }; }, [homes, lanes, myId]); const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id)); const looseLanes = lanes.filter((l) => !grouped.has(l.id)); // A home holding the open workspace shows it, whatever was stored. const homeIsOpen = (id: string, own: Lane[]) => openHomes.has(id) || (view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target))); const toggleHome = (id: string, own: Lane[]) => { const next = new Set(openHomes); if (homeIsOpen(id, own)) next.delete(id); else next.add(id); setOpenHomes(next); writePref(HOMES_OPEN_KEY, JSON.stringify([...next])); }; 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" > {cloneParent && ( setCloning(false)} onReopen={() => setCloning(true)} onCloned={(lane, at) => { setCloning(false); toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd })); setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane])); changed(); }} /> )} {/* ── Places ── */}
{/* ── Header bar ── */}
openTrash("*") : goUp} > {/* 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")} {cloneParent && ( } onClick={() => { setMenu(null); setCloning(true); }} > {t("fx.cloneRepo")} )} } 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), })}

)}
) : trashRoot ? ( void emptyTrashOf(home, count)} onRefresh={() => void load()} /> ) : ( void purge(id)} onRefresh={() => void load()} /> )} {/* ── 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")} {cloneParent && ( setCloning(true))}> {t("fx.cloneRepo")} )} 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")} )} )} {placeCtx.menu && ( {"lane" in placeCtx.menu.item ? (() => { const l = placeCtx.menu.item.lane; return ( <> go("", l.id))}> {t("fx.open")} void copyText(l.cwd))}> {t("fx.copyPath")} ); })() : (() => { const home = (placeCtx.menu.item as { home: string }).home; return ( <> go("", { home }))}> {t("fx.open")} openTrash(home))}> {t("fx.openTrash")} ); })()} )} {crumbCtx.menu && ( {(() => { const dir = crumbCtx.menu.item; const name = dir.split("/").pop() || placeName(target); return ( <> go(dir))}>{t("fx.open")} download([{ name, path: dir, type: "dir", size: null, mtime: 0 }]) )} > {t("fx.downloadZip")} {!ro && clip && ( void paste(dir))}> {t("fx.pasteInto")} )} void copyText(dir || "/"))}> {t("fx.copyPath")} ); })()} )} {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?: GitSource | 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 (isSubmitEnter(e)) 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" : "" }`} /> ); } /** * An admin's Trash root: one folder per user's Trash (theirs first), each * with its item count — or why it could not be read. Opening one shows that * user's Trash, where its items are restored or deleted forever. */ function TrashFolders({ homes, items, errors, error, loading, homeName, onOpen, onEmpty, onRefresh, }: { homes: FsHome[]; /** Every Trash's items, merged, each tagged with its `home`. */ items: FsTrashItem[]; /** Per home id: why its Trash could not be read. */ errors: Record; error: string | null; loading: boolean; homeName: (id: string) => string; onOpen: (home: string) => void; onEmpty: (home: string, count: number) => void; onRefresh: () => void; }) { const { t } = useTranslation(["lanes"]); const menu = useContextMenu(); const counts = new Map(); for (const i of items) if (i.home) counts.set(i.home, (counts.get(i.home) ?? 0) + 1); const ordered = [...homes.filter((h) => h.id === "me"), ...homes.filter((h) => h.id !== "me")]; return (
menu.open(e, null)} >
{t("fx.trashFoldersHint")}
{error && (
{error}
)}
{ordered.map((h) => { const n = counts.get(h.id) ?? 0; const failed = errors[h.id]; const name = h.id === "me" ? t("fx.myTrash") : homeName(h.id); return ( ); })}
{menu.menu && ( {menu.menu.item !== null ? ( (() => { const home = menu.menu.item; const n = counts.get(home) ?? 0; return ( <> onOpen(home))}>{t("fx.open")} onEmpty(home, n))}> {t("fx.emptyTrash")} ); })() ) : ( {t("fx.refresh")} )} )}
); } function TrashView({ items, error, laneName, onRestore, onPurge, onRefresh, }: { items: FsTrashItem[]; error: string | null; onRefresh: () => void; laneName: (id: number) => string; onRestore: (id: string) => void; onPurge: (id: string) => void; }) { const { t } = useTranslation(["lanes"]); const menu = useContextMenu(); return (
menu.open(e, null)} > {error && (
{error}
)}
{t("fx.trashHint")}
{items.map((i) => ( menu.open(e, 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 && !error && (

{t("fx.trashEmpty")}

)}
{menu.menu && ( {menu.menu.item ? ( (() => { const i = menu.menu.item; return ( <> onRestore(i.id))}> {t("fx.restore")} void copyText(i.path))}> {t("fx.copyPath")} onPurge(i.id))}> {t("fx.purge")} ); })() ) : ( <> {t("fx.refresh")} onPurge("all"))}> {t("fx.emptyTrash")} )} )}
); }