b058179177
- 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.
2113 lines
75 KiB
TypeScript
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>
|
|
);
|
|
}
|