0c2f49876d
- Users page: one Edit user form (display name, email, role, active, a new or generated password, workspaces given or taken back, sign out everywhere), validated as a whole before anything is written (PATCH /api/users/:id, GET /api/users/:id); users.display_name/email added migration-safe. - Settings → Account for every signed-in user: profile, own password (the current one required, this login kept), sign out other devices (/api/me/account, /api/me/password, /api/me/sign-out-others; login cookie only, never a pane token). - Display names wherever a person is named (sidebar, workspace owner groups, Users page and audit log); owner_display_name / reader_display additive. - Users page: admins add and remove Claude/Codex pool accounts. - Folder browser: an admin with their own Linux account opens in their home; the "Server folder" tick opens the host's (never remembered). - AI Proxy: "Config 9router" beside Add profile when 9router is installed, opened on the dashboard's own host (GET /api/ai-proxies/9router). - Terminal: a tmux selection reaches the clipboard over plain HTTP too (writeClipboard's textarea fallback, focus handed back). - Chat: a file the session touched outside the workspace previews (GET /api/sessions/:id/file). - install-system: `!requiretty` only when visudo accepts it (sudo-rs on Ubuntu 25.10+ rejects the setting). - openapi.yaml regenerated; docs (API, SERVER-MODE, LANES) updated. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
720 lines
26 KiB
TypeScript
720 lines
26 KiB
TypeScript
/**
|
||
* @file A server-backed folder (or file) browser for the path inputs' Browse
|
||
* button, laid out like the OS's own picker. Browsers refuse to expose an
|
||
* absolute filesystem path from a native picker, so path selection here is
|
||
* done by browsing `GET /api/lanes/browse` instead: back/forward/up, a
|
||
* clickable breadcrumb that turns into a path field (Ctrl+L), a Places column
|
||
* (Home, the user's workspaces, recent picks), a filter, Name/Modified columns,
|
||
* hidden entries on demand and New folder (`POST /api/lanes/browse/mkdir`).
|
||
* A click selects, a double-click or Enter opens a folder (picks a file),
|
||
* Backspace goes up and typing jumps to a name.
|
||
* An admin with a Linux account of their own (S3; the listing carries
|
||
* `hostHome`) opens in THEIR home; the "Server folder" tick switches the
|
||
* browser to the host account's home, where a workspace runs as the shared
|
||
* host account. It is never remembered: every open starts at the admin's own.
|
||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||
*/
|
||
|
||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
ArrowLeft,
|
||
ArrowRight,
|
||
ArrowUp,
|
||
Clock,
|
||
Eye,
|
||
File,
|
||
FolderGit2,
|
||
FolderOpen,
|
||
FolderPlus,
|
||
Home,
|
||
Pencil,
|
||
Server,
|
||
Star,
|
||
} from "lucide-react";
|
||
import { api } from "../../lib/api";
|
||
import { formatDateTime } from "../../lib/format";
|
||
import { isSubmitEnter } from "../../lib/keys";
|
||
import { useDialog } from "../../hooks/useDialog";
|
||
import { useWorkLanes } from "../../hooks/useLanes";
|
||
|
||
type Listing = Awaited<ReturnType<typeof api.lanes.browse>>;
|
||
type Entry = Listing["entries"][number];
|
||
|
||
const RECENT_KEY = "agenthub.browse.recent";
|
||
const HIDDEN_KEY = "agenthub.browse.hidden";
|
||
const RECENT_MAX = 8;
|
||
|
||
function readStored<T>(key: string, fallback: T): T {
|
||
try {
|
||
const raw = localStorage.getItem(key);
|
||
return raw ? (JSON.parse(raw) as T) : fallback;
|
||
} catch {
|
||
return fallback;
|
||
}
|
||
}
|
||
|
||
function writeStored(key: string, value: unknown) {
|
||
try {
|
||
localStorage.setItem(key, JSON.stringify(value));
|
||
} catch {
|
||
// A private window or blocked storage: the preference just isn't kept.
|
||
}
|
||
}
|
||
|
||
/** `[label, path]` for each level of `p`, root first. */
|
||
function crumbs(p: string): [string, string][] {
|
||
const win = /^[A-Za-z]:\\/.test(p);
|
||
const sep = win ? "\\" : "/";
|
||
const parts = p.split(sep).filter(Boolean);
|
||
const out: [string, string][] = win ? [] : [["/", "/"]];
|
||
let acc = "";
|
||
for (const part of parts) {
|
||
acc = win ? (acc ? `${acc}\\${part}` : `${part}\\`) : `${acc}/${part}`;
|
||
out.push([part, acc]);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
const baseName = (p: string) => p.split(/[\\/]/).filter(Boolean).pop() || p;
|
||
|
||
type Props = {
|
||
open: boolean;
|
||
/** `file` lists files too, and picking one is the only way to select. */
|
||
kind?: "dir" | "file";
|
||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||
initialPath?: string;
|
||
onSelect: (path: string) => void;
|
||
onClose: () => void;
|
||
};
|
||
|
||
/**
|
||
* Mounted only while open, so a closed picker (one behind every Browse
|
||
* button) asks the server for nothing, and each opening starts fresh.
|
||
*/
|
||
export function FolderBrowseModal(props: Props) {
|
||
return props.open ? <BrowseDialog {...props} /> : null;
|
||
}
|
||
|
||
function BrowseDialog({
|
||
open,
|
||
kind = "dir",
|
||
initialPath,
|
||
onSelect,
|
||
onClose,
|
||
}: {
|
||
open: boolean;
|
||
/** `file` lists files too, and picking one is the only way to select. */
|
||
kind?: "dir" | "file";
|
||
/** Path to start browsing from; omitted defaults server-side to the home dir. */
|
||
initialPath?: string;
|
||
onSelect: (path: string) => void;
|
||
onClose: () => void;
|
||
}) {
|
||
const { t } = useTranslation(["lanes"]);
|
||
const { lanes } = useWorkLanes();
|
||
const [listing, setListing] = useState<Listing | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 });
|
||
const [selected, setSelected] = useState<string | null>(null);
|
||
const [filter, setFilter] = useState("");
|
||
const [editing, setEditing] = useState(false);
|
||
const [pathText, setPathText] = useState("");
|
||
const [sort, setSort] = useState<{ key: "name" | "mtime"; dir: 1 | -1 }>({
|
||
key: "name",
|
||
dir: 1,
|
||
});
|
||
const [showHidden, setShowHidden] = useState(() => readStored(HIDDEN_KEY, false));
|
||
const [creating, setCreating] = useState(false);
|
||
const [newName, setNewName] = useState("");
|
||
const [recent, setRecent] = useState<string[]>(() => readStored(RECENT_KEY, []));
|
||
// The highest folder this user may open (a member's own folder answers no
|
||
// parent) — breadcrumbs above it are not links.
|
||
const [floor, setFloor] = useState<string | null>(null);
|
||
const [home, setHome] = useState<string | null>(null);
|
||
// An admin's "Server folder" tick (S3): browse the host account's home.
|
||
const [hostHome, setHostHome] = useState<string | null>(null);
|
||
const [onHost, setOnHost] = useState(false);
|
||
const listRef = useRef<HTMLDivElement>(null);
|
||
const crumbRef = useRef<HTMLDivElement>(null);
|
||
const typeAhead = useRef({ text: "", at: 0 });
|
||
|
||
const load = async (
|
||
target?: string,
|
||
{ push = true, hidden = showHidden, select = null as string | null, host = onHost } = {}
|
||
) => {
|
||
setError(null);
|
||
try {
|
||
const next = await api.lanes.browse(target, kind === "file", hidden, host);
|
||
setListing(next);
|
||
setSelected(select);
|
||
setFilter("");
|
||
setEditing(false);
|
||
if (next.home) setHome(next.home);
|
||
setHostHome(next.hostHome ?? null);
|
||
if (next.parent === null && crumbs(next.path).length > 1) setFloor(next.path);
|
||
if (push) {
|
||
setHistory((h) => {
|
||
if (h.stack[h.at] === next.path) return h;
|
||
const stack = [...h.stack.slice(0, h.at + 1), next.path];
|
||
return { stack, at: stack.length - 1 };
|
||
});
|
||
}
|
||
return true;
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
return false;
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!open) return;
|
||
setHistory({ stack: [], at: -1 });
|
||
setCreating(false);
|
||
setOnHost(false);
|
||
void load(initialPath, { host: false });
|
||
// Only re-run when the modal actually opens - not on every initialPath
|
||
// keystroke in the field behind it.
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open]);
|
||
|
||
// The shared dialog contract: focus in, Tab trapped, Escape closes (only
|
||
// this one when it sits over the Add-workspace modal), focus restored.
|
||
const panel = useRef<HTMLDivElement>(null);
|
||
useDialog(open, panel, onClose);
|
||
|
||
const entries = useMemo(() => {
|
||
const needle = filter.trim().toLowerCase();
|
||
const list = (listing?.entries ?? []).filter(
|
||
(e) => !needle || e.name.toLowerCase().includes(needle)
|
||
);
|
||
const by = (a: Entry, b: Entry) =>
|
||
sort.key === "mtime"
|
||
? ((a.mtime ?? 0) - (b.mtime ?? 0)) * sort.dir
|
||
: a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: "base" }) *
|
||
sort.dir;
|
||
// Folders first, as every file picker lists them.
|
||
return [...list].sort((a, b) => Number(!!a.file) - Number(!!b.file) || by(a, b));
|
||
}, [listing, filter, sort]);
|
||
|
||
const places = useMemo(() => {
|
||
const seen = new Set<string>();
|
||
return lanes
|
||
.filter((l) => l.cwd && !seen.has(l.cwd) && seen.add(l.cwd))
|
||
.map((l) => ({ label: l.title || baseName(l.cwd), path: l.cwd }));
|
||
}, [lanes]);
|
||
|
||
// A deep path keeps its LAST levels in view, like a native path bar.
|
||
useEffect(() => {
|
||
const el = crumbRef.current;
|
||
if (el) el.scrollLeft = el.scrollWidth;
|
||
}, [listing?.path, editing]);
|
||
|
||
useEffect(() => {
|
||
if (!selected) return;
|
||
const el = Array.from(listRef.current?.querySelectorAll<HTMLElement>("[data-path]") ?? []).find(
|
||
(x) => x.dataset.path === selected
|
||
);
|
||
el?.scrollIntoView?.({ block: "nearest" });
|
||
}, [selected]);
|
||
|
||
if (!open) return null;
|
||
|
||
const finish = (picked: string) => {
|
||
const folder = kind === "file" ? (listing?.path ?? picked) : picked;
|
||
const next = [folder, ...recent.filter((r) => r !== folder)].slice(0, RECENT_MAX);
|
||
setRecent(next);
|
||
writeStored(RECENT_KEY, next);
|
||
onSelect(picked);
|
||
onClose();
|
||
};
|
||
|
||
const activate = (entry: Entry) => {
|
||
if (entry.file) finish(entry.path);
|
||
else void load(entry.path);
|
||
};
|
||
|
||
const go = (delta: -1 | 1) => {
|
||
const at = history.at + delta;
|
||
const target = history.stack[at];
|
||
if (target === undefined) return;
|
||
void load(target, { push: false }).then((ok) => {
|
||
if (ok) setHistory((h) => ({ ...h, at }));
|
||
});
|
||
};
|
||
|
||
const goUp = () => {
|
||
if (!listing?.parent) return;
|
||
const from = listing.path;
|
||
void load(listing.parent, { select: from });
|
||
};
|
||
|
||
const selectedEntry = entries.find((e) => e.path === selected) ?? null;
|
||
const target =
|
||
kind === "file"
|
||
? selectedEntry?.file
|
||
? selectedEntry.path
|
||
: null
|
||
: selectedEntry && !selectedEntry.file
|
||
? selectedEntry.path
|
||
: (listing?.path ?? null);
|
||
|
||
const onListKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
const idx = entries.findIndex((x) => x.path === selected);
|
||
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
|
||
e.preventDefault();
|
||
if (e.altKey && e.key === "ArrowUp") return goUp();
|
||
const next =
|
||
e.key === "ArrowDown" ? Math.min(entries.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||
if (entries[next]) setSelected(entries[next].path);
|
||
return;
|
||
}
|
||
if (e.key === "Home" || e.key === "End") {
|
||
e.preventDefault();
|
||
const pick = e.key === "Home" ? entries[0] : entries[entries.length - 1];
|
||
if (pick) setSelected(pick.path);
|
||
return;
|
||
}
|
||
if (e.key === "Enter") {
|
||
e.preventDefault();
|
||
if (selectedEntry) activate(selectedEntry);
|
||
else if (kind === "dir" && target) finish(target);
|
||
return;
|
||
}
|
||
if (e.key === "Backspace") {
|
||
e.preventDefault();
|
||
goUp();
|
||
return;
|
||
}
|
||
if (e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||
// Typing jumps to the first name that starts with what was typed.
|
||
const now = Date.now();
|
||
const ta = typeAhead.current;
|
||
ta.text = now - ta.at > 800 ? e.key.toLowerCase() : ta.text + e.key.toLowerCase();
|
||
ta.at = now;
|
||
const hit = entries.find((x) => x.name.toLowerCase().startsWith(ta.text));
|
||
if (hit) setSelected(hit.path);
|
||
}
|
||
};
|
||
|
||
const onPanelKey = (e: KeyboardEvent<HTMLDivElement>) => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l") {
|
||
e.preventDefault();
|
||
setPathText(listing?.path ?? "");
|
||
setEditing(true);
|
||
} else if (e.altKey && e.key === "ArrowLeft") {
|
||
e.preventDefault();
|
||
go(-1);
|
||
} else if (e.altKey && e.key === "ArrowRight") {
|
||
e.preventDefault();
|
||
go(1);
|
||
}
|
||
};
|
||
|
||
const createFolder = async () => {
|
||
const name = newName.trim();
|
||
if (!name || !listing) return;
|
||
setError(null);
|
||
try {
|
||
const made = await api.lanes.mkdir(listing.path, name);
|
||
setCreating(false);
|
||
setNewName("");
|
||
await load(listing.path, { push: false, select: made.path });
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
|
||
// Switch between the admin's own home and the host account's, and open it.
|
||
const toggleHost = () => {
|
||
const next = !onHost;
|
||
setOnHost(next);
|
||
void load(undefined, { host: next });
|
||
};
|
||
|
||
const toggleHidden = () => {
|
||
const next = !showHidden;
|
||
setShowHidden(next);
|
||
writeStored(HIDDEN_KEY, next);
|
||
if (listing) void load(listing.path, { push: false, hidden: next, select: selected });
|
||
};
|
||
|
||
const iconBtn =
|
||
"flex h-7 w-7 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-2 disabled:opacity-40 disabled:hover:bg-transparent";
|
||
const placeBtn = (active: boolean) =>
|
||
`flex w-full items-center gap-2 rounded px-2 py-1 text-left text-xs ${
|
||
active ? "bg-surface-2 text-fg-primary" : "text-fg-secondary hover:bg-surface-2"
|
||
}`;
|
||
const path = listing?.path ?? "";
|
||
const floorDepth = floor ? crumbs(floor).length : 0;
|
||
|
||
return (
|
||
<div
|
||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4"
|
||
onClick={onClose}
|
||
role="presentation"
|
||
>
|
||
<div
|
||
ref={panel}
|
||
className="relative flex h-[min(80vh,640px)] w-full max-w-3xl flex-col rounded-xl border border-border bg-surface-1 shadow-xl shadow-black/40"
|
||
onClick={(e) => e.stopPropagation()}
|
||
onKeyDown={onPanelKey}
|
||
role="dialog"
|
||
aria-modal="true"
|
||
aria-label={kind === "file" ? t("browse.fileTitle") : t("browse.title")}
|
||
data-tour="folder-browse"
|
||
>
|
||
{/* Toolbar: history, up, and the breadcrumb / path field. */}
|
||
<div className="flex items-center gap-1 border-b border-border p-2">
|
||
<button
|
||
type="button"
|
||
className={iconBtn}
|
||
onClick={() => go(-1)}
|
||
disabled={history.at <= 0}
|
||
title={t("browse.back")}
|
||
aria-label={t("browse.back")}
|
||
>
|
||
<ArrowLeft className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={iconBtn}
|
||
onClick={() => go(1)}
|
||
disabled={history.at >= history.stack.length - 1}
|
||
title={t("browse.forward")}
|
||
aria-label={t("browse.forward")}
|
||
>
|
||
<ArrowRight className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={iconBtn}
|
||
onClick={goUp}
|
||
disabled={!listing?.parent}
|
||
title={t("browse.up")}
|
||
aria-label={t("browse.up")}
|
||
>
|
||
<ArrowUp className="h-4 w-4" />
|
||
</button>
|
||
{editing ? (
|
||
<input
|
||
autoFocus
|
||
value={pathText}
|
||
onChange={(e) => setPathText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (isSubmitEnter(e)) {
|
||
e.preventDefault();
|
||
if (pathText.trim()) void load(pathText.trim());
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
setEditing(false);
|
||
}
|
||
}}
|
||
onBlur={() => setEditing(false)}
|
||
aria-label={t("browse.editPath")}
|
||
spellCheck={false}
|
||
className="input min-w-0 flex-1 font-mono text-xs"
|
||
/>
|
||
) : (
|
||
<div
|
||
ref={crumbRef}
|
||
className="flex min-w-0 flex-1 items-center overflow-x-auto rounded-md border border-border-light px-1 py-0.5"
|
||
onDoubleClick={() => {
|
||
setPathText(path);
|
||
setEditing(true);
|
||
}}
|
||
>
|
||
{crumbs(path).map(([label, p], i, all) => {
|
||
const last = i === all.length - 1;
|
||
const locked = i + 1 < floorDepth;
|
||
return (
|
||
<span key={p} className="flex shrink-0 items-center">
|
||
{i > 0 && all[i - 1]?.[0] !== "/" && (
|
||
<span className="px-0.5 text-fg-muted">›</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
disabled={last || locked}
|
||
onClick={() => void load(p, { select: all[i + 1]?.[1] ?? null })}
|
||
className={`rounded px-1 py-0.5 font-mono text-xs ${
|
||
last
|
||
? "font-medium text-fg-primary"
|
||
: locked
|
||
? "text-fg-muted"
|
||
: "text-fg-secondary hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
{label}
|
||
</button>
|
||
</span>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={iconBtn}
|
||
onMouseDown={(e) => e.preventDefault()}
|
||
onClick={() => {
|
||
setPathText(path);
|
||
setEditing((v) => !v);
|
||
}}
|
||
title={t("browse.editPath")}
|
||
aria-label={t("browse.editPath")}
|
||
>
|
||
<Pencil className="h-3.5 w-3.5" />
|
||
</button>
|
||
</div>
|
||
|
||
<div className="flex min-h-0 flex-1">
|
||
{/* Places */}
|
||
<nav
|
||
className="hidden w-44 shrink-0 overflow-y-auto border-r border-border p-2 sm:block"
|
||
aria-label={t("browse.places")}
|
||
>
|
||
{home && (
|
||
<button
|
||
type="button"
|
||
className={placeBtn(path === home)}
|
||
onClick={() => void load(home)}
|
||
title={home}
|
||
>
|
||
<Home className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">{t("browse.home")}</span>
|
||
</button>
|
||
)}
|
||
{places.length > 0 && (
|
||
<>
|
||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||
<Star className="h-3 w-3" />
|
||
{t("browse.workspaces")}
|
||
</div>
|
||
{places.map((pl) => (
|
||
<button
|
||
key={pl.path}
|
||
type="button"
|
||
className={placeBtn(path === pl.path)}
|
||
onClick={() => void load(pl.path)}
|
||
title={pl.path}
|
||
>
|
||
<FolderGit2 className="h-3.5 w-3.5 shrink-0 text-accent" />
|
||
<span className="truncate">{pl.label}</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
{recent.length > 0 && (
|
||
<>
|
||
<div className="mt-3 flex items-center gap-1 px-2 pb-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||
<Clock className="h-3 w-3" />
|
||
{t("browse.recent")}
|
||
</div>
|
||
{recent.map((r) => (
|
||
<button
|
||
key={r}
|
||
type="button"
|
||
className={placeBtn(path === r)}
|
||
onClick={() => void load(r)}
|
||
title={r}
|
||
>
|
||
<FolderOpen className="h-3.5 w-3.5 shrink-0" />
|
||
<span className="truncate">{baseName(r)}</span>
|
||
</button>
|
||
))}
|
||
</>
|
||
)}
|
||
</nav>
|
||
|
||
{/* Listing */}
|
||
<div className="flex min-w-0 flex-1 flex-col">
|
||
<div className="flex items-center gap-2 border-b border-border p-2">
|
||
<input
|
||
value={filter}
|
||
onChange={(e) => setFilter(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "ArrowDown") {
|
||
e.preventDefault();
|
||
if (entries[0]) setSelected(entries[0].path);
|
||
listRef.current?.focus();
|
||
} else if (isSubmitEnter(e) && entries.length === 1 && entries[0]) {
|
||
e.preventDefault();
|
||
activate(entries[0]);
|
||
}
|
||
}}
|
||
placeholder={t("browse.filter")}
|
||
aria-label={t("browse.filter")}
|
||
className="input min-w-0 flex-1 text-xs"
|
||
/>
|
||
</div>
|
||
<div className="flex items-center border-b border-border px-3 py-1 text-[10px] font-medium uppercase tracking-wide text-fg-muted">
|
||
{(["name", "mtime"] as const).map((key) => (
|
||
<button
|
||
key={key}
|
||
type="button"
|
||
onClick={() =>
|
||
setSort((s) => ({ key, dir: s.key === key ? (s.dir === 1 ? -1 : 1) : 1 }))
|
||
}
|
||
className={`${key === "name" ? "flex-1 text-left" : "hidden w-32 text-right sm:block"} hover:text-fg-secondary`}
|
||
>
|
||
{key === "name" ? t("browse.name") : t("browse.modified")}
|
||
{sort.key === key && (sort.dir === 1 ? " ↑" : " ↓")}
|
||
</button>
|
||
))}
|
||
</div>
|
||
<div
|
||
ref={listRef}
|
||
tabIndex={0}
|
||
role="listbox"
|
||
aria-label={path}
|
||
onKeyDown={onListKey}
|
||
className="min-h-0 flex-1 overflow-y-auto p-1 outline-none"
|
||
>
|
||
{error && (
|
||
<p role="alert" className="p-2 text-xs text-status-danger">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{creating && (
|
||
<div className="flex items-center gap-2 px-2 py-1">
|
||
<FolderPlus className="h-3.5 w-3.5 shrink-0 text-fg-muted" />
|
||
<input
|
||
autoFocus
|
||
value={newName}
|
||
onChange={(e) => setNewName(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
e.stopPropagation();
|
||
if (isSubmitEnter(e)) {
|
||
e.preventDefault();
|
||
void createFolder();
|
||
} else if (e.key === "Escape") {
|
||
e.preventDefault();
|
||
setCreating(false);
|
||
}
|
||
}}
|
||
placeholder={t("browse.newFolderName")}
|
||
aria-label={t("browse.newFolderName")}
|
||
className="input min-w-0 flex-1 text-xs"
|
||
/>
|
||
<button
|
||
type="button"
|
||
onClick={() => void createFolder()}
|
||
disabled={!newName.trim()}
|
||
className="btn-primary text-xs disabled:opacity-50"
|
||
>
|
||
{t("browse.create")}
|
||
</button>
|
||
</div>
|
||
)}
|
||
{entries.map((entry) => {
|
||
const isSel = entry.path === selected;
|
||
return (
|
||
<div
|
||
key={entry.path}
|
||
data-path={entry.path}
|
||
role="option"
|
||
aria-selected={isSel}
|
||
onClick={() => setSelected(entry.path)}
|
||
onDoubleClick={() => activate(entry)}
|
||
className={`flex cursor-default select-none items-center gap-2 rounded px-2 py-1.5 text-xs ${
|
||
isSel ? "bg-accent-solid text-white" : "text-fg-primary hover:bg-surface-2"
|
||
}`}
|
||
>
|
||
{entry.file ? (
|
||
<File className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`} />
|
||
) : entry.isGitRepo ? (
|
||
<FolderGit2
|
||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-accent"}`}
|
||
/>
|
||
) : (
|
||
<FolderOpen
|
||
className={`h-3.5 w-3.5 shrink-0 ${isSel ? "" : "text-fg-muted"}`}
|
||
/>
|
||
)}
|
||
<span className="min-w-0 flex-1 truncate">{entry.name}</span>
|
||
<span
|
||
className={`hidden w-32 shrink-0 text-right tabular-nums sm:block ${isSel ? "" : "text-fg-muted"}`}
|
||
>
|
||
{entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
{listing && entries.length === 0 && (
|
||
<p className="p-2 text-xs text-fg-muted">
|
||
{filter.trim() ? t("browse.noMatch") : t("browse.empty")}
|
||
</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Footer: what will be picked, and the actions. */}
|
||
<div className="border-t border-border p-2">
|
||
<div
|
||
// rtl so a long path loses its START to the ellipsis, not the folder.
|
||
className="truncate px-1 pb-2 text-left font-mono text-xs text-fg-secondary [direction:rtl]"
|
||
title={target ?? undefined}
|
||
>
|
||
<bdi>{target ?? "…"}</bdi>
|
||
</div>
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setNewName("");
|
||
setCreating(true);
|
||
}}
|
||
disabled={!listing}
|
||
className="btn-ghost flex items-center gap-1 border border-border text-xs disabled:opacity-50"
|
||
>
|
||
<FolderPlus className="h-3.5 w-3.5" />
|
||
{t("browse.newFolder")}
|
||
</button>
|
||
<label className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary">
|
||
<input type="checkbox" checked={showHidden} onChange={toggleHidden} />
|
||
<Eye className="h-3.5 w-3.5" />
|
||
{t("browse.showHidden")}
|
||
</label>
|
||
{hostHome && (
|
||
<label
|
||
className="flex cursor-pointer items-center gap-1 text-xs text-fg-secondary"
|
||
title={t("browse.hostFolderHint", { path: hostHome })}
|
||
>
|
||
<input
|
||
type="checkbox"
|
||
checked={onHost}
|
||
onChange={toggleHost}
|
||
data-testid="browse-host-folder"
|
||
/>
|
||
<Server className="h-3.5 w-3.5" />
|
||
{t("browse.hostFolder")}
|
||
</label>
|
||
)}
|
||
<span className="hidden flex-1 truncate text-[10px] text-fg-muted md:block">
|
||
{t("browse.hint")}
|
||
</span>
|
||
<div className="ml-auto flex items-center gap-2">
|
||
<button
|
||
type="button"
|
||
onClick={onClose}
|
||
className="btn-ghost border border-border text-xs"
|
||
>
|
||
{t("destructive.cancel")}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
disabled={!target}
|
||
onClick={() => target && finish(target)}
|
||
className="btn-primary text-xs disabled:opacity-50"
|
||
>
|
||
{kind === "file" ? t("browse.selectFile") : t("browse.select")}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|