/** * @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ĩ */ 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>; type Entry = Listing["entries"][number]; const RECENT_KEY = "agenthub.browse.recent"; const HIDDEN_KEY = "agenthub.browse.hidden"; const RECENT_MAX = 8; function readStored(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 ? : 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(null); const [error, setError] = useState(null); const [history, setHistory] = useState<{ stack: string[]; at: number }>({ stack: [], at: -1 }); const [selected, setSelected] = useState(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(() => 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(null); const [home, setHome] = useState(null); // An admin's "Server folder" tick (S3): browse the host account's home. const [hostHome, setHostHome] = useState(null); const [onHost, setOnHost] = useState(false); const listRef = useRef(null); const crumbRef = useRef(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(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(); 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("[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) => { 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) => { 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 (
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. */}
{editing ? ( 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" /> ) : (
{ setPathText(path); setEditing(true); }} > {crumbs(path).map(([label, p], i, all) => { const last = i === all.length - 1; const locked = i + 1 < floorDepth; return ( {i > 0 && all[i - 1]?.[0] !== "/" && ( › )} ); })}
)}
{/* Places */} {/* Listing */}
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" />
{(["name", "mtime"] as const).map((key) => ( ))}
{error && (

{error}

)} {creating && (
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" />
)} {entries.map((entry) => { const isSel = entry.path === selected; return (
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 ? ( ) : entry.isGitRepo ? ( ) : ( )} {entry.name} {entry.mtime ? formatDateTime(new Date(entry.mtime).toISOString()) : ""}
); })} {listing && entries.length === 0 && (

{filter.trim() ? t("browse.noMatch") : t("browse.empty")}

)}
{/* Footer: what will be picked, and the actions. */}
{target ?? "…"}
{hostHome && ( )} {t("browse.hint")}
); }