Files
Triple-C/app/src/components/projects/home/FilesTab.tsx
T
shadow-testandClaude Opus 5 eead748222
Build App (Preview) / compute-version (pull_request) Successful in 5s
Build Container / build-container (pull_request) Successful in 37s
Build App (Preview) / create-release (pull_request) Successful in 1s
Build App (Preview) / build-macos (pull_request) Successful in 2m40s
Build App (Preview) / build-windows (pull_request) Successful in 4m56s
Build App (Preview) / build-linux (pull_request) Successful in 5m11s
Build App (Preview) / prune-previews (pull_request) Successful in 1s
Close what two reviews found in the Files tab transfers
Two independent reviews of 2c9482a, one for correctness and one against the
threat model. Between them they found two ways to lose a file, one way for a
container to choose a Windows save destination, and a rule that made every
dotfile unsavable. Every finding below was demonstrated against a real
container before being fixed, and the fixes are demonstrated the same way.

## The download was accepting truncated reads and refusing whole ones

The `[ -f ]` bracket around the read was wrong in both directions.

It missed the case that loses data. Truncation *in place* — `> file`, log
rotation, `tar -x`, most build tools — leaves a regular file behind, so `dd`
stopped at the new EOF and exited 0. Measured: a 600 MB source truncated
mid-read delivered 34 MB, which was then renamed over the user's own earlier
copy and toasted as `Saved (34.1 MB)`. Truncate-to-zero did the same and needs
no adversary at all.

And it failed *good* downloads. `dd` already holds the fd, and neither `rm` nor
`mv` can touch an open one — the bytes are complete. But `rm` makes `[ -f ]`
false, so a finished 600 MB transfer of a file a bundler happened to unlink was
deleted, reporting "nothing was saved". The comment claiming the bracket caught
a "mix of two files" was simply wrong; an open fd cannot be a mix.

So the script now measures the file before reading it and puts the answer on
stderr, and Rust checks that at least that many bytes arrived. One rule,
subsuming everything the bracket was for. Verified against a real container:
truncation mid-read and the FIFO race are refused; deletion, rename-over, a
growing file, an empty file and an untouched 600 MB read all pass.

## On Windows the container, not the user, was naming the save destination

`suggested_save_name` split on `/` only, and it feeds the save dialog's
pre-filled name. Backslash is a legal Linux filename character and
`validate_container_path` has no reason to object — `..\..\Users\…` is one
POSIX segment. The Windows common file dialog parses its name box as a path on
Save, so a container-created file called
`..\..\..\Users\vic\AppData\Roaming\Microsoft\Word\STARTUP\x.dotm` put its own
bytes in an auto-loading Office directory on one un-read click. `resolve_host_path`
did not stop it: Word's `STARTUP` and Excel's `XLSTART` are not in the autorun
denylist, which this file's own docs already concede is "losing by
construction" and was never meant to be the boundary here.

The name is now sanitized of every separator, the drive colon and the rest of
what NTFS refuses, so it cannot be a path on any platform this ships to.

## No dotfile could be saved, and the app pre-filled the name that guaranteed it

The write policy judged the leaf for hiddenness, so `/workspace/.env` was
refused *after* the modal and the overwrite prompt — quoting the name the app
itself had suggested. `.gitignore`, `.dockerignore`, `.eslintrc.json`, `.nvmrc`:
all unsavable, while uploading them worked, so a dotfile could go in and never
come out.

`HostPathUse` gains a third mode. `WriteChosenName` drops the leaf check and
keeps every directory rule, and only `download_container_file` uses it — the
dialog is a real boundary for that caller and only that caller.
`download_container_backup` still takes its path over IPC as a string and keeps
the strict rule.

