c8bc4a95a9
Workspace groups: named, foldable, ordered sidebar sections kept per account (agenthub.workspace.groups); a workspace joins one from its menu, a drop on the header, a drag under a row, or Alt+Up/Down past a group's edge; headers drag to reorder; a folded header shows its most urgent state; New workspace in this group; fold/unfold all. Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the Changes panel shows a parent folder's repos only when Settings -> Git -> Changes panel turns it on (agenthub.changes.innerRepos, off by default). Cost: the hook writer adds a session's sub-agent transcripts to its tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the session's own model was never counted live (~8% of a week here); an imported session is re-raised on every pass, so a claude -p run first seen with an empty transcript no longer stays at $0 (~10%). A one-time raise-only catch-up (user_version 5) re-reads ~/.claude/projects. Also: omp install script may redirect to its exact install.redirect URL (#85, repo-links test exempts it), native paste in the terminal.
2369 lines
86 KiB
TypeScript
2369 lines
86 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.
|
|
* The sidebar nests each workspace under its owner's home (a chevron drops
|
|
* them down, remembered per browser); one with no owner home stays in a flat
|
|
* Workspaces list.
|
|
* Server side: `/api/lanes/:id/fs/*` and `/api/homes/:who/fs/*`
|
|
* (server/lib/file-explorer.js).
|
|
* @author Nguyễn Ngọc Trí Vĩ <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,
|
|
type GitSource,
|
|
isHomePlace,
|
|
} from "../../lib/api";
|
|
import type { Lane } from "../../lib/types";
|
|
import { useDialog } from "../../hooks/useDialog";
|
|
import { toast } from "../../lib/toast";
|
|
import { confirmDialog } from "../../lib/confirm";
|
|
import { formatBytes } from "../../lib/format";
|
|
import { copyText } from "../../lib/clipboard";
|
|
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
|
|
import { CloneHereDialog } from "./CloneHereDialog";
|
|
import { isSubmitEnter } from "../../lib/keys";
|
|
import { useAuth } from "../AuthGate";
|
|
|
|
/** One upload batch at most (the server caps one file the same). */
|
|
export const FX_BATCH_MAX_BYTES = 500 * 1024 * 1024;
|
|
/** Files up to this size travel together, many per request (`uploadBatch`):
|
|
* their cost is per FILE (a request, a path walk, a sudo), not per byte. */
|
|
const SMALL_FILE = 4 * 1024 * 1024;
|
|
const PACK_BYTES = 32 * 1024 * 1024;
|
|
const PACK_FILES = 1000;
|
|
/** Keeps a pack's path list well under the server's 1 MB JSON body (upload-check). */
|
|
const PACK_NAME_BYTES = 400 * 1024;
|
|
|
|
/** Small files in packs (each ≥ 2 files) and the rest, one by one. */
|
|
export function packUploads<T extends { file: Blob; rel: string }>(files: T[]) {
|
|
const packs: T[][] = [];
|
|
const singles: T[] = [];
|
|
let cur: T[] = [];
|
|
let bytes = 0;
|
|
let names = 0;
|
|
for (const f of files) {
|
|
if (f.file.size > SMALL_FILE) {
|
|
singles.push(f);
|
|
continue;
|
|
}
|
|
const n = f.rel.length * 3 + 64;
|
|
if (
|
|
cur.length &&
|
|
(cur.length >= PACK_FILES || bytes + f.file.size > PACK_BYTES || names + n > PACK_NAME_BYTES)
|
|
) {
|
|
packs.push(cur);
|
|
cur = [];
|
|
bytes = 0;
|
|
names = 0;
|
|
}
|
|
cur.push(f);
|
|
bytes += f.file.size;
|
|
names += n;
|
|
}
|
|
if (cur.length) packs.push(cur);
|
|
for (const p of packs.filter((p) => p.length < 2)) singles.push(...p);
|
|
return { packs: packs.filter((p) => p.length >= 2), singles };
|
|
}
|
|
const MIME = "application/x-agenthub-fx";
|
|
const VIEW_KEY = "agenthub.files.view";
|
|
const HIDDEN_KEY = "agenthub.files.hidden";
|
|
/** The sidebar homes whose workspaces are dropped down (a JSON list of home ids). */
|
|
const HOMES_OPEN_KEY = "agenthub.files.homesOpen";
|
|
/** A sidebar user row; `noHome`: it only groups an owner's workspaces, the
|
|
* viewer may not open that home (an analyst's view of other users). */
|
|
type UserRow = FsHome & { noHome?: boolean };
|
|
|
|
type Clip = { place: FsPlace; paths: string[]; cut: boolean };
|
|
// The clipboard outlives the dialog and a workspace switch, like a desktop's.
|
|
let clipboard: Clip | null = null;
|
|
|
|
/** A menu item's handler that closes its menu first. */
|
|
const pick = (m: { close: () => void }, fn: () => void) => () => {
|
|
m.close();
|
|
fn();
|
|
};
|
|
/** The workspace id of a place, or null for a home. */
|
|
const laneOf = (t: FsPlace) => (isHomePlace(t) ? null : typeof t === "number" ? t : t.lane);
|
|
/** A workspace's own folder (not a session worktree or an extra folder), or a home. */
|
|
const wholePlace = (t: FsPlace) => isHomePlace(t) || typeof t === "number";
|
|
const samePlace = (a: FsPlace, b: FsPlace) => JSON.stringify(a) === JSON.stringify(b);
|
|
const join = (dir: string, name: string) => (dir ? `${dir}/${name}` : name);
|
|
const errText = (e: unknown) => (e instanceof Error ? e.message : String(e));
|
|
const errCode = (e: unknown) => (e as { code?: string })?.code;
|
|
const isFolder = (e: FsEntry) => e.type === "dir" || e.type === "linkdir";
|
|
const ext = (name: string) => name.slice(name.lastIndexOf(".") + 1).toLowerCase();
|
|
const IMAGE = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp", "ico"]);
|
|
const CODE = new Set([
|
|
"js",
|
|
"jsx",
|
|
"ts",
|
|
"tsx",
|
|
"mjs",
|
|
"cjs",
|
|
"py",
|
|
"go",
|
|
"rs",
|
|
"java",
|
|
"kt",
|
|
"c",
|
|
"h",
|
|
"cpp",
|
|
"cs",
|
|
"rb",
|
|
"php",
|
|
"sh",
|
|
"css",
|
|
"scss",
|
|
"html",
|
|
"vue",
|
|
"svelte",
|
|
"sql",
|
|
"yml",
|
|
"yaml",
|
|
"toml",
|
|
"xml",
|
|
]);
|
|
|
|
function readPref(key: string, fallback: string) {
|
|
try {
|
|
return localStorage.getItem(key) ?? fallback;
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
function writePref(key: string, value: string) {
|
|
try {
|
|
localStorage.setItem(key, value);
|
|
} catch {
|
|
/* a per-browser convenience only */
|
|
}
|
|
}
|
|
|
|
/** Every file under a dropped folder entry, with its path inside the drop. */
|
|
async function walkEntry(
|
|
entry: FileSystemEntry,
|
|
prefix: string,
|
|
out: { file: File; rel: string }[]
|
|
) {
|
|
if (entry.isFile) {
|
|
const file = await new Promise<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?: GitSource | null;
|
|
initialPath?: string;
|
|
/** Called after anything changed files, so a tree elsewhere can re-read. */
|
|
onChanged?: () => void;
|
|
onClose?: () => void;
|
|
/** The page keeps `?lane=` / `?home=` and `&path=` in the URL. */
|
|
onNavigate?: (place: FsPlace, path: string) => void;
|
|
}) {
|
|
const { t } = useTranslation(["lanes"]);
|
|
const placeOf = (): FsPlace =>
|
|
laneId != null ? gitTarget(laneId, session, source) : { home: initialHome || "me" };
|
|
const [target, setTarget] = useState<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 [cloning, setCloning] = useState(false);
|
|
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 { state: auth } = useAuth();
|
|
const myId = auth.mode === "server" ? (auth.user?.id ?? null) : null;
|
|
const [openHomes, setOpenHomes] = useState<Set<string>>(() => {
|
|
try {
|
|
const v: unknown = JSON.parse(readPref(HOMES_OPEN_KEY, '["me"]'));
|
|
return new Set(Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : []);
|
|
} catch {
|
|
return new Set(["me"]);
|
|
}
|
|
});
|
|
const [clip, setClip] = useState<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;
|
|
// Clone into the open folder: the absolute path of a workspace's own folder
|
|
// or of the user's own home (never a session worktree, an extra folder or
|
|
// another user's home); the server checks the folder again.
|
|
const cloneRoot = isHomePlace(target)
|
|
? target.home === "me"
|
|
? (homes.find((h) => h.id === "me")?.dir ?? null)
|
|
: null
|
|
: typeof target === "number"
|
|
? (lanes.find((l) => l.id === target)?.cwd ?? null)
|
|
: null;
|
|
const cloneParent =
|
|
cloneRoot && view === "files"
|
|
? path
|
|
? `${cloneRoot.replace(/\/+$/, "")}/${path}`
|
|
: cloneRoot
|
|
: null;
|
|
|
|
/** Go to `p` in workspace `tgt`, remembering where we were for Back. */
|
|
const go = (p: string, tgt: FsPlace = target) => {
|
|
const sameLane = samePlace(tgt, target);
|
|
if (sameLane && p === path && view === "files") return;
|
|
setHistory((h) => ({ back: sameLane ? [...h.back, path] : [], fwd: [] }));
|
|
if (!sameLane) setTarget(tgt);
|
|
setPathState(p);
|
|
setView("files");
|
|
setSelected(new Set());
|
|
setQuery(null);
|
|
onNavigate?.(tgt, p);
|
|
};
|
|
const back = () => {
|
|
const prev = history.back[history.back.length - 1];
|
|
if (prev === undefined) return;
|
|
setHistory((h) => ({ back: h.back.slice(0, -1), fwd: [path, ...h.fwd] }));
|
|
setPathState(prev);
|
|
setSelected(new Set());
|
|
onNavigate?.(target, prev);
|
|
};
|
|
const forward = () => {
|
|
const next = history.fwd[0];
|
|
if (next === undefined) return;
|
|
setHistory((h) => ({ back: [...h.back, path], fwd: h.fwd.slice(1) }));
|
|
setPathState(next);
|
|
setSelected(new Set());
|
|
onNavigate?.(target, next);
|
|
};
|
|
|
|
const load = useCallback(async () => {
|
|
setError(null);
|
|
setLoading(true);
|
|
try {
|
|
if (view === "trash" && trashRoot) {
|
|
const out = await api.files.allTrash();
|
|
setTrash(out.items);
|
|
// A user whose trash could not be read is said so on their folder.
|
|
setTrashErrors(Object.fromEntries((out.errors ?? []).map((e) => [e.home, e.message])));
|
|
} else if (view === "trash") setTrash((await api.files.trash(target)).items);
|
|
else setListing(await api.files.list(target, path));
|
|
} catch (e) {
|
|
// A dashboard that does not know /fs yet (not restarted) answers 404.
|
|
setError(
|
|
(e as { status?: number }).status === 404 && !errCode(e) ? t("fx.unavailable") : errText(e)
|
|
);
|
|
if (view === "files" && errCode(e) === "ENOFILE" && path) setPathState("");
|
|
if (view === "trash") setTrash([]);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}, [target, path, view, trashRoot, t]);
|
|
useEffect(() => {
|
|
void load();
|
|
}, [load]);
|
|
|
|
const changed = () => {
|
|
void load();
|
|
onChanged?.();
|
|
};
|
|
|
|
const entries = useMemo(() => {
|
|
const all = listing?.entries ?? [];
|
|
const q = query?.trim().toLowerCase();
|
|
return all.filter(
|
|
(e) => (showHidden || !e.name.startsWith(".")) && (!q || e.name.toLowerCase().includes(q))
|
|
);
|
|
}, [listing, showHidden, query]);
|
|
const selectedEntries = entries.filter((e) => selected.has(e.path));
|
|
|
|
/** A choice for taken names: Replace / Keep both / Skip (null = cancel). */
|
|
const askConflict = (names: string[], many: boolean) =>
|
|
new Promise<{ choice: FsConflict | null; all: boolean }>((resolve) => {
|
|
setAskAll(false);
|
|
setAsk({ names, many, resolve });
|
|
});
|
|
const answer = (choice: FsConflict | null) => {
|
|
ask?.resolve({ choice, all: askAll });
|
|
setAsk(null);
|
|
};
|
|
|
|
/** Run `op` once; on EEXISTS ask, then run it again with the choice. */
|
|
const withConflict = async <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 done = 0;
|
|
let saved = 0;
|
|
let forAll: FsConflict | null = null;
|
|
// false once the server says a batch cannot be written here (an old helper).
|
|
let batchable = true;
|
|
const show = (name: string, extra = 0) =>
|
|
setProgress({ name, done, total: files.length, sent: sent + extra, bytes });
|
|
/** One file; false = the user cancelled. */
|
|
const one = async ({ file, rel }: { file: File; rel: string }) => {
|
|
const dest = join(dir, rel);
|
|
show(rel);
|
|
const send = (c: FsConflict) => api.files.upload(target, dest, file, c, (n) => show(rel, n));
|
|
try {
|
|
const r = await send(forAll ?? "fail").catch(async (e) => {
|
|
if (errCode(e) !== "EEXISTS") throw e;
|
|
const a = await askConflict([rel], files.length > 1);
|
|
if (!a.choice) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
|
if (a.all) forAll = a.choice;
|
|
return send(a.choice);
|
|
});
|
|
if (!r.skipped) saved++;
|
|
} catch (e) {
|
|
if (errCode(e) === "ECANCEL") return false;
|
|
toast.error(t("fx.uploadFailed", { name: rel, message: errText(e) }));
|
|
}
|
|
sent += file.size;
|
|
done++;
|
|
return true;
|
|
};
|
|
/** Many small files in one request: "ok", "cancel", or "fallback" (send them one by one). */
|
|
const pack = async (b: { file: File; rel: string }[]) => {
|
|
const label = `${b[0]!.rel} (+${b.length - 1})`;
|
|
const size = b.reduce((n, f) => n + f.file.size, 0);
|
|
show(label);
|
|
// Settled BEFORE the bytes go: a 409 after a 32 MB body would waste it.
|
|
const choose = async (taken: string[]) => {
|
|
if (forAll) return forAll;
|
|
if (!taken.length) return "fail" as const;
|
|
const a = await askConflict(taken, files.length > 1);
|
|
if (a.all && a.choice) forAll = a.choice;
|
|
return a.choice;
|
|
};
|
|
try {
|
|
const c = await api.files.uploadCheck(
|
|
target,
|
|
dir,
|
|
b.map((f) => f.rel)
|
|
);
|
|
if (!c.batch) {
|
|
batchable = false;
|
|
return "fallback";
|
|
}
|
|
let policy = await choose(c.taken);
|
|
if (!policy) return "cancel";
|
|
const send = (p: FsConflict) =>
|
|
api.files.uploadBatch(target, dir, b, p, (n) => show(label, n));
|
|
const r = await send(policy).catch(async (e) => {
|
|
// Taken meanwhile: ask about those, then once more.
|
|
if (errCode(e) !== "EEXISTS") throw e;
|
|
policy = await choose((e as { names?: string[] }).names ?? []);
|
|
if (!policy) throw Object.assign(new Error("cancelled"), { code: "ECANCEL" });
|
|
return send(policy);
|
|
});
|
|
saved += r.saved;
|
|
} catch (e) {
|
|
const code = errCode(e);
|
|
if (code === "ECANCEL") return "cancel";
|
|
// Refused before a byte was written (a bad name, an old helper…):
|
|
// each file then gets its own answer.
|
|
if (code === "EUPGRADE") batchable = false;
|
|
if (code && /^E(UPGRADE|BADNAME|BADPATH|SECRET|BADBODY|TOOBIG|EXISTS)$/.test(code)) {
|
|
return "fallback";
|
|
}
|
|
toast.error(t("fx.uploadFailed", { name: label, message: errText(e) }));
|
|
}
|
|
sent += size;
|
|
done += b.length;
|
|
return "ok";
|
|
};
|
|
try {
|
|
const { packs, singles } = packUploads(files);
|
|
let go = true;
|
|
for (const b of packs) {
|
|
const r = batchable ? await pack(b) : "fallback";
|
|
if (r === "cancel") go = false;
|
|
if (r === "fallback") for (const f of b) if (go && !(await one(f))) go = false;
|
|
if (!go) break;
|
|
}
|
|
for (const f of singles) if (!go || !(await one(f))) break;
|
|
} finally {
|
|
setProgress(null);
|
|
setBusy(false);
|
|
if (saved) toast.success(t("fx.uploaded", { count: saved }));
|
|
changed();
|
|
}
|
|
};
|
|
|
|
const onDrop = async (e: ReactDragEvent, dir = path) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
setDropping(false);
|
|
if (ro) {
|
|
toast.error(t("fx.readOnlyHint"));
|
|
return;
|
|
}
|
|
const inner = e.dataTransfer.getData(MIME);
|
|
if (inner) {
|
|
await moveInto(JSON.parse(inner) as string[], dir, e.ctrlKey || e.metaKey);
|
|
return;
|
|
}
|
|
const out: { file: File; rel: string }[] = [];
|
|
const dropped = [...e.dataTransfer.items]
|
|
.map((i) => i.webkitGetAsEntry?.())
|
|
.filter(Boolean) as FileSystemEntry[];
|
|
if (dropped.length) {
|
|
for (const en of dropped) await walkEntry(en, "", out);
|
|
} else {
|
|
for (const f of e.dataTransfer.files) out.push({ file: f, rel: f.name });
|
|
}
|
|
await uploadAll(out, dir);
|
|
};
|
|
|
|
// ── Selection & keys ────────────────────────────────────────────────────
|
|
const click = (e: ReactMouseEvent, entry: FsEntry) => {
|
|
e.stopPropagation();
|
|
const next = new Set(selected);
|
|
if (e.shiftKey && anchor.current) {
|
|
const a = entries.findIndex((x) => x.path === anchor.current);
|
|
const b = entries.findIndex((x) => x.path === entry.path);
|
|
const [lo, hi] = a < b ? [a, b] : [b, a];
|
|
for (const x of entries.slice(lo, hi + 1)) next.add(x.path);
|
|
} else if (e.ctrlKey || e.metaKey) {
|
|
if (next.has(entry.path)) next.delete(entry.path);
|
|
else next.add(entry.path);
|
|
anchor.current = entry.path;
|
|
} else {
|
|
next.clear();
|
|
next.add(entry.path);
|
|
anchor.current = entry.path;
|
|
}
|
|
setSelected(next);
|
|
};
|
|
const toggleHidden = () => {
|
|
setShowHidden((v) => {
|
|
writePref(HIDDEN_KEY, v ? "0" : "1");
|
|
return !v;
|
|
});
|
|
setMenu(null);
|
|
};
|
|
const setLayoutPref = (l: "grid" | "list") => {
|
|
setLayout(l);
|
|
writePref(VIEW_KEY, l);
|
|
};
|
|
const onKey = (e: ReactKeyboardEvent) => {
|
|
if (renaming || creating || ask || cloning) return;
|
|
const mod = e.ctrlKey || e.metaKey;
|
|
const k = e.key.toLowerCase();
|
|
// Another user's home is read-only: no key that writes.
|
|
if (ro && (e.key === "Delete" || e.key === "F2" || (mod && (k === "x" || k === "v")))) return;
|
|
if (e.altKey && e.key === "ArrowLeft") back();
|
|
else if (e.altKey && e.key === "ArrowRight") forward();
|
|
else if (e.altKey && e.key === "ArrowUp") goUp();
|
|
else if (mod && k === "h") toggleHidden();
|
|
else if (view !== "files") return;
|
|
else if (e.key === "Delete") void removeItems(selectedEntries);
|
|
else if (e.key === "F2" && selectedEntries.length === 1) startRename(selectedEntries[0]!);
|
|
else if (e.key === "Enter" && selectedEntries.length === 1) openEntry(selectedEntries[0]!);
|
|
else if (e.key === "Backspace") goUp();
|
|
else if (mod && k === "a") setSelected(new Set(entries.map((x) => x.path)));
|
|
else if (mod && k === "c") putClip(selectedEntries, false);
|
|
else if (mod && k === "x") putClip(selectedEntries, true);
|
|
else if (mod && k === "v") void paste();
|
|
else if (mod && k === "f") setQuery((q) => q ?? "");
|
|
else return;
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
};
|
|
|
|
const homeName = (who: string) =>
|
|
who === "me" ? t("fx.myHome") : (homes.find((h) => h.id === who)?.label ?? who);
|
|
/** Delete forever — asked first when it is all of it or someone else's. */
|
|
const purge = async (id: string) => {
|
|
if (id === "all" || ro) {
|
|
const item = trash.find((i) => i.id === id);
|
|
const ok = await confirmDialog({
|
|
title: id === "all" ? t("fx.emptyTrashTitle") : t("fx.purgeTitle"),
|
|
message:
|
|
id === "all"
|
|
? t("fx.emptyTrashBody", { count: trash.length, where: trashLabel })
|
|
: t("fx.purgeBody", { name: item?.path ?? id, where: trashLabel }),
|
|
confirmLabel: id === "all" ? t("fx.emptyTrash") : t("fx.purge"),
|
|
});
|
|
if (!ok) return;
|
|
}
|
|
const item = trash.find((i) => i.id === id);
|
|
await run(() => api.files.purge(target, id, item?.store), t("fx.purged"));
|
|
};
|
|
/** Empty one user's whole Trash from the Trash root — asked first. */
|
|
const emptyTrashOf = async (home: string, count: number) => {
|
|
const ok = await confirmDialog({
|
|
title: t("fx.emptyTrashTitle"),
|
|
message: t("fx.emptyTrashBody", {
|
|
count,
|
|
where: home === "me" ? t("fx.myTrash") : t("fx.trashOf", { name: homeName(home) }),
|
|
}),
|
|
confirmLabel: t("fx.emptyTrash"),
|
|
});
|
|
if (ok) await run(() => api.files.purge({ home }, "all"), t("fx.purged"));
|
|
};
|
|
const restoreItem = (id: string) => {
|
|
const item = trash.find((i) => i.id === id);
|
|
void run(() => api.files.restore(target, id, item?.store), t("fx.restored"));
|
|
};
|
|
/**
|
|
* Open a Trash. Every user has ONE recycle bin — their home's, over every
|
|
* place they deleted in — so `home` opens that user's ("me": the caller's);
|
|
* null (no home to open) falls back to the trash of the place shown, and
|
|
* "*" opens the root an admin starts on: one folder per user's Trash.
|
|
*/
|
|
const openTrash = (home: string | null) => {
|
|
if (home === "*") {
|
|
setTrashRoot(true);
|
|
setView("trash");
|
|
setSelected(new Set());
|
|
return;
|
|
}
|
|
setTrashRoot(false);
|
|
if (home && !(isHomePlace(target) && target.home === home)) {
|
|
setTarget({ home });
|
|
setPathState("");
|
|
setHistory({ back: [], fwd: [] });
|
|
onNavigate?.({ home }, "");
|
|
}
|
|
setView("trash");
|
|
setSelected(new Set());
|
|
};
|
|
// Someone else's Trash can be opened: the Trash starts on its folders.
|
|
const manyTrashes = homes.some((h) => h.id !== "me");
|
|
const openTrashes = () =>
|
|
openTrash(manyTrashes ? "*" : homes.some((h) => h.id === "me") ? "me" : null);
|
|
const placeName = (p: FsPlace) => (isHomePlace(p) ? homeName(p.home) : laneName(laneOf(p)!));
|
|
const laneName = (id: number) => {
|
|
const l = lanes.find((x) => x.id === id);
|
|
return l ? l.title || l.cwd.split("/").pop() || `#${id}` : `#${id}`;
|
|
};
|
|
// A home's Trash is its USER's recycle bin, named after them: another
|
|
// user's shows THAT user's (an admin may restore from or purge it; the rest
|
|
// of the home stays read-only). A workspace's (no home to open) is the
|
|
// trash of whoever owns its folder.
|
|
const trashLabel = trashRoot
|
|
? t("fx.trash")
|
|
: !isHomePlace(target)
|
|
? t("fx.trashAt", { name: laneName(laneOf(target)!) })
|
|
: target.home === "me"
|
|
? t("fx.myTrash")
|
|
: t("fx.trashOf", { name: homeName(target.home) });
|
|
const crumbs = path ? path.split("/") : [];
|
|
const menuItem = ctx.menu?.item ?? null;
|
|
const menuTargets = menuItem ? (selected.has(menuItem.path) ? selectedEntries : [menuItem]) : [];
|
|
const close = (fn: () => void) => () => {
|
|
ctx.close();
|
|
fn();
|
|
};
|
|
const itemProps = (e: FsEntry) => ({
|
|
draggable: !renaming,
|
|
onDragStart: (ev: ReactDragEvent) => {
|
|
const paths = selected.has(e.path) ? [...selected] : [e.path];
|
|
ev.dataTransfer.setData(MIME, JSON.stringify(paths));
|
|
// Into a terminal or the prompt: the path as text.
|
|
ev.dataTransfer.setData("text/plain", e.path);
|
|
ev.dataTransfer.effectAllowed = "copyMove";
|
|
},
|
|
onDragOver: isFolder(e) ? (ev: ReactDragEvent) => ev.preventDefault() : undefined,
|
|
onDrop: isFolder(e) ? (ev: ReactDragEvent) => void onDrop(ev, e.path) : undefined,
|
|
onClick: (ev: ReactMouseEvent) => click(ev, e),
|
|
onDoubleClick: () => openEntry(e),
|
|
onContextMenu: (ev: ReactMouseEvent) => {
|
|
ev.preventDefault();
|
|
ev.stopPropagation();
|
|
if (!selected.has(e.path)) setSelected(new Set([e.path]));
|
|
ctx.open(ev, e);
|
|
},
|
|
});
|
|
const nameBox = (e: FsEntry, center: boolean) =>
|
|
renaming?.path === e.path ? (
|
|
<InlineName
|
|
center={center}
|
|
value={renaming.value}
|
|
onChange={(v) => setRenaming({ ...renaming, value: v })}
|
|
onDone={() => void commitRename()}
|
|
onCancel={() => setRenaming(null)}
|
|
/>
|
|
) : null;
|
|
// Each workspace drops down under its owner: the caller's own under My
|
|
// home, another user's under theirs (`owner_name` = that home's id). An
|
|
// owner whose home this viewer may not open (an analyst's view of other
|
|
// users) still gets a row, one that only groups. A workspace with no owner
|
|
// stays in the flat Workspaces list. Which rows are open is per browser.
|
|
const { lanesOfHome, userRows } = useMemo(() => {
|
|
const ids = new Set(homes.map((h) => h.id));
|
|
const map = new Map<string, Lane[]>();
|
|
const extra = new Map<string, UserRow>();
|
|
for (const l of lanes) {
|
|
const home =
|
|
myId != null && l.owner_id === myId && ids.has("me")
|
|
? "me"
|
|
: l.owner_name && l.owner_name !== "me"
|
|
? l.owner_name
|
|
: null;
|
|
if (!home) continue;
|
|
map.set(home, [...(map.get(home) ?? []), l]);
|
|
if (!ids.has(home) && !extra.has(home)) {
|
|
extra.set(home, {
|
|
id: home,
|
|
label: l.owner_display_name || home,
|
|
readOnly: true,
|
|
noHome: true,
|
|
});
|
|
}
|
|
}
|
|
const rows: UserRow[] = [
|
|
...homes.filter((h) => h.id !== "me"),
|
|
...[...extra.values()].sort((a, b) => a.label.localeCompare(b.label)),
|
|
];
|
|
return { lanesOfHome: map, userRows: rows };
|
|
}, [homes, lanes, myId]);
|
|
const grouped = new Set([...lanesOfHome.values()].flat().map((l) => l.id));
|
|
const looseLanes = lanes.filter((l) => !grouped.has(l.id));
|
|
// A home holding the open workspace shows it, whatever was stored.
|
|
const homeIsOpen = (id: string, own: Lane[]) =>
|
|
openHomes.has(id) ||
|
|
(view === "files" && !isHomePlace(target) && own.some((l) => l.id === laneOf(target)));
|
|
const toggleHome = (id: string, own: Lane[]) => {
|
|
const next = new Set(openHomes);
|
|
if (homeIsOpen(id, own)) next.delete(id);
|
|
else next.add(id);
|
|
setOpenHomes(next);
|
|
writePref(HOMES_OPEN_KEY, JSON.stringify([...next]));
|
|
};
|
|
const placeClass = (active: boolean) =>
|
|
`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-[13px] ${
|
|
active ? "bg-accent/15 font-medium text-accent" : "text-fg-secondary hover:bg-surface-2"
|
|
}`;
|
|
|
|
return (
|
|
<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"
|
|
>
|
|
{cloneParent && (
|
|
<CloneHereDialog
|
|
open={cloning}
|
|
parent={cloneParent}
|
|
onClose={() => setCloning(false)}
|
|
onReopen={() => setCloning(true)}
|
|
onCloned={(lane, at) => {
|
|
setCloning(false);
|
|
toast.success(t("fx.cloned", { path: at, workspace: lane.title || lane.cwd }));
|
|
setLanes((ls) => (ls.some((l) => l.id === lane.id) ? ls : [...ls, lane]));
|
|
changed();
|
|
}}
|
|
/>
|
|
)}
|
|
{/* ── Places ── */}
|
|
<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")}>
|
|
{(() => {
|
|
const laneRow = (l: Lane, nested: boolean) => (
|
|
<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
|
|
)}${nested ? " pl-7" : ""}`}
|
|
>
|
|
<FolderGit2 className="h-4 w-4 shrink-0" />
|
|
<span className="truncate">{l.title || l.cwd.split("/").pop()}</span>
|
|
</button>
|
|
);
|
|
// A home row with a chevron: its user's workspaces drop down under it.
|
|
const homeRow = (h: UserRow) => {
|
|
const own = lanesOfHome.get(h.id) ?? [];
|
|
const open = own.length > 0 && homeIsOpen(h.id, own);
|
|
const mine = h.id === "me";
|
|
// No home to open: the name drops the list down too.
|
|
const browse = !h.noHome;
|
|
return (
|
|
<div key={h.id} data-testid={`fx-home-${h.id}`}>
|
|
<div className="flex items-center">
|
|
<button
|
|
type="button"
|
|
aria-label={t("fx.toggleWorkspaces", {
|
|
name: mine ? t("fx.myHome") : h.label,
|
|
})}
|
|
aria-expanded={open}
|
|
disabled={!own.length}
|
|
onClick={() => toggleHome(h.id, own)}
|
|
className="flex h-6 w-5 shrink-0 items-center justify-center rounded text-fg-muted hover:bg-surface-2 disabled:opacity-0"
|
|
>
|
|
{open ? (
|
|
<ChevronDown className="h-3.5 w-3.5" />
|
|
) : (
|
|
<ChevronRight className="h-3.5 w-3.5" />
|
|
)}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
title={mine || !browse ? undefined : t("fx.readOnlyHome")}
|
|
aria-expanded={browse ? undefined : open}
|
|
onClick={() => (browse ? go("", { home: h.id }) : toggleHome(h.id, own))}
|
|
onContextMenu={browse ? (e) => placeCtx.open(e, { home: h.id }) : undefined}
|
|
className={placeClass(
|
|
browse && view === "files" && isHomePlace(target) && target.home === h.id
|
|
)}
|
|
>
|
|
{mine ? (
|
|
<House className="h-4 w-4 shrink-0" />
|
|
) : (
|
|
<UserRound className="h-4 w-4 shrink-0" />
|
|
)}
|
|
<span className="truncate">{mine ? t("fx.myHome") : h.label}</span>
|
|
{own.length > 0 && (
|
|
<span className="ml-auto shrink-0 text-[11px] tabular-nums text-fg-muted">
|
|
{own.length}
|
|
</span>
|
|
)}
|
|
{!mine && browse && (
|
|
<Eye
|
|
className={`${own.length ? "" : "ml-auto "}h-3 w-3 shrink-0 opacity-60`}
|
|
/>
|
|
)}
|
|
</button>
|
|
</div>
|
|
{open && own.map((l) => laneRow(l, true))}
|
|
</div>
|
|
);
|
|
};
|
|
const me = homes.find((h) => h.id === "me");
|
|
const others = userRows;
|
|
return (
|
|
<>
|
|
{me && <div className="pt-3">{homeRow(me)}</div>}
|
|
{others.length > 0 && (
|
|
<>
|
|
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{others.some((h) => !h.noHome) ? t("fx.userHomes") : t("fx.users")}
|
|
</p>
|
|
{others.map(homeRow)}
|
|
</>
|
|
)}
|
|
{looseLanes.length > 0 && (
|
|
<>
|
|
<p className="px-2 pb-1 pt-3 text-[10px] font-semibold uppercase tracking-wider text-fg-muted">
|
|
{t("fx.places")}
|
|
</p>
|
|
{looseLanes.map((l) => laneRow(l, false))}
|
|
</>
|
|
)}
|
|
</>
|
|
);
|
|
})()}
|
|
</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>
|
|
{cloneParent && (
|
|
<DropItem
|
|
icon={<FolderGit2 className="h-4 w-4" />}
|
|
onClick={() => {
|
|
setMenu(null);
|
|
setCloning(true);
|
|
}}
|
|
>
|
|
{t("fx.cloneRepo")}
|
|
</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>
|
|
{cloneParent && (
|
|
<MenuItem onSelect={close(() => setCloning(true))}>
|
|
{t("fx.cloneRepo")}
|
|
</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?: GitSource | 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 (isSubmitEnter(e)) onDone();
|
|
if (e.key === "Escape") onCancel();
|
|
}}
|
|
onBlur={onDone}
|
|
className={`w-full rounded border border-accent bg-surface-0 px-1.5 py-0.5 text-xs outline-none ${
|
|
center ? "text-center" : ""
|
|
}`}
|
|
/>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* An admin's Trash root: one folder per user's Trash (theirs first), each
|
|
* with its item count — or why it could not be read. Opening one shows that
|
|
* user's Trash, where its items are restored or deleted forever.
|
|
*/
|
|
function TrashFolders({
|
|
homes,
|
|
items,
|
|
errors,
|
|
error,
|
|
loading,
|
|
homeName,
|
|
onOpen,
|
|
onEmpty,
|
|
onRefresh,
|
|
}: {
|
|
homes: FsHome[];
|
|
/** Every Trash's items, merged, each tagged with its `home`. */
|
|
items: FsTrashItem[];
|
|
/** Per home id: why its Trash could not be read. */
|
|
errors: Record<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>
|
|
);
|
|
}
|