import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { FileEntry, Project } from "../../../lib/types"; import { useFileManager } from "../../../hooks/useFileManager"; import Button from "../../ui/Button"; import FileViewerModal from "./FileViewerModal"; import { formatBytes } from "./format"; interface Props { project: Project; } /** Key of the synthetic "go up one level" row. No listing ever contains `..`. */ const PARENT_ROW = ".."; /** * The project's file browser. * * Container-side only: it lists, opens, renames and creates folders inside the * container, and it does no host filesystem I/O at all. A file gets *into* a * container by being dropped onto the Terminal tab, and a whole tree comes back * out through "Back up container" in the project's Workspace settings. Four * successive audits found that host paths crossing IPC were where the criticals * lived; those two paths are the ones that survived, and this pane is not one * of them. * * Interaction model, chosen to match every desktop file manager rather than * the old half-and-half: **single click selects, double click opens**. That * moved directory navigation onto double click too — a single click used to * navigate, which made it impossible to select a directory in order to rename * it. Keyboard mirrors it exactly: Enter opens, F2 renames. * * ## Focus, and why it is a roving tabindex * * Every row used to be `tabIndex={0}`, which made a 400-entry directory about * twelve hundred tab stops — Tab could not get *out* of the list, let alone * past it — and rows are keyed by name, so navigating unmounted the focused * `` and dropped focus to ``: Enter on a directory ejected you from * the grid, arrows dead, Tab restarting from the top of the document. So * exactly one row carries `tabIndex={0}` (the *active* row), the arrows move * it, and a single effect below is responsible for putting focus back on a * sensible row after anything that re-renders the list. */ export default function FilesTab({ project }: Props) { const { currentPath, entries, loading, error, completed, navigate, goUp, refresh, renameEntry, createFolder, } = useFileManager(project.id); const running = project.status === "running"; /** The row the user has selected, by name — names are unique in a directory. */ const [selected, setSelected] = useState(null); const [renaming, setRenaming] = useState(null); const [renameDraft, setRenameDraft] = useState(""); const [creatingFolder, setCreatingFolder] = useState(false); const [folderDraft, setFolderDraft] = useState(""); const [viewing, setViewing] = useState(null); /** The row that owns the grid's single tab stop. */ const [activeRow, setActiveRow] = useState(null); const paneRef = useRef(null); const renameInputRef = useRef(null); const folderInputRef = useRef(null); useEffect(() => { if (running) navigate("/workspace"); // Re-list when the container comes up. }, [navigate, running]); // Leaving a directory invalidates every in-flight row interaction. useEffect(() => { setSelected(null); setRenaming(null); }, [currentPath]); useEffect(() => { if (renaming) { renameInputRef.current?.focus(); renameInputRef.current?.select(); } }, [renaming]); useEffect(() => { if (creatingFolder) folderInputRef.current?.focus(); }, [creatingFolder]); // --------------------------------------------------------------------------- // Roving tabindex // --------------------------------------------------------------------------- /** Every row's key, in visual order. The parent row is a row like any other. */ const rowKeys = useMemo( () => [ ...(currentPath !== "/" ? [PARENT_ROW] : []), ...entries.map((entry) => entry.name), ], [currentPath, entries], ); /** * The active row, resolved against what is actually on screen. Keeping the * *intent* in state and resolving it at render time means a rename or a * deletion cannot leave the grid with no tab stop at all. */ const active = activeRow && rowKeys.includes(activeRow) ? activeRow : rowKeys[0]; const rowElement = useCallback((key: string): HTMLElement | undefined => { // Matched on the dataset rather than a selector, because a file name is // user data and can contain quotes, brackets and backslashes. const rows = paneRef.current?.querySelectorAll("tr[data-file-row]") ?? []; return Array.from(rows).find((row) => row.dataset.fileRow === key); }, []); const focusRow = useCallback( (key: string) => { setActiveRow(key); rowElement(key)?.focus(); }, [rowElement], ); /** * Where focus should land the next time the grid re-renders, if it is loose. * `key` is a preference, not a promise — the row may not exist any more (a * rename that failed, a navigation into a different directory), in which case * the first row takes it. */ const wantFocus = useRef<{ key: string | null } | null>(null); /** * The single place that decides where focus goes after the list changes. * * Runs after a navigation (rows are keyed by name, so the focused `` is * gone), after a rename commits or is abandoned, and after Escape. It never * *steals* focus: if the user has moved on to a button or the breadcrumb it * drops the request instead, so a background re-list cannot yank the caret * out from under them. */ useEffect(() => { if (renaming !== null) return; // the rename input owns focus const want = wantFocus.current; if (!want) return; wantFocus.current = null; const focused = document.activeElement as HTMLElement | null; const loose = !focused || focused === document.body || focused === document.documentElement || !!focused.closest?.("tr[data-file-row]"); if (!loose) return; const key = want.key && rowKeys.includes(want.key) ? want.key : rowKeys[0]; if (key !== undefined) focusRow(key); }, [rowKeys, renaming, focusRow]); /** Arrow / Home / End movement over the rows. */ const moveActive = useCallback( (from: string, to: 1 | -1 | "first" | "last") => { if (rowKeys.length === 0) return; const i = rowKeys.indexOf(from); const next = to === "first" ? 0 : to === "last" ? rowKeys.length - 1 : Math.min(rowKeys.length - 1, Math.max(0, (i < 0 ? 0 : i) + to)); focusRow(rowKeys[next]); }, [rowKeys, focusRow], ); const startRename = useCallback((entry: FileEntry) => { setSelected(entry.name); setActiveRow(entry.name); setRenameDraft(entry.name); setRenaming(entry.name); // Whichever way the rename ends, focus comes back to this row unless the // commit renames it — `commitRename` overwrites the preference below. wantFocus.current = { key: entry.name }; }, []); const commitRename = useCallback( async (entry: FileEntry) => { const renamedTo = renameDraft.trim(); wantFocus.current = { key: renamedTo || entry.name }; const done = await renameEntry(entry, renameDraft); if (done) setRenaming(null); }, [renameEntry, renameDraft], ); const commitFolder = useCallback(async () => { const created = folderDraft.trim(); const done = await createFolder(folderDraft); if (done) { setCreatingFolder(false); setFolderDraft(""); wantFocus.current = { key: created || null }; } }, [createFolder, folderDraft]); /** Double click / Enter: directories navigate, files open the viewer. */ const openEntry = useCallback( (entry: FileEntry) => { if (entry.is_directory) { // The new listing's first row is `..`, which is the sensible landing // place: it is where you go to undo the step you just took. wantFocus.current = { key: null }; navigate(entry.path); } else { setViewing(entry); } }, [navigate], ); const openParent = useCallback(() => { // Coming back up, the directory just left is the interesting row. const leaving = currentPath.split("/").filter(Boolean).pop() ?? null; wantFocus.current = { key: leaving }; goUp(); }, [currentPath, goUp]); const breadcrumbs = currentPath === "/" ? [{ label: "/", path: "/" }] : currentPath .split("/") .reduce<{ label: string; path: string }[]>((acc, part, i) => { if (i === 0) { acc.push({ label: "/", path: "/" }); } else if (part) { const parentPath = acc[acc.length - 1].path; const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`; acc.push({ label: part, path: fullPath }); } return acc; }, []); if (!running) { return (

Start the container to browse its files.

); } const rowClass = (isSelected: boolean) => `cursor-pointer transition-colors ${ isSelected ? "bg-[var(--bg-tertiary)]" : "hover:bg-[var(--bg-tertiary)]" }`; const headerClass = "px-2 py-1.5 font-medium text-[var(--text-secondary)]"; /** * The live region's text. One region, always mounted, filled and emptied — * a `role="status"` node that is *inserted* already carrying its text is * frequently not announced at all, which is how every completion notice used * to go by in silence. */ const liveText = completed ?? ""; return (
{liveText}
{/* The one failure that stays inline: it explains why the grid below is empty, it is in context, and there are no rows for it to scroll behind. Every *transient* failure — rename, new folder — goes to `ToastHost` instead, which is above the file viewer's overlay and does not scroll away. */} {error && (
{error}
)} {loading && entries.length === 0 ? (
Loading…
) : ( {creatingFolder && ( )} {currentPath !== "/" && ( setActiveRow(PARENT_ROW)} onDoubleClick={openParent} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); openParent(); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); moveActive(PARENT_ROW, e.key === "ArrowDown" ? 1 : -1); } else if (e.key === "Home" || e.key === "End") { e.preventDefault(); moveActive(PARENT_ROW, e.key === "Home" ? "first" : "last"); } }} className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors" > )} {entries.map((entry) => { const isSelected = selected === entry.name; const isRenaming = renaming === entry.name; return ( { setSelected(entry.name); setActiveRow(entry.name); }} onDoubleClick={() => openEntry(entry)} onKeyDown={(e) => { if (isRenaming) return; if (e.key === "Enter") { e.preventDefault(); setSelected(entry.name); setActiveRow(entry.name); openEntry(entry); } else if (e.key === "F2") { e.preventDefault(); startRename(entry); } else if (e.key === "ArrowDown" || e.key === "ArrowUp") { e.preventDefault(); moveActive(entry.name, e.key === "ArrowDown" ? 1 : -1); } else if (e.key === "Home" || e.key === "End") { e.preventDefault(); moveActive(entry.name, e.key === "Home" ? "first" : "last"); } }} className={rowClass(isSelected)} > ); })} {entries.length === 0 && !loading && ( )}
Name Size Modified Actions
setFolderDraft(e.target.value)} onBlur={commitFolder} onKeyDown={(e) => { if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") { setCreatingFolder(false); setFolderDraft(""); } }} className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]" />
Folder, ..
{isRenaming ? ( setRenameDraft(e.target.value)} onClick={(e) => e.stopPropagation()} onDoubleClick={(e) => e.stopPropagation()} onBlur={() => commitRename(entry)} onKeyDown={(e) => { e.stopPropagation(); if (e.key === "Enter") (e.target as HTMLInputElement).blur(); if (e.key === "Escape") setRenaming(null); }} className="w-64 px-1 py-0 select-text bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs font-mono text-[var(--text-primary)]" /> ) : ( {/* Directory-ness was carried by hue and an `aria-hidden` emoji, i.e. by nothing at all for a screen reader. The emoji stays hidden — it reads as "file folder" in some voices and as nothing in others — and the word is what is announced. */} {entry.is_directory ? "Folder, " : "File, "} {entry.is_directory && } {entry.name} {entry.is_symlink && ( ↗ link )} )} {!entry.is_directory && formatBytes(entry.size)} {entry.modified} {!isRenaming && ( <> {/* WCAG 2.5.3: the accessible name has to *contain* the visible label, so the row context is appended rather than substituted. "Rename notes.txt" used to be the whole name, which left a voice-control user saying "click Rename" at a button that had no such name. */} )}
Empty directory
)}
{viewing && ( setViewing(null)} /> )}
); }