## Smaller, all found by the reviews

  * A download had no ceiling. `dd` resolves through the container's `PATH`,
    which its agent owns with passwordless sudo; a replacement writing forever
    was measured at ~6 GB/s, so one click on a file listed as 2 KB filled the
    host disk with no progress shown and no cancel. Bounded now by what the
    file measured, with slack that is absolute for small files and
    proportional for large ones, so an honest growing log is unaffected.
  * "Framed rather than verbatim" did not stop container text reaching the
    toast headline: `readableRefusal` matches with `includes`, and it has to,
    because the app's own refusals carry those markers mid-sentence. Anchoring
    would break them. The fix is at the injection point — container text is
    clipped to one 200-character line with control characters stripped — plus a
    `max-h-40` on the toast message, which the `detail` block always had and
    this half did not. 8 KB of prose in a `z-[60]` card pushed its own dismiss
    button off-screen.
  * `savingPath` was a scalar while the design deliberately allows concurrent
    saves. Starting a second freed the first's row mid-transfer, and whichever
    finished first cleared both; dismissing the second dialog was enough. It is
    a `Set` now.
  * `setUploading(false)` fired when the command settled, not when the refresh
    finished, so a second click landed mid-relisting.
  * `upload_files_to_container` checked the container directory before the
    picker and then used the unresolved path. A modal has no time limit. It
    re-checks after, which is what `docker::exec`'s doc comment already claimed.
  * `wait_for_exec_exit` flattened a missing exit code to `Some(0)`, which made
    the new `!= Some(0)` check unreachable by construction.
  * `normalize_host_path` did not collapse repeated separators, so the lexical
    system-root rule was silently absent for `C:\\Windows\…`. Not exploitable —
    the resolved pass catches it — but a documented layer that does nothing is
    a trap for the next caller.
  * README still carried the "no host path crosses IPC in either direction"
    claim the previous commit narrowed everywhere else, and HOW-TO-USE
    described the hidden rule without saying it applies to folders only.

480 Rust tests, 602 frontend, no new clippy warnings. Eleven mutations against
the new tests, all killed — two of the first round survived and were rewritten:
one because `split_whitespace` already handled the case I thought I was
testing, one because I had deleted a comment rather than the behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LHL9ty7arp8FHwvE77ne7y
2026-08-25 10:42:26 -07:00

