Files
AgentHub/client/src/components/lanes/FileExplorer.tsx
T
nntrivi2001 b058179177 feat: right-click menus in Files/Users/Artifacts; several SSH keys and accounts per host
- Files: right-click a sidebar workspace (Open, Copy path) or home (Open,
  Open Trash), a path crumb (Open, Download as zip, Paste into, Copy path),
  a Trash row (Restore, Copy path, Delete forever), the Trash area (Refresh,
  Empty Trash) and an admin's per-user Trash folder (Open, Empty Trash).
- Users: row menu (Edit + Open home in Files for an admin, Copy username/email).
- Artifacts: card menu repeating its buttons, plus Copy link for a page.
- Git identity: named SSH keys beside the default ones (id_<type>_<name>,
  max 10, DELETE ?id=); SSH logins per alias (`ssh://<alias>/` →
  `ssh://<user>@<host>/`, so several AWS CodeCommit accounts share a host),
  each optionally tied to one key (`agenthub.<from>.sshKey`) — a clone of
  that URL offers only that key and persists it. Terminal sshCommand follows
  the keys. Settings → Git: "More SSH keys" + account name/key in the AWS and
  other-host forms. Docs/API.md, OpenAPI updated.
2026-10-15 16:42:47 +07:00

2113 lines
75 KiB
TypeScript

