import { ChevronRightIcon, FileIcon, FilePlusIcon, FolderIcon, FolderPlusIcon, PencilIcon, RefreshCwIcon, TriangleAlertIcon, Trash2Icon, } from "lucide-react"; import { type KeyboardEvent as ReactKeyboardEvent, type MouseEvent as ReactMouseEvent, type ReactNode, useCallback, useEffect, useId, useLayoutEffect, useMemo, useRef, useState, } from "react"; import { AlertDialog } from "radix-ui"; import { usePortalContainer } from "../ui/portal-container"; import { VList, type VListHandle } from "virtua"; import { cn } from "../lib/cn"; import { MENU_BUTTON_CLASS, MENU_SEPARATOR_CLASS, MENU_SURFACE_CLASS } from "../lib/menu-styles"; import { type PortalTokenStyle, usePortalTokenSource, usePortalTokenStyle, } from "../lib/use-portal-token-style"; import { useUnicodeFallbackFonts } from "../lib/use-unicode-fonts"; import type { FileTreeNode, UseSandboxFilesResult } from "../hooks/use-sandbox-files"; import { visibleFileTree, type FileNodeVisibilityPredicate } from "../file-node-visibility"; import { useOpenGeniText } from "../messages/runtime"; export type FileBrowserProps = { /** From `useSandboxFiles(...)`. */ result: UseSandboxFilesResult; /** * Rendered instead of the built-in tree when the file surface is unavailable * (e.g. a `FileSystem.available === false` capability). Default: a quiet notice. */ fallback?: ReactNode | undefined; /** Selection callback for the preview pane. */ onSelectFile?: ((path: string) => void) | undefined; selectedPath?: string | undefined; /** Presentation-only node filter. Hidden parents never expose their children. */ isNodeVisible?: FileNodeVisibilityPredicate | undefined; /** Deliberately reveal this selected path by lazily opening every loaded * ancestor and scrolling its row into view. The path is opaque and is never * normalized or re-rooted here. */ revealPath?: string | undefined; /** Increment/change to reveal the same path again after a user collapses it. */ revealPathRequestId?: string | number | undefined; /** Render-prop to theme/replace a row entirely (the Pierre-swap escape hatch). */ renderNode?: ((node: FileTreeNode, depth: number, expanded: boolean) => ReactNode) | undefined; /** Shown when the tree is empty (no files / not loaded yet). */ emptyState?: ReactNode | undefined; /** * Enable the file-manager affordances (toolbar, context menu, drag-drop move, * inline rename, delete, new file/folder). Defaults to `true` when the hook * exposes the mutation methods; pass `false` for a strictly read-only tree. */ editable?: boolean | undefined; /** * Confirm a (recursive) delete before it runs. Return `false` to cancel. * The default is an accessible, non-blocking confirmation dialog. Supply this * callback to delegate confirmation to a host-owned dialog. */ confirmDelete?: ((node: FileTreeNode) => boolean | Promise) | undefined; className?: string | undefined; }; const STATUS_TINT: Record, string> = { added: "text-og-status-idle", modified: "text-og-status-running", deleted: "text-og-status-failed line-through", renamed: "text-og-accent", untracked: "text-og-fg-subtle", }; const useBrowserLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect; /** Parent dir of a workspace-relative POSIX path ("" for a root entry). */ function parentOf(path: string): string { const i = path.lastIndexOf("/"); return i <= 0 ? "" : path.slice(0, i); } /** Join a parent dir with a leaf name (handles the root "" parent). */ function joinPath(parent: string, name: string): string { return parent ? `${parent}/${name}` : name; } /** A stable React key for a flat render item. */ function itemKey(item: RenderItem): string { switch (item.type) { case "node": return item.node.path; case "create": return `create:${item.parent}`; case "skeleton": return `skeleton:${item.path}`; case "residue": return `residue:${item.path}`; } } function pathIsInsideDirectory(path: string, directory: string): boolean { if (!path.startsWith(directory) || path === directory) return false; if (directory.endsWith("/") || directory.endsWith("\\")) return true; const boundary = path[directory.length]; return boundary === "/" || boundary === "\\"; } /** Walk the authoritative tree without assuming its target-owned separator. */ function findNode(nodes: FileTreeNode[], path: string): FileTreeNode | undefined { for (const node of nodes) { if (node.path === path) return node; if (node.kind === "dir" && node.children && pathIsInsideDirectory(path, node.path)) { const hit = findNode(node.children, path); if (hit) return hit; } } return undefined; } type RevealTrace = { directories: string[]; frontier: string | null; }; /** Follow only paths already named by the authoritative tree. This deliberately * avoids manufacturing ancestors from a platform-specific path string. */ function traceRevealPath(nodes: FileTreeNode[], targetPath: string): RevealTrace { for (const node of nodes) { if (node.path === targetPath) { return { directories: node.kind === "dir" ? [node.path] : [], frontier: node.kind === "dir" && node.children === undefined ? node.path : null, }; } if (node.kind !== "dir" || !pathIsInsideDirectory(targetPath, node.path)) continue; if (node.children === undefined) { return { directories: [node.path], frontier: node.path }; } const child = traceRevealPath(node.children, targetPath); return { directories: [node.path, ...child.directories], frontier: child.frontier, }; } return { directories: [], frontier: null }; } /** A pending inline-create: a phantom input row under `parent`. */ type DraftCreate = { parent: string; kind: "file" | "dir" }; /** A pending inline-rename of an existing node. */ type DraftRename = { path: string }; type ContextMenuState = { node: FileTreeNode | null; x: number; y: number }; /** One virtualized row. Nodes plus the synthetic rows the recursive renderer * used to nest inline. */ type RenderItem = | { type: "node"; node: FileTreeNode; depth: number } | { type: "create"; parent: string; kind: "file" | "dir"; depth: number } | { type: "skeleton"; path: string; depth: number } | { type: "residue"; path: string; depth: number }; /** * The file MANAGER, fed by the FileSystem service via `useSandboxFiles`. This is * a first-class editable tree (not a render-only view): lazy-expand with a * spinner on the in-flight node, git-status tinting, full keyboard navigation, * selection, and the mutating affordances wired straight to the hook — * * • drag-and-drop MOVE → `moveEntry(from, to)` * • inline RENAME → `moveEntry(path, newPath)` (F2 / double-click / menu) * • DELETE → `deleteEntry(path, recursive)` (Del / menu, confirmed) * • NEW FILE / FOLDER → `createFile` / `createDir` (toolbar + menu), then open * • right-click CONTEXT MENU * * `renderNode` is still honoured as the Pierre-swap escape hatch for the row * chrome; the manager scaffolding (toolbar, dnd, menu, inline inputs) wraps it. */ export function FileBrowser({ result, fallback, onSelectFile, selectedPath, isNodeVisible, revealPath, revealPathRequestId, renderNode, emptyState, editable = true, confirmDelete, className, }: FileBrowserProps) { const t = useOpenGeniText(); const [expanded, setExpanded] = useState>(new Set()); const [loadingPaths, setLoadingPaths] = useState>(new Set()); const [active, setActive] = useState(null); const [draftCreate, setDraftCreate] = useState(null); const [draftRename, setDraftRename] = useState(null); const [dragOver, setDragOver] = useState(null); const [menu, setMenu] = useState(null); const [pendingDelete, setPendingDelete] = useState(null); const [busy, setBusy] = useState(false); const [coarsePointer, setCoarsePointer] = useState(false); const [revealRequest, setRevealRequest] = useState<{ path: string; requestId: string | number; } | null>(null); const handledRevealRequestRef = useRef<{ path: string; requestId: string | number; } | null>(null); const attemptedRevealPathsRef = useRef>(new Set()); const treeId = useId(); const container = usePortalTokenSource(); const containerRef = container.currentRef; const deleteReturnFocusRef = useRef(null); const vlistRef = useRef(null); const portalTokenStyle = usePortalTokenStyle(container.source); const getTreeElement = useCallback( () => (typeof document === "undefined" ? null : document.getElementById(treeId)), [treeId], ); const visibleTree = useMemo( () => visibleFileTree(result.tree, isNodeVisible), [isNodeVisible, result.tree], ); useBrowserLayoutEffect(() => { if (typeof window === "undefined" || typeof window.matchMedia !== "function") return; const query = window.matchMedia("(pointer: coarse)"); const update = () => setCoarsePointer(query.matches); update(); if (typeof query.addEventListener === "function") { query.addEventListener("change", update); return () => query.removeEventListener("change", update); } if (typeof query.addListener === "function") { query.addListener(update); return () => query.removeListener(update); } }, []); const effectiveRevealRequestId = revealPath ? (revealPathRequestId ?? revealPath) : null; useEffect(() => { if (!revealPath || effectiveRevealRequestId === null) { setRevealRequest(null); return; } const handled = handledRevealRequestRef.current; if (handled?.path === revealPath && handled.requestId === effectiveRevealRequestId) return; handledRevealRequestRef.current = { path: revealPath, requestId: effectiveRevealRequestId, }; attemptedRevealPathsRef.current = new Set(); setActive(null); setRevealRequest({ path: revealPath, requestId: effectiveRevealRequestId }); }, [effectiveRevealRequestId, revealPath]); // The keyboard cursor: an explicitly-navigated node falls back to the // externally-selected file so arrow keys pick up where the preview pane is. const visibleActive = active && findNode(visibleTree, active) ? active : null; const cursor = visibleActive ?? (selectedPath && findNode(visibleTree, selectedPath) ? selectedPath : null); useEffect(() => { if (active !== null && visibleActive === null) setActive(null); }, [active, visibleActive]); const expand = useCallback( async (path: string) => { const node = findNode(visibleTree, path); if (node && node.kind === "dir" && node.children === undefined) { setLoadingPaths((prev) => new Set(prev).add(path)); try { await result.expand(path); } finally { setLoadingPaths((prev) => { const next = new Set(prev); next.delete(path); return next; }); } } }, [result, visibleTree], ); const open = useCallback( (path: string) => { setExpanded((prev) => new Set(prev).add(path)); void expand(path); }, [expand], ); useEffect(() => { if (!revealRequest) return; const trace = traceRevealPath(visibleTree, revealRequest.path); if (trace.directories.length > 0) { setExpanded((previous) => { const next = new Set(previous); let changed = false; for (const path of trace.directories) { if (next.has(path)) continue; next.add(path); changed = true; } return changed ? next : previous; }); } const frontier = trace.frontier; if ( !frontier || attemptedRevealPathsRef.current.has(frontier) || loadingPaths.has(frontier) || result.expandingPaths.has(frontier) ) { return; } attemptedRevealPathsRef.current.add(frontier); void expand(frontier); }, [expand, loadingPaths, result.expandingPaths, revealRequest, visibleTree]); const toggle = useCallback( async (node: FileTreeNode) => { if (node.kind !== "dir") return; const isOpen = expanded.has(node.path); setExpanded((prev) => { const next = new Set(prev); if (isOpen) next.delete(node.path); else next.add(node.path); return next; }); if (!isOpen) await expand(node.path); }, [expanded, expand], ); // Flatten the VISIBLE (expanded) tree into a single ordered render list — the // input to the virtualizer. Interleaves the synthetic rows the recursive // renderer used to nest (inline create input, lazy-expand skeleton, and the // cold residue "contents on machine" row) so every visual row is one list item. const cold = result.source !== "live"; const renderItems = useMemo(() => { const items: RenderItem[] = []; if (draftCreate && draftCreate.parent === "") { items.push({ type: "create", parent: "", kind: draftCreate.kind, depth: 0 }); } const walk = (nodes: FileTreeNode[], depth: number) => { for (const node of nodes) { items.push({ type: "node", node, depth }); if (node.kind !== "dir" || !expanded.has(node.path)) continue; if (draftCreate && draftCreate.parent === node.path) { items.push({ type: "create", parent: node.path, kind: draftCreate.kind, depth: depth + 1, }); } const childless = node.children === undefined || node.children.length === 0; const isLoading = loadingPaths.has(node.path) || result.expandingPaths.has(node.path); // A collapsed residue dir (node_modules, .git, …) can't list children from // the cold capture — offer to open it live rather than a dead skeleton. if (node.truncated && childless && cold && !isLoading) { items.push({ type: "residue", path: node.path, depth: depth + 1 }); } else if (node.children && node.children.length > 0) { walk(node.children, depth + 1); } else if (isLoading && childless) { items.push({ type: "skeleton", path: node.path, depth: depth + 1 }); } } }; walk(visibleTree, 0); return items; }, [visibleTree, expanded, draftCreate, loadingPaths, result.expandingPaths, cold]); const unicodeFontPaths = useMemo( () => renderItems.flatMap((item) => (item.type === "node" ? [item.node.path] : [])), [renderItems], ); useUnicodeFallbackFonts(unicodeFontPaths); // The visible NODE rows in order — drives keyboard up/down navigation. const flatRows = useMemo( () => renderItems.flatMap((item) => item.type === "node" ? [{ node: item.node, depth: item.depth }] : [], ), [renderItems], ); const rowIndexByPath = useMemo( () => new Map(flatRows.map((row, index) => [row.node.path, index] as const)), [flatRows], ); const treePositionByPath = useMemo(() => { const positions = new Map(); const visit = (nodes: FileTreeNode[]) => { nodes.forEach((node, index) => { positions.set(node.path, { position: index + 1, setSize: nodes.length }); if (node.children) visit(node.children); }); }; visit(visibleTree); return positions; }, [visibleTree]); const cursorIndex = cursor ? (rowIndexByPath.get(cursor) ?? -1) : -1; const activeDescendantId = cursorIndex >= 0 ? `${treeId}-item-${cursorIndex}` : undefined; // A capture is immutable review data even if a stale capability document still // says the machine is writable. Mutations become available only after a live // reconciliation has actually succeeded. const supportsMutation = editable && result.source === "live" && result.error === null && !result.loading; const performDelete = useCallback( async (node: FileTreeNode) => { if (!supportsMutation) return; const recursive = node.kind === "dir"; setBusy(true); try { await result.deleteEntry(node.path, recursive); } catch { // The hook surfaces the error on `result.error`; the panel renders it. } finally { setBusy(false); } }, [supportsMutation, result], ); const runDelete = useCallback( async (node: FileTreeNode, returnFocus?: HTMLElement) => { if (!supportsMutation) return; setMenu(null); if (!confirmDelete) { deleteReturnFocusRef.current = returnFocus ?? (typeof document !== "undefined" && document.activeElement instanceof HTMLElement ? document.activeElement : null); setPendingDelete(node); return; } if (await confirmDelete(node)) await performDelete(node); }, [supportsMutation, confirmDelete, performDelete], ); const closePendingDelete = useCallback( (restoreTrigger: boolean) => { const returnTarget = restoreTrigger ? deleteReturnFocusRef.current : getTreeElement(); deleteReturnFocusRef.current = null; setPendingDelete(null); if (typeof window !== "undefined") { window.requestAnimationFrame(() => { const target = returnTarget?.isConnected ? returnTarget : getTreeElement(); target?.focus(); }); } }, [getTreeElement], ); const commitCreate = useCallback( async (name: string) => { const draft = draftCreate; setDraftCreate(null); if (!draft || !supportsMutation) return; const trimmed = name.trim().replace(/\/+$/, ""); if (!trimmed) return; const path = joinPath(draft.parent, trimmed); setBusy(true); try { if (draft.kind === "dir") { await result.createDir(path); open(path); } else { await result.createFile(path); // Open the freshly-created file in the preview/editor pane. setActive(path); onSelectFile?.(path); } } catch { /* error surfaced via result.error */ } finally { setBusy(false); } }, [draftCreate, supportsMutation, result, open, onSelectFile], ); const commitRename = useCallback( async (name: string) => { const draft = draftRename; setDraftRename(null); if (!draft || !supportsMutation) return; const node = findNode(visibleTree, draft.path); const trimmed = name.trim().replace(/\/+$/, ""); if (!node || !trimmed || trimmed === node.name) return; const newPath = joinPath(parentOf(draft.path), trimmed); setBusy(true); try { await result.moveEntry(draft.path, newPath); if (node.kind === "file" && (selectedPath === draft.path || active === draft.path)) { setActive(newPath); onSelectFile?.(newPath); } } catch { /* error surfaced via result.error */ } finally { setBusy(false); } }, [draftRename, supportsMutation, result, selectedPath, active, onSelectFile, visibleTree], ); // Begin a new file/folder under the active node's directory (or root). const startCreate = useCallback( (kind: "file" | "dir") => { if (!supportsMutation) return; const anchor = cursor ? findNode(visibleTree, cursor) : undefined; const parent = anchor ? (anchor.kind === "dir" ? anchor.path : parentOf(anchor.path)) : ""; if (parent) open(parent); setDraftRename(null); setMenu(null); setDraftCreate({ parent, kind }); }, [supportsMutation, cursor, open, visibleTree], ); const startRename = useCallback( (node: FileTreeNode) => { if (!supportsMutation) return; setDraftCreate(null); setMenu(null); setDraftRename({ path: node.path }); }, [supportsMutation], ); // Drag-drop move: drop a node onto a directory (or the root) → moveEntry. const onDropOnto = useCallback( async (targetDir: string, sourcePath: string) => { setDragOver(null); if (!supportsMutation || !sourcePath) return; const src = findNode(visibleTree, sourcePath); if (!src) return; // No-op drops: onto its own current parent, onto itself, or into its own subtree. if (parentOf(sourcePath) === targetDir) return; if (targetDir === sourcePath || targetDir.startsWith(`${sourcePath}/`)) return; const newPath = joinPath(targetDir, src.name); if (newPath === sourcePath) return; setBusy(true); try { await result.moveEntry(sourcePath, newPath); if (src.kind === "file" && (selectedPath === sourcePath || active === sourcePath)) { setActive(newPath); onSelectFile?.(newPath); } } catch { /* 409/collision etc. surfaced via result.error */ } finally { setBusy(false); } }, [supportsMutation, result, selectedPath, active, onSelectFile, visibleTree], ); const onKeyDown = useCallback( (e: ReactKeyboardEvent) => { if (draftCreate || draftRename) return; // inline input owns the keyboard if (flatRows.length === 0) return; const idx = flatRows.findIndex((r) => r.node.path === cursor); const move = (next: number) => { const clamped = Math.max(0, Math.min(flatRows.length - 1, next)); const row = flatRows[clamped]; if (row) setActive(row.node.path); e.preventDefault(); }; const cur = idx >= 0 ? flatRows[idx] : undefined; switch (e.key) { case "ArrowDown": move(idx < 0 ? 0 : idx + 1); break; case "ArrowUp": move(idx < 0 ? 0 : idx - 1); break; case "Home": move(0); break; case "End": move(flatRows.length - 1); break; case "PageDown": { const pageSize = Math.max( 1, Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1, ); move((idx < 0 ? 0 : idx) + pageSize); break; } case "PageUp": { const pageSize = Math.max( 1, Math.floor((containerRef.current?.clientHeight || 280) / (coarsePointer ? 44 : 28)) - 1, ); move((idx < 0 ? 0 : idx) - pageSize); break; } case "ArrowRight": if (cur?.node.kind === "dir") { if (!expanded.has(cur.node.path)) open(cur.node.path); else move(idx + 1); e.preventDefault(); } break; case "ArrowLeft": if (cur) { if (cur.node.kind === "dir" && expanded.has(cur.node.path)) { setExpanded((prev) => { const n = new Set(prev); n.delete(cur.node.path); return n; }); } else { const parent = parentOf(cur.node.path); if (parent) setActive(parent); } e.preventDefault(); } break; case "Enter": if (cur) { if (cur.node.kind === "dir") void toggle(cur.node); else onSelectFile?.(cur.node.path); e.preventDefault(); } break; case "ContextMenu": case "F10": if (cur && supportsMutation && (e.key === "ContextMenu" || e.shiftKey)) { const rowIndex = rowIndexByPath.get(cur.node.path) ?? 0; const row = document.getElementById(`${treeId}-item-${rowIndex}`); const rect = row?.getBoundingClientRect(); setMenu({ node: cur.node, x: rect ? rect.left + Math.min(32, rect.width / 2) : 8, y: rect ? rect.bottom : 8, }); e.preventDefault(); } else if (e.key === "F10") { break; } break; case "F2": if (cur) { startRename(cur.node); e.preventDefault(); } break; case "Delete": case "Backspace": if (cur && supportsMutation) { void runDelete(cur.node); e.preventDefault(); } break; } }, [ draftCreate, draftRename, flatRows, cursor, expanded, open, toggle, onSelectFile, startRename, runDelete, supportsMutation, coarsePointer, containerRef, rowIndexByPath, treeId, ], ); // Close the context menu on any outside interaction. useEffect(() => { if (!menu) return; const close = () => setMenu(null); window.addEventListener("click", close); window.addEventListener("scroll", close, true); window.addEventListener("resize", close); return () => { window.removeEventListener("click", close); window.removeEventListener("scroll", close, true); window.removeEventListener("resize", close); }; }, [menu]); const showErrorEmpty = result.error && visibleTree.length === 0 && !draftCreate; const showDegradedTree = result.error && visibleTree.length > 0; const showEmpty = !result.loading && visibleTree.length === 0 && !draftCreate; // Retry lives next to the state it explains (the degraded/empty notice), so do // not render a second toolbar retry for the same failure. const showToolbar = supportsMutation || result.loading; // Virtua caches the previous visible range when a lazy-expanded subtree is // inserted in the middle of a short list. The spacer grows but later root // siblings can remain unmounted until a scroll/resize (for example, // `.bash_history` vanished after expanding `repositories/`). Small // trees do not benefit from virtualization, so render them directly and keep // Virtua only for genuinely large workspaces. const usesVirtualTree = !showErrorEmpty && !showEmpty && renderItems.length > 500; // Render a SINGLE node row (no recursion — children are separate flat items the // virtualizer renders). Returns the row element; the caller assigns the key. const renderNodeRow = (node: FileTreeNode, depth: number): ReactNode => { const isOpen = expanded.has(node.path); if (renderNode) { return
{renderNode(node, depth, isOpen)}
; } const isDir = node.kind === "dir"; const isSelected = node.path === selectedPath || node.path === active; const isCursor = node.path === cursor; const isLoading = loadingPaths.has(node.path) || result.expandingPaths.has(node.path); const isRenaming = draftRename?.path === node.path; const isDropTarget = dragOver === (isDir ? node.path : parentOf(node.path)); const dropDir = isDir ? node.path : parentOf(node.path); const treePosition = treePositionByPath.get(node.path); return (
{ e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(dropDir); } : undefined } onDragLeave={ supportsMutation ? (e) => { e.stopPropagation(); setDragOver((cur) => (cur === dropDir ? null : cur)); } : undefined } onDrop={ supportsMutation ? (e) => { e.preventDefault(); e.stopPropagation(); const src = e.dataTransfer.getData("text/og-path"); void onDropOnto(dropDir, src); } : undefined } > {isRenaming ? ( void commitRename(name)} onCancel={() => setDraftRename(null)} /> ) : ( )}
); }; // Render one flat list item (node row or a synthetic create/skeleton/residue row). const renderItem = (item: RenderItem): ReactNode => { switch (item.type) { case "node": return renderNodeRow(item.node, item.depth); case "create": return ( void commitCreate(name)} onCancel={() => setDraftCreate(null)} /> ); case "skeleton": return (
{[0, 1, 2].map((i) => (
))}
); case "residue": return (
{t("fileBrowser.contentsOnMachineOpenWhen")}
); } }; // Keep the keyboard cursor's row on screen even though off-screen rows unmount. useEffect(() => { if (!cursor) return; const index = renderItems.findIndex((it) => it.type === "node" && it.node.path === cursor); if (index >= 0) vlistRef.current?.scrollToIndex(index); }, [cursor, renderItems]); useEffect(() => { if (!revealRequest) return; const index = renderItems.findIndex( (item) => item.type === "node" && item.node.path === revealRequest.path, ); if (index < 0) return; if (usesVirtualTree) { vlistRef.current?.scrollToIndex(index); } else { document.getElementById(`${treeId}-item-${index}`)?.scrollIntoView?.({ block: "nearest" }); } setRevealRequest((current) => current?.path === revealRequest.path && current.requestId === revealRequest.requestId ? null : current, ); }, [renderItems, revealRequest, treeId, usesVirtualTree]); return (
{showToolbar && (
{supportsMutation ? ( <> startCreate("file")} disabled={busy} > startCreate("dir")} disabled={busy} > { const node = cursor ? findNode(visibleTree, cursor) : undefined; if (node) startRename(node); }} disabled={busy || !cursor} > { const node = cursor ? findNode(visibleTree, cursor) : undefined; if (node) void runDelete(node, event.currentTarget); }} disabled={busy || !cursor} > ) : null} void result.refresh()} disabled={busy || result.loading} >
)} {showDegradedTree ? (
{result.source === "capture" ? t("fileBrowser.liveFilesAreTemporarilyUnavailable") : t("fileBrowser.liveRefreshFailedShowingThe")}
) : null} {/* The tree itself. The root is a drop target so a node can be moved to "". */} {/* biome-ignore lint/a11y/noNoninteractiveTabindex: the tree owns keyboard nav */}
{ if ((event.target as HTMLElement).id === treeId && cursorIndex < 0) { const first = flatRows[0]; if (first) setActive(first.node.path); } }} onKeyDown={onKeyDown} onDragOver={ supportsMutation ? (e) => { e.preventDefault(); e.dataTransfer.dropEffect = "move"; setDragOver(""); } : undefined } onDrop={ supportsMutation ? (e) => { e.preventDefault(); const src = e.dataTransfer.getData("text/og-path"); void onDropOnto("", src); } : undefined } className={cn( "flex min-h-0 min-w-0 flex-1 flex-col p-1", dragOver === "" && "ring-1 ring-inset ring-og-accent", )} data-opengeni-file-tree > {showErrorEmpty ? (
{fallback ?? t("fileBrowser.couldNotLoadFilesMessage", { message: result.error?.message ?? t("fileBrowser.refreshTheFileListTo"), })}
) : showEmpty ? (
{emptyState ?? t("fileBrowser.thisDirectoryIsEmpty")}
) : !usesVirtualTree ? (
{renderItems.map((item) => (
{renderItem(item)}
))}
) : ( // Virtualized: only the rows in/near the viewport mount, so a 3k-node // tree stays responsive. The flat item list already carries the // expanded subtree + synthetic (create/skeleton/residue) rows. {renderItems.map((item) => (
{renderItem(item)}
))}
)}
{menu && menu.node && ( menu.node && startRename(menu.node)} onDelete={() => menu.node && void runDelete(menu.node)} onNewFile={() => startCreate("file")} onNewFolder={() => startCreate("dir")} onClose={() => { setMenu(null); window.requestAnimationFrame(() => getTreeElement()?.focus()); }} /> )} {pendingDelete ? ( closePendingDelete(true)} onConfirm={() => { const node = pendingDelete; closePendingDelete(false); void performDelete(node); }} /> ) : null}
); } function DeleteDialog({ node, busy, portalTokenStyle, onCancel, onConfirm, }: { node: FileTreeNode; busy: boolean; portalTokenStyle: PortalTokenStyle; onCancel: () => void; onConfirm: () => void; }) { const t = useOpenGeniText(); const portalContainer = usePortalContainer(); const cancelRef = useRef(null); const [open, setOpen] = useState(true); const closeIntentRef = useRef<"cancel" | "confirm">("cancel"); const settledRef = useRef(false); const settleTimerRef = useRef | null>(null); const settle = () => { if (settledRef.current) return; settledRef.current = true; if (closeIntentRef.current === "confirm") { onConfirm(); } else { onCancel(); } }; const scheduleSettle = () => { if (settleTimerRef.current !== null) return; settleTimerRef.current = setTimeout(() => { settleTimerRef.current = null; settle(); }, 0); }; useEffect( () => () => { if (settleTimerRef.current !== null) clearTimeout(settleTimerRef.current); }, [], ); return ( { if (!nextOpen && !busy) { setOpen(false); scheduleSettle(); } }} > { event.preventDefault(); cancelRef.current?.focus(); }} onCloseAutoFocus={(event) => { event.preventDefault(); scheduleSettle(); }} onEscapeKeyDown={(event) => { event.stopPropagation(); if (busy) { event.preventDefault(); } else { closeIntentRef.current = "cancel"; } }} >
{t("fileBrowser.delete")}{" "} {node.kind === "dir" ? t("fileBrowser.folder") : t("fileBrowser.file")}? {node.path} {node.kind === "dir" ? " and everything inside it will be permanently removed." : " will be permanently removed."}
); } function ToolbarButton({ label, onClick, disabled, children, }: { label: string; onClick: (event: ReactMouseEvent) => void; disabled?: boolean; children: ReactNode; }) { return ( ); } /** An inline text input for create / rename, indented to match its row depth. */ function InlineInput({ depth, kind, initialValue, onCommit, onCancel, }: { depth: number; kind: "file" | "dir"; initialValue: string; onCommit: (value: string) => void; onCancel: () => void; }) { const t = useOpenGeniText(); const [value, setValue] = useState(initialValue); const ref = useRef(null); // Track whether we've already resolved so a blur after Enter/Escape is a no-op. const doneRef = useRef(false); useEffect(() => { const el = ref.current; if (!el) return; el.focus(); // Select the basename (excluding any extension) for a rename, like an IDE. const dot = initialValue.lastIndexOf("."); if (initialValue && dot > 0) el.setSelectionRange(0, dot); else el.select(); }, [initialValue]); const finish = (commit: boolean) => { if (doneRef.current) return; doneRef.current = true; if (commit) onCommit(value); else onCancel(); }; return (
{kind === "dir" ? ( ) : ( )} setValue(e.target.value)} onBlur={() => finish(true)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); finish(true); } else if (e.key === "Escape") { e.preventDefault(); finish(false); } e.stopPropagation(); }} className={cn( "min-w-0 flex-1 rounded-og-sm border bg-og-bg px-1 py-0 text-og-sm", "border-og-accent text-og-fg", "outline-hidden", )} />
); } /** The right-click / kebab context menu, positioned at the click point. */ function ContextMenu({ node, x, y, onRename, onDelete, onNewFile, onNewFolder, onClose, }: { node: FileTreeNode; x: number; y: number; onRename: () => void; onDelete: () => void; onNewFile: () => void; onNewFolder: () => void; onClose: () => void; }) { const t = useOpenGeniText(); const isDir = node.kind === "dir"; const menuRef = useRef(null); // Keep the menu on-screen when opened near the viewport edge. const vw = typeof window !== "undefined" ? window.innerWidth : 9999; const vh = typeof window !== "undefined" ? window.innerHeight : 9999; const left = Math.max(4, Math.min(x, vw - 180)); const top = Math.max(4, Math.min(y, vh - 160)); useEffect(() => { menuRef.current?.querySelector('[role="menuitem"]')?.focus(); }, []); const onKeyDown = (event: ReactKeyboardEvent) => { const items = Array.from( menuRef.current?.querySelectorAll('[role="menuitem"]') ?? [], ); if (items.length === 0) return; const current = items.findIndex((item) => item === document.activeElement); let next: number | null = null; if (event.key === "ArrowDown") next = (current + 1 + items.length) % items.length; else if (event.key === "ArrowUp") next = (current - 1 + items.length) % items.length; else if (event.key === "Home") next = 0; else if (event.key === "End") next = items.length - 1; else if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); onClose(); return; } if (next === null) return; event.preventDefault(); event.stopPropagation(); items[next]?.focus(); }; const item = (label: string, icon: ReactNode, onClick: () => void, danger?: boolean) => ( ); return (
e.stopPropagation()} style={{ left, top }} className={cn(MENU_SURFACE_CLASS, "fixed z-50 min-w-[180px] overflow-hidden")} > {isDir && ( <> {item(t("fileBrowser.newFile"), , onNewFile)} {item(t("fileBrowser.newFolder"), , onNewFolder)}
)} {item(t("fileBrowser.rename"), , onRename)} {item("Delete", , onDelete, true)}
); }