591 lines
24 KiB
TypeScript

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.
*
* It lists, opens, renames and creates folders inside the container, and it
* copies single files across the boundary: "Upload…" in the toolbar, and a
* per-row "Save to host…".
*
* **Neither of those names a host path, and this file must never learn how
* to.** Four successive audits found that host paths crossing IPC were where
* the criticals lived — a frontend `open()`/`save()` handing Rust a string is
* exactly the shape that failed — so the picker is opened by the *backend*
* (`pick_files_to_upload` / `pick_save_path` in `commands/file_commands.rs`).
* What this file *sends* is a project id and a container path; the host side of
* the transfer is chosen by a person in an OS dialog. That is why
* `uploadFiles()` takes no argument and `saveToHost()` takes only the entry.
* (A failed transfer does report a host path back, in the text of its error —
* the inbound direction is the one that is closed, not both.)
*
* Drag-and-drop is deliberately still absent, in both directions. A file also
* 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 ⋯ menu —
* which is still the right answer for a directory, since "Save to host…" is one
* file at a time and is not offered on folders.
*
* 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
* `<tr>` and dropped focus to `<body>`: 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,
uploadFiles,
saveToHost,
uploading,
savingPaths,
} = 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<string | null>(null);
const [renaming, setRenaming] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const [creatingFolder, setCreatingFolder] = useState(false);
const [folderDraft, setFolderDraft] = useState("");
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null);
const paneRef = useRef<HTMLDivElement>(null);
const renameInputRef = useRef<HTMLInputElement>(null);
const folderInputRef = useRef<HTMLInputElement>(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<HTMLElement>("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 `<tr>` 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 (
<div className="p-4">
<p className="text-[13px] text-[var(--text-secondary)]">
Start the container to browse its files.
</p>
</div>
);
}
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 (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
<nav aria-label="Path" className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1">
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
<button
type="button"
onClick={() => {
wantFocus.current = { key: null };
navigate(crumb.path);
}}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
>
{crumb.label}
</button>
</span>
))}
</nav>
<div className="flex-1" />
<span role="status" className="mr-2 text-[var(--text-secondary)] whitespace-nowrap">
{liveText}
</span>
<Button
onClick={() => {
setFolderDraft("");
setCreatingFolder(true);
}}
>
New folder
</Button>
{/* The file picker this opens belongs to Rust, not to the webview — so
this file imports no dialog plugin and never composes a host path.
`uploadFiles` takes no argument for the same reason. */}
<Button
onClick={() => void uploadFiles()}
disabled={uploading}
className="ml-1"
>
{uploading ? "Uploading…" : "Upload…"}
</Button>
<Button onClick={refresh} disabled={loading} className="ml-1">
Refresh
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{/* 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 && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error}
</div>
)}
{loading && entries.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
Loading…
</div>
) : (
<table role="grid" aria-label="Files" className="w-full text-xs">
<thead>
<tr role="row">
<th role="columnheader" scope="col" className={`${headerClass} px-4 text-left`}>
Name
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
Size
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-left`}>
Modified
</th>
<th role="columnheader" scope="col" className={`${headerClass} text-right`}>
Actions
</th>
</tr>
</thead>
<tbody>
{creatingFolder && (
<tr role="row">
<td role="gridcell" className="px-4 py-1.5" colSpan={4}>
<input
ref={folderInputRef}
value={folderDraft}
aria-label="New folder name"
placeholder="Folder name"
onChange={(e) => 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)]"
/>
</td>
</tr>
)}
{currentPath !== "/" && (
<tr
role="row"
data-file-row={PARENT_ROW}
tabIndex={active === PARENT_ROW ? 0 : -1}
aria-label="Parent directory"
onClick={() => 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"
>
<td role="gridcell" className="px-4 py-1.5 text-[var(--text-primary)] font-mono">
<span className="sr-only">Folder, </span>
..
</td>
<td role="gridcell" colSpan={3} />
</tr>
)}
{entries.map((entry) => {
const isSelected = selected === entry.name;
const isRenaming = renaming === entry.name;
return (
<tr
key={entry.name}
role="row"
data-file-row={entry.name}
tabIndex={active === entry.name ? 0 : -1}
aria-selected={isSelected}
onClick={() => {
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)}
>
<td role="gridcell" className="px-4 py-1.5">
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label={`New name for ${entry.name}`}
onChange={(e) => 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)]"
/>
) : (
<span
className={`font-mono ${
entry.is_directory
? "text-[var(--accent)]"
: "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. */}
<span className="sr-only">
{entry.is_directory ? "Folder, " : "File, "}
</span>
{entry.is_directory && <span aria-hidden="true">📁 </span>}
<span>{entry.name}</span>
{entry.is_symlink && (
<span
className="ml-1 text-[var(--text-secondary)]"
title="Symbolic link"
>
↗ link
</span>
)}
</span>
)}
</td>
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
{!entry.is_directory && formatBytes(entry.size)}
</td>
<td role="gridcell" className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td role="gridcell" className="px-2 py-1.5 text-right whitespace-nowrap">
{!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. */}
<Button
aria-label={`Rename — ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
startRename(entry);
}}
>
Rename
</Button>
{/* Folders have no single-file equivalent — a
recursive download is what "Back up container" is
for, and offering one here would mean rebuilding
the tree-walking this pane deliberately does not
do. */}
{!entry.is_directory && (
<Button
aria-label={`Save to host — ${entry.name}`}
className="ml-1"
// Only this row: a large file can take a while,
// and there is no reason the rest of the pane
// should go dead while it is written.
disabled={savingPaths.has(entry.path)}
onClick={(e) => {
e.stopPropagation();
void saveToHost(entry);
}}
// A double-click is its own event, and
// `onClick`'s `stopPropagation` says nothing
// about it — so an impatient double-click here
// reached the row's `onDoubleClick` and dropped
// the viewer modal over the pane, on top of the
// save dialog the backend had just opened.
onDoubleClick={(e) => e.stopPropagation()}
>
{savingPaths.has(entry.path) ? "Saving…" : "Save to host…"}
</Button>
)}
</>
)}
</td>
</tr>
);
})}
{entries.length === 0 && !loading && (
<tr role="row">
<td
role="gridcell"
colSpan={4}
className="px-4 py-8 text-center text-[var(--text-secondary)]"
>
Empty directory
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{viewing && (
<FileViewerModal
projectId={project.id}
entry={viewing}
onClose={() => setViewing(null)}
/>
)}
</div>
);
}