/**
* @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.
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
* (server/lib/file-explorer.js).
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
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 { confirmDialog } from "../../lib/confirm";
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;
/** 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<File>((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<FileSystemEntry[]>((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 <Folder className={`${cls} shrink-0 fill-accent/25 text-accent`} />;
if (entry.type === "linkdir") return <FolderSymlink className={`${cls} shrink-0 text-accent`} />;
if (entry.type === "link") return <Link2 className={`${cls} shrink-0 text-fg-muted`} />;
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 <Icon className={`${cls} shrink-0 text-fg-muted`} />;
}
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<FsPlace>(placeOf);
const [homes, setHomes] = useState<FsHome[]>([]);
const [path, setPathState] = useState(initialPath);
const [history, setHistory] = useState<{ back: string[]; fwd: string[] }>({ back: [], fwd: [] });
const [listing, setListing] = useState<FsListing | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<string>>(new Set());
const anchor = useRef<string | null>(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<string | null>(null);
const [trash, setTrash] = useState<FsTrashItem[]>([]);
// 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<Record<string, string>>({});
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<Ask | null>(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<Lane[]>([]);
const [clip, setClip] = useState<Clip | null>(clipboard);
const [dropping, setDropping] = useState(false);
const [menu, setMenu] = useState<"new" | "upload" | "more" | null>(null);
const fileInput = useRef<HTMLInputElement>(null);
const folderInput = useRef<HTMLInputElement>(null);
const ctx = useContextMenu<FsEntry | null>();
// The sidebar's places and the path bar's crumbs have their own menus.
const placeCtx = useContextMenu<{ home: string } | { lane: Lane }>();
const crumbCtx = useContextMenu<string>();
// 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" && 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 <T,>(op: (c: FsConflict) => Promise<T>): Promise<T | null> => {
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<unknown>, 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<unknown>((conflict) => api.files.move(target, c.paths, into, conflict));
} else {
const done = await withConflict<unknown>((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<unknown>((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);
/** 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 ? (
<InlineName
center={center}
value={renaming.value}
onChange={(v) => 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 (
<div
className="relative flex h-full min-h-0 w-full overflow-hidden bg-surface-1 text-fg-primary"
onKeyDown={onKey}
onClick={() => setMenu(null)}
data-testid="file-explorer"
>
{/* ── Places ── */}
<aside className="hidden w-52 shrink-0 flex-col border-r border-border bg-surface-0 sm:flex">
<nav className="min-h-0 flex-1 overflow-y-auto px-2 pb-2" aria-label={t("fx.places")}>
{homes.some((h) => h.id === "me") && (
<div className="pt-3">
<button
type="button"
onClick={() => go("", { home: "me" })}
onContextMenu={(e) => placeCtx.open(e, { home: "me" })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === "me"
)}
>
<House className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.myHome")}</span>
</button>
</div>
)}
{homes.some((h) => h.id !== "me") && (
<>
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.userHomes")}
</p>
{homes
.filter((h) => h.id !== "me")
.map((h) => (
<button
key={h.id}
type="button"
title={t("fx.readOnlyHome")}
onClick={() => go("", { home: h.id })}
onContextMenu={(e) => placeCtx.open(e, { home: h.id })}
className={placeClass(
view === "files" && isHomePlace(target) && target.home === h.id
)}
>
<UserRound className="h-4 w-4 shrink-0" />
<span className="truncate">{h.label}</span>
<Eye className="ml-auto h-3 w-3 shrink-0 opacity-60" />
</button>
))}
</>
)}
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
{t("fx.places")}
</p>
{lanes.map((l) => (
<button
key={l.id}
type="button"
title={l.cwd}
onClick={() => go("", l.id)}
onContextMenu={(e) => placeCtx.open(e, { lane: l })}
className={placeClass(
view === "files" && !isHomePlace(target) && laneOf(target) === l.id
)}
>
<FolderGit2 className="h-4 w-4 shrink-0" />
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
</button>
))}
</nav>
<div className="border-t border-border p-2" data-testid="fx-trashes">
<button type="button" onClick={openTrashes} className={placeClass(view === "trash")}>
<Trash2 className="h-4 w-4 shrink-0" />
<span className="truncate">{t("fx.trash")}</span>
</button>
</div>
</aside>
<div className="flex min-w-0 flex-1 flex-col">
{/* ── Header bar ── */}
<header className="flex items-center gap-1.5 border-b border-border px-2 py-2">
<HeadButton label={t("fx.back")} disabled={!history.back.length} onClick={back}>
<ArrowLeft className="h-4 w-4" />
</HeadButton>
<HeadButton label={t("fx.forward")} disabled={!history.fwd.length} onClick={forward}>
<ArrowRight className="h-4 w-4" />
</HeadButton>
<HeadButton
label={t("fx.up")}
disabled={view === "trash" ? trashRoot || !manyTrashes : listing?.parent == null}
onClick={view === "trash" ? () => openTrash("*") : goUp}
>
<ArrowUp className="h-4 w-4" />
</HeadButton>
{/* Path bar — or the search field in its place, like GNOME Files. */}
{query !== null ? (
<div className="flex min-w-0 flex-1 items-center gap-1.5 rounded-md border border-accent bg-surface-0 px-2 py-1">
<Search className="h-3.5 w-3.5 text-fg-muted" />
<input
autoFocus
value={query}
onChange={(e) => 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"
/>
<button type="button" aria-label={t("fx.close")} onClick={() => setQuery(null)}>
<X className="h-3.5 w-3.5 text-fg-muted" />
</button>
</div>
) : (
<nav
aria-label={t("fx.location")}
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md bg-surface-2 px-1 py-0.5"
>
{view === "trash" && manyTrashes ? (
<>
<button
type="button"
onClick={() => openTrash("*")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
trashRoot ? "font-semibold" : "text-fg-secondary"
}`}
>
<Trash2 className="h-4 w-4" /> {t("fx.trash")}
</button>
{!trashRoot && (
<span className="flex shrink-0 items-center">
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
<span className="px-2 py-1 text-sm font-semibold">
{isHomePlace(target) ? homeName(target.home) : trashLabel}
</span>
</span>
)}
</>
) : view === "trash" ? (
<span className="flex items-center gap-1.5 px-2 py-1 text-sm font-medium">
<Trash2 className="h-4 w-4" /> {trashLabel}
</span>
) : (
<>
<button
type="button"
onClick={() => go("")}
onContextMenu={(e) => crumbCtx.open(e, "")}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, "")}
className={`flex shrink-0 items-center gap-1.5 rounded px-2 py-1 text-sm hover:bg-surface-3 ${
crumbs.length ? "text-fg-secondary" : "font-semibold"
}`}
>
{isHomePlace(target) ? (
<House className="h-4 w-4" />
) : (
<FolderGit2 className="h-4 w-4" />
)}
{placeName(target)}
</button>
{crumbs.map((c, i) => (
<span key={i} className="flex shrink-0 items-center">
<ChevronRight className="h-3.5 w-3.5 text-fg-muted" />
<button
type="button"
onClick={() => go(crumbs.slice(0, i + 1).join("/"))}
onContextMenu={(e) => crumbCtx.open(e, crumbs.slice(0, i + 1).join("/"))}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => void onDrop(e, crumbs.slice(0, i + 1).join("/"))}
className={`rounded px-2 py-1 text-sm hover:bg-surface-3 ${
i === crumbs.length - 1 ? "font-semibold" : "text-fg-secondary"
}`}
>
{c}
</button>
</span>
))}
</>
)}
</nav>
)}
{view === "files" && (
<>
<HeadButton
label={t("fx.search")}
onClick={() => setQuery((q) => (q === null ? "" : null))}
>
<Search className="h-4 w-4" />
</HeadButton>
<div
className="flex rounded-md border border-border"
role="group"
aria-label={t("fx.viewAs")}
>
<HeadButton
label={t("fx.gridView")}
pressed={layout === "grid"}
onClick={() => setLayoutPref("grid")}
>
<LayoutGrid className="h-4 w-4" />
</HeadButton>
<HeadButton
label={t("fx.listView")}
pressed={layout === "list"}
onClick={() => setLayoutPref("list")}
>
<List className="h-4 w-4" />
</HeadButton>
</div>
{ro ? (
<span
data-testid="fx-readonly"
className="flex items-center gap-1 rounded-md border border-border px-2 py-1 text-xs text-fg-secondary"
>
<Eye className="h-3.5 w-3.5" />
{t("fx.readOnly")}
</span>
) : (
<>
<DropButton
label={t("fx.new")}
icon={<Plus className="h-4 w-4" />}
open={menu === "new"}
onToggle={() => setMenu(menu === "new" ? null : "new")}
disabled={busy}
>
<DropItem
icon={<Folder className="h-4 w-4" />}
onClick={() => startCreate("dir")}
>
{t("fx.newFolder")}
</DropItem>
<DropItem
icon={<FileIcon className="h-4 w-4" />}
onClick={() => startCreate("file")}
>
{t("fx.newFile")}
</DropItem>
</DropButton>
<DropButton
label={t("fx.upload")}
icon={<Upload className="h-4 w-4" />}
primary
open={menu === "upload"}
onToggle={() => setMenu(menu === "upload" ? null : "upload")}
disabled={busy}
>
<DropItem
icon={<FileIcon className="h-4 w-4" />}
onClick={() => fileInput.current?.click()}
>
{t("fx.uploadFiles")}
</DropItem>
<DropItem
icon={<Folder className="h-4 w-4" />}
onClick={() => folderInput.current?.click()}
>
{t("fx.uploadFolder")}
</DropItem>
</DropButton>
</>
)}
<DropButton
label={t("fx.more")}
iconOnly
icon={<EllipsisVertical className="h-4 w-4" />}
open={menu === "more"}
onToggle={() => setMenu(menu === "more" ? null : "more")}
>
<DropItem onClick={() => void paste()} disabled={!clip || ro}>
{clip
? t("fx.pasteFrom", {
count: clip.paths.length,
workspace: placeName(clip.place),
})
: t("fx.paste")}
</DropItem>
<DropItem
onClick={() => {
setMenu(null);
setSelected(new Set(entries.map((x) => x.path)));
}}
>
{t("fx.selectAll")}
</DropItem>
<DropItem onClick={toggleHidden}>
{showHidden ? "✓ " : ""}
{t("fx.showHidden")}
</DropItem>
<DropItem
onClick={() => {
setMenu(null);
void load();
}}
>
{t("fx.refresh")}
</DropItem>
</DropButton>
</>
)}
{onClose && (
<HeadButton label={t("fx.close")} onClick={onClose}>
<X className="h-4 w-4" />
</HeadButton>
)}
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="fx-upload-files"
onChange={(e) => {
const files = [...(e.target.files ?? [])].map((f) => ({ file: f, rel: f.name }));
e.target.value = "";
void uploadAll(files);
}}
/>
<input
ref={folderInput}
type="file"
multiple
hidden
data-testid="fx-upload-folder"
// A folder picker: each file comes with its path inside it.
{...({ webkitdirectory: "", directory: "" } as Record<string, string>)}
onChange={(e) => {
const files = [...(e.target.files ?? [])].map((f) => ({
file: f,
rel: f.webkitRelativePath || f.name,
}));
e.target.value = "";
void uploadAll(files);
}}
/>
</header>
{view === "files" ? (
<div
tabIndex={0}
role="grid"
aria-label={t("fx.title")}
data-testid="fx-list"
onContextMenu={(e) => {
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 && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
{layout === "grid" ? (
<div className="grid grid-cols-[repeat(auto-fill,minmax(112px,1fr))] gap-1 p-3">
{creating && (
<div className="flex flex-col items-center gap-1 rounded-lg bg-accent/10 p-2">
{creating.kind === "dir" ? (
<Folder className="h-12 w-12 fill-accent/25 text-accent" />
) : (
<FileIcon className="h-12 w-12 text-fg-muted" />
)}
<InlineName
center
value={creating.value}
onChange={(v) => setCreating({ ...creating, value: v })}
onDone={() => void commitCreate()}
onCancel={() => setCreating(null)}
/>
</div>
)}
{entries.map((e) => (
<div
key={e.path}
role="gridcell"
aria-selected={selected.has(e.path)}
data-testid="fx-row"
title={e.path}
{...itemProps(e)}
className={`flex cursor-default select-none flex-col items-center gap-1 rounded-lg p-2 ${
selected.has(e.path)
? "bg-accent/20 ring-1 ring-accent/60"
: "hover:bg-surface-2"
}`}
>
{e.type === "file" &&
!isHomePlace(target) &&
IMAGE.has(ext(e.name)) &&
(e.size ?? 0) < 5 * 1024 * 1024 ? (
<img
src={api.lanes.gitRawUrl(
target as Exclude<FsPlace, { home: string }>,
e.path
)}
alt=""
loading="lazy"
draggable={false}
className="h-12 w-12 rounded object-cover"
/>
) : (
<EntryIcon entry={e} size="lg" />
)}
{nameBox(e, true) ?? (
<span
className={`line-clamp-2 w-full break-all text-center text-xs leading-snug ${
e.name.startsWith(".") ? "text-fg-muted" : ""
}`}
>
{e.name}
</span>
)}
</div>
))}
</div>
) : (
<table className="w-full table-fixed text-left text-[13px]">
<thead className="sticky top-0 z-10 bg-surface-1 text-[11px] text-fg-muted">
<tr className="border-b border-border">
<th className="px-4 py-1.5 font-medium">{t("fx.name")}</th>
<th className="w-28 px-2 py-1.5 text-right font-medium">{t("fx.size")}</th>
<th className="w-48 px-4 py-1.5 font-medium">{t("fx.modified")}</th>
</tr>
</thead>
<tbody>
{creating && (
<tr>
<td colSpan={3} className="px-4 py-1">
<InlineName
value={creating.value}
onChange={(v) => setCreating({ ...creating, value: v })}
onDone={() => void commitCreate()}
onCancel={() => setCreating(null)}
/>
</td>
</tr>
)}
{entries.map((e) => (
<tr
key={e.path}
aria-selected={selected.has(e.path)}
data-testid="fx-row"
{...itemProps(e)}
className={`cursor-default select-none ${
selected.has(e.path) ? "bg-accent/20" : "hover:bg-surface-2"
}`}
>
<td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2">
<EntryIcon entry={e} size="sm" />
{nameBox(e, false) ?? (
<span
className={`truncate ${e.name.startsWith(".") ? "text-fg-muted" : ""}`}
>
{e.name}
</span>
)}
</span>
</td>
<td className="px-2 py-1.5 text-right text-fg-secondary">
{e.size != null ? formatBytes(e.size) : isFolder(e) ? "—" : ""}
</td>
<td className="px-4 py-1.5 text-fg-secondary">
{new Date(e.mtime).toLocaleString()}
</td>
</tr>
))}
</tbody>
</table>
)}
{listing && !entries.length && !creating && !error && (
<div className="flex flex-col items-center justify-center gap-2 py-16 text-fg-muted">
<Folder className="h-16 w-16 opacity-40" />
<p className="max-w-md text-center text-sm">
{query
? t("fx.noMatch")
: isHomePlace(target) && !path
? t("fx.homeEmpty")
: t("fx.empty")}
</p>
</div>
)}
{loading && !listing && <p className="p-6 text-sm text-fg-muted">{t("fx.loading")}</p>}
{dropping && (
<div className="pointer-events-none absolute inset-2 flex items-center justify-center rounded-xl border-2 border-dashed border-accent bg-accent/10">
<p className="rounded-lg bg-surface-1 px-4 py-2 text-sm font-medium text-accent shadow">
{t("fx.dropHere", {
folder: crumbs[crumbs.length - 1] ?? placeName(target),
})}
</p>
</div>
)}
</div>
) : trashRoot ? (
<TrashFolders
homes={homes}
items={trash}
errors={trashErrors}
error={error}
loading={loading}
homeName={homeName}
onOpen={openTrash}
onEmpty={(home, count) => void emptyTrashOf(home, count)}
onRefresh={() => void load()}
/>
) : (
<TrashView
items={trash}
error={error}
laneName={laneName}
onRestore={restoreItem}
onPurge={(id) => void purge(id)}
onRefresh={() => void load()}
/>
)}
{/* ── Selection action bar / status bar ── */}
{view === "files" && selected.size > 0 ? (
<div
data-testid="fx-actionbar"
className="flex flex-wrap items-center gap-1.5 border-t border-border bg-surface-2 px-3 py-1.5"
>
<span className="mr-2 text-xs font-medium">
{t("fx.selectedCount", { count: selected.size })}
</span>
<ActionButton
icon={<Download className="h-3.5 w-3.5" />}
onClick={() => download(selectedEntries)}
>
{t("fx.download")}
</ActionButton>
<ActionButton
icon={<Copy className="h-3.5 w-3.5" />}
onClick={() => putClip(selectedEntries, false)}
>
{t("fx.copy")}
</ActionButton>
{!ro && (
<ActionButton
icon={<Scissors className="h-3.5 w-3.5" />}
onClick={() => putClip(selectedEntries, true)}
>
{t("fx.cut")}
</ActionButton>
)}
{!ro && selectedEntries.length === 1 && (
<ActionButton
icon={<Pencil className="h-3.5 w-3.5" />}
onClick={() => startRename(selectedEntries[0]!)}
>
{t("fx.rename")}
</ActionButton>
)}
{!ro && (
<ActionButton
danger
icon={<Trash2 className="h-3.5 w-3.5" />}
disabled={busy}
onClick={() => void removeItems(selectedEntries)}
>
{t("fx.delete")}
</ActionButton>
)}
<button
type="button"
className="ml-auto text-xs text-fg-muted hover:text-fg-primary"
onClick={() => setSelected(new Set())}
>
{t("fx.clearSelection")}
</button>
</div>
) : (
<div className="flex items-center gap-3 border-t border-border px-4 py-1 text-[11px] text-fg-muted">
<span>
{view === "files" ? t("fx.itemsCount", { count: entries.length }) : t("fx.trashHint")}
</span>
{clip && view === "files" && (
<button
type="button"
className="flex items-center gap-1 text-accent hover:underline"
onClick={() => void paste()}
>
<ClipboardPaste className="h-3 w-3" />
{t("fx.pasteFrom", { count: clip.paths.length, workspace: placeName(clip.place) })}
</button>
)}
<span className="ml-auto hidden md:inline">{t("fx.dropHint")}</span>
</div>
)}
{progress && (
<div
data-testid="fx-progress"
className="absolute bottom-12 right-4 w-72 rounded-lg border border-border bg-surface-1 p-3 shadow-xl"
>
<p className="truncate text-xs font-medium">
{t("fx.uploading", { done: progress.done + 1, total: progress.total })}
</p>
<p className="truncate text-[11px] text-fg-muted">{progress.name}</p>
<div className="mt-2 h-1.5 overflow-hidden rounded bg-surface-3">
<div
className="h-full bg-accent-solid transition-[width]"
style={{
width: `${Math.min(100, (progress.sent / Math.max(1, progress.bytes)) * 100)}%`,
}}
/>
</div>
</div>
)}
</div>
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="fx-menu">
{menuItem ? (
<>
<MenuItem onSelect={close(() => openEntry(menuItem))}>
{isFolder(menuItem) ? t("fx.open") : t("fx.download")}
</MenuItem>
{(isFolder(menuItem) || menuTargets.length > 1) && (
<MenuItem onSelect={close(() => download(menuTargets))}>
{t("fx.downloadZip")}
</MenuItem>
)}
<MenuSeparator />
{!ro && (
<MenuItem onSelect={close(() => putClip(menuTargets, true))}>
{t("fx.cut")}
</MenuItem>
)}
<MenuItem onSelect={close(() => putClip(menuTargets, false))}>
{t("fx.copy")}
</MenuItem>
{!ro && isFolder(menuItem) && clip && (
<MenuItem onSelect={close(() => void paste(menuItem.path))}>
{t("fx.pasteInto")}
</MenuItem>
)}
{!ro && menuTargets.length === 1 && (
<MenuItem onSelect={close(() => startRename(menuItem))}>{t("fx.rename")}</MenuItem>
)}
<MenuItem onSelect={close(() => void copyText(menuItem.path))}>
{t("fx.copyPath")}
</MenuItem>
{!ro && (
<>
<MenuSeparator />
<MenuItem danger onSelect={close(() => void removeItems(menuTargets))}>
{t("fx.delete")}
</MenuItem>
</>
)}
</>
) : (
<>
{!ro && (
<>
<MenuItem onSelect={close(() => startCreate("dir"))}>
{t("fx.newFolder")}
</MenuItem>
<MenuItem onSelect={close(() => startCreate("file"))}>{t("fx.newFile")}</MenuItem>
<MenuSeparator />
<MenuItem onSelect={close(() => fileInput.current?.click())}>
{t("fx.uploadFiles")}
</MenuItem>
<MenuItem onSelect={close(() => folderInput.current?.click())}>
{t("fx.uploadFolder")}
</MenuItem>
</>
)}
{!ro && clip && (
<>
<MenuSeparator />
<MenuItem onSelect={close(() => void paste())}>{t("fx.paste")}</MenuItem>
</>
)}
<MenuSeparator />
<MenuItem onSelect={close(() => setSelected(new Set(entries.map((x) => x.path))))}>
{t("fx.selectAll")}
</MenuItem>
<MenuItem onSelect={close(toggleHidden)}>
{showHidden ? "✓ " : ""}
{t("fx.showHidden")}
</MenuItem>
</>
)}
</ContextMenu>
)}
{placeCtx.menu && (
<ContextMenu
x={placeCtx.menu.x}
y={placeCtx.menu.y}
onClose={placeCtx.close}
testId="fx-place-menu"
>
{"lane" in placeCtx.menu.item
? (() => {
const l = placeCtx.menu.item.lane;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", l.id))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => void copyText(l.cwd))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()
: (() => {
const home = (placeCtx.menu.item as { home: string }).home;
return (
<>
<MenuItem onSelect={pick(placeCtx, () => go("", { home }))}>
{t("fx.open")}
</MenuItem>
<MenuItem onSelect={pick(placeCtx, () => openTrash(home))}>
{t("fx.openTrash")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{crumbCtx.menu && (
<ContextMenu
x={crumbCtx.menu.x}
y={crumbCtx.menu.y}
onClose={crumbCtx.close}
testId="fx-crumb-menu"
>
{(() => {
const dir = crumbCtx.menu.item;
const name = dir.split("/").pop() || placeName(target);
return (
<>
<MenuItem onSelect={pick(crumbCtx, () => go(dir))}>{t("fx.open")}</MenuItem>
<MenuItem
onSelect={pick(crumbCtx, () =>
download([{ name, path: dir, type: "dir", size: null, mtime: 0 }])
)}
>
{t("fx.downloadZip")}
</MenuItem>
{!ro && clip && (
<MenuItem onSelect={pick(crumbCtx, () => void paste(dir))}>
{t("fx.pasteInto")}
</MenuItem>
)}
<MenuSeparator />
<MenuItem onSelect={pick(crumbCtx, () => void copyText(dir || "/"))}>
{t("fx.copyPath")}
</MenuItem>
</>
);
})()}
</ContextMenu>
)}
{ask && (
<div
className="absolute inset-0 z-20 flex items-center justify-center bg-black/50"
role="presentation"
>
<div
role="alertdialog"
aria-label={t("fx.conflictTitle")}
data-testid="fx-conflict"
className="w-full max-w-md space-y-3 rounded-xl border border-border bg-surface-1 p-5 shadow-xl"
>
<p className="text-base font-semibold">{t("fx.conflictTitle")}</p>
<p className="break-all text-sm text-fg-secondary">
{t("fx.conflictBody", { names: ask.names.slice(0, 5).join(", ") })}
</p>
{ask.many && (
<label className="flex items-center gap-2 text-sm text-fg-secondary">
<input
type="checkbox"
checked={askAll}
onChange={(e) => setAskAll(e.target.checked)}
/>
{t("fx.conflictAll")}
</label>
)}
<div className="flex flex-wrap justify-end gap-2 pt-1">
<button
type="button"
className="btn-ghost border border-border text-sm"
onClick={() => answer(null)}
>
{t("fx.cancel")}
</button>
<button
type="button"
className="btn-ghost border border-border text-sm"
onClick={() => answer("skip")}
>
{t("fx.skip")}
</button>
<button
type="button"
className="btn-ghost border border-border text-sm"
onClick={() => answer("keep")}
>
{t("fx.keepBoth")}
</button>
<button
type="button"
className="btn-primary text-sm"
onClick={() => answer("replace")}
>
{t("fx.replace")}
</button>
</div>
</div>
</div>
)}
</div>
);
}
/**
* 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<HTMLDivElement>(null);
const titleId = useId();
useDialog(open, panelRef, onClose);
if (!open) return null;
return createPortal(
<div
className="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-3"
onClick={onClose}
role="presentation"
>
<div
ref={panelRef}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
className="flex h-[92vh] w-[96vw] max-w-7xl overflow-hidden rounded-xl border border-border shadow-2xl shadow-black/50"
onClick={(e) => e.stopPropagation()}
>
<h2 id={titleId} className="sr-only">
{t("fx.title")}
</h2>
<FileBrowser {...rest} onClose={onClose} />
</div>
</div>,
document.body
);
}
function HeadButton({
label,
onClick,
disabled,
pressed,
children,
}: {
label: string;
onClick: () => void;
disabled?: boolean;
pressed?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
aria-pressed={pressed}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`rounded-md p-1.5 transition-colors disabled:cursor-not-allowed disabled:opacity-35 ${
pressed
? "bg-surface-3 text-fg-primary"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
}`}
>
{children}
</button>
);
}
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 (
<div className="relative" onClick={(e) => e.stopPropagation()}>
<button
type="button"
aria-haspopup="menu"
aria-expanded={open}
aria-label={label}
title={label}
disabled={disabled}
onClick={onToggle}
className={`flex items-center gap-1 rounded-md px-2 py-1.5 text-sm disabled:opacity-40 ${
primary
? "bg-accent-solid text-white hover:opacity-90"
: "text-fg-secondary hover:bg-surface-2 hover:text-fg-primary"
}`}
>
{icon}
{!iconOnly && (
<>
<span className="hidden md:inline">{label}</span>
<ChevronDown className="h-3.5 w-3.5 opacity-70" />
</>
)}
</button>
{open && (
<div
role="menu"
className="absolute right-0 top-full z-30 mt-1 min-w-48 rounded-lg border border-border bg-surface-1 py-1 shadow-xl"
>
{children}
</div>
)}
</div>
);
}
function DropItem({
icon,
onClick,
disabled,
children,
}: {
icon?: ReactNode;
onClick: () => void;
disabled?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
role="menuitem"
disabled={disabled}
onClick={onClick}
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-fg-primary hover:bg-surface-2 disabled:opacity-40"
>
{icon}
{children}
</button>
);
}
function ActionButton({
icon,
onClick,
disabled,
danger,
children,
}: {
icon: ReactNode;
onClick: () => void;
disabled?: boolean;
danger?: boolean;
children: ReactNode;
}) {
return (
<button
type="button"
disabled={disabled}
onClick={onClick}
className={`flex items-center gap-1.5 rounded-md border border-border bg-surface-1 px-2.5 py-1 text-xs disabled:opacity-40 ${
danger
? "text-status-danger hover:bg-status-danger/10"
: "text-fg-primary hover:bg-surface-3"
}`}
>
{icon}
{children}
</button>
);
}
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<HTMLInputElement>(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 (
<input
ref={ref}
value={value}
aria-label={t("fx.nameField")}
onChange={(e) => 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" : ""
}`}
/>
);
}
/**
* 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<string, string>;
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<string | null>();
const counts = new Map<string, number>();
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 (
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash-root"
onContextMenu={(e) => menu.open(e, null)}
>
<div className="border-b border-border px-4 py-2 text-sm text-fg-secondary">
{t("fx.trashFoldersHint")}
</div>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="grid min-h-0 flex-1 auto-rows-min grid-cols-[repeat(auto-fill,minmax(8.5rem,1fr))] gap-2 overflow-y-auto p-4">
{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 (
<button
key={h.id}
type="button"
title={t("fx.trashOf", { name: h.id === "me" ? t("fx.myHome") : h.label })}
onClick={() => onOpen(h.id)}
onContextMenu={(e) => menu.open(e, h.id)}
className="flex flex-col items-center gap-1 rounded-lg px-2 py-3 text-center hover:bg-surface-2"
data-testid="fx-trash-folder"
>
<span className="relative">
<Folder
className={`h-14 w-14 ${
n ? "fill-accent/25 text-accent" : "fill-surface-3 text-fg-muted"
}`}
/>
{h.id === "me" ? (
<House className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
) : (
<UserRound className="absolute bottom-2 right-0 h-4 w-4 text-fg-secondary" />
)}
</span>
<span className="w-full truncate text-[13px] font-medium">{name}</span>
<span
className={`w-full truncate text-[11px] ${
failed ? "text-status-danger" : "text-fg-muted"
}`}
title={failed}
>
{failed
? failed
: loading
? "…"
: n
? t("fx.itemsCount", { count: n })
: t("fx.trashFolderEmpty")}
</span>
</button>
);
})}
</div>
{menu.menu && (
<ContextMenu
x={menu.menu.x}
y={menu.menu.y}
onClose={menu.close}
testId="fx-trash-root-menu"
>
{menu.menu.item !== null ? (
(() => {
const home = menu.menu.item;
const n = counts.get(home) ?? 0;
return (
<>
<MenuItem onSelect={pick(menu, () => onOpen(home))}>{t("fx.open")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!n} onSelect={pick(menu, () => onEmpty(home, n))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
);
})()
) : (
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
)}
</ContextMenu>
)}
</div>
);
}
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<FsTrashItem | null>();
return (
<div
className="flex min-h-0 flex-1 flex-col"
data-testid="fx-trash"
onContextMenu={(e) => menu.open(e, null)}
>
{error && (
<div className="m-4 rounded-md border border-status-danger/40 bg-status-danger/10 px-3 py-2 text-sm text-status-danger">
{error}
</div>
)}
<div className="flex items-center gap-2 border-b border-border px-4 py-2 text-sm text-fg-secondary">
<span className="flex-1">{t("fx.trashHint")}</span>
<button
type="button"
disabled={!items.length}
onClick={() => onPurge("all")}
className="btn-ghost border border-border text-xs hover:text-status-danger disabled:opacity-40"
>
{t("fx.emptyTrash")}
</button>
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<table className="w-full table-fixed text-left text-[13px]">
<thead className="text-[11px] text-fg-muted">
<tr className="border-b border-border">
<th className="px-4 py-1.5 font-medium">{t("fx.name")}</th>
<th className="w-40 px-2 py-1.5 font-medium">{t("fx.from")}</th>
<th className="w-44 px-2 py-1.5 font-medium">{t("fx.deletedAt")}</th>
<th className="w-56 px-4 py-1.5" />
</tr>
</thead>
<tbody>
{items.map((i) => (
<tr
key={i.id}
className="border-b border-border/40 hover:bg-surface-2"
data-testid="fx-trash-row"
onContextMenu={(e) => menu.open(e, i)}
>
<td className="truncate px-4 py-1.5">
<span className="flex items-center gap-2" title={i.path}>
{i.isDir ? (
<Folder className="h-4 w-4 fill-accent/25 text-accent" />
) : (
<FileIcon className="h-4 w-4 text-fg-muted" />
)}
<span className="truncate">{i.path}</span>
</span>
</td>
<td className="truncate px-2 py-1.5 text-fg-secondary">
{i.laneId != null ? laneName(i.laneId) : ""}
</td>
<td className="px-2 py-1.5 text-fg-secondary">
{new Date(i.deletedAt).toLocaleString()}
</td>
<td className="px-4 py-1.5 text-right">
<span className="inline-flex gap-1">
<button
type="button"
className="btn-ghost border border-border text-xs"
onClick={() => onRestore(i.id)}
>
<RotateCcw className="h-3 w-3" />
{t("fx.restore")}
</button>
<button
type="button"
className="btn-ghost border border-border text-xs hover:text-status-danger"
onClick={() => onPurge(i.id)}
>
{t("fx.purge")}
</button>
</span>
</td>
</tr>
))}
</tbody>
</table>
{!items.length && !error && (
<div className="flex flex-col items-center gap-2 py-16 text-fg-muted">
<Trash2 className="h-14 w-14 opacity-40" />
<p className="text-sm">{t("fx.trashEmpty")}</p>
</div>
)}
</div>
{menu.menu && (
<ContextMenu x={menu.menu.x} y={menu.menu.y} onClose={menu.close} testId="fx-trash-menu">
{menu.menu.item ? (
(() => {
const i = menu.menu.item;
return (
<>
<MenuItem onSelect={pick(menu, () => onRestore(i.id))}>
{t("fx.restore")}
</MenuItem>
<MenuItem onSelect={pick(menu, () => void copyText(i.path))}>
{t("fx.copyPath")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(menu, () => onPurge(i.id))}>
{t("fx.purge")}
</MenuItem>
</>
);
})()
) : (
<>
<MenuItem onSelect={pick(menu, onRefresh)}>{t("fx.refresh")}</MenuItem>
<MenuSeparator />
<MenuItem danger disabled={!items.length} onSelect={pick(menu, () => onPurge("all"))}>
{t("fx.emptyTrash")}
</MenuItem>
</>
)}
</ContextMenu>
)}
</div>
);
}