Files
Triple-C/app/src/hooks/useFileManager.ts
T
shadow-testandClaude Opus 5 06ccb4d818 Ship the Files tab container-side only
Four successive audits found the same thing: host filesystem paths crossing
IPC is where the criticals in this work live. The most recent one found the
`link(2)` upload reservation returning success against a *directory* (linking
into it, leaving permanent stray files, and via a symlink-to-directory writing
outside the validated write root), failing every upload permanently on any
filesystem without hard links, and the post-resolution credential check
weakened from a general rule to an eleven-name denylist.

Rather than fix that a fifth time, the Files tab ships as what it is good at:
a browser, viewer and renamer that never touches the host.

Removed: `upload_file_to_container`, `download_container_file`, and everything
that existed only for them — the whole reservation (`UPLOAD_RESERVATION_SCRIPT`,
`reserve_upload_destination`, the placeholder rollback, `exec_oneshot_as_within`
which had no other caller), `stream_container_file_to_host`, `ChannelReader`,
`save_to_host`, the download ceiling, and the collision marker with its
frontend contract. On the frontend: the upload button, the pane's
`onDragDropEvent` handler, both "Save to host…" affordances, `uploadPaths` /
`downloadFile` / the overwrite prompt, and `OverwriteConfirmModal`.
`lib/uploadErrors.ts` is now `lib/refusalText.ts` and keeps only the half that
turns any backend refusal into the sentence a person reads.

Kept, and not weakened: `upload_host_file_to_terminal` and
`download_container_backup`. They predate this work, their hardening is a real
improvement over main, and they are now the whole answer to "how do I get a
file in or out" — drop it on the Terminal, or Back up container. The drop gate
(`lib/dropTarget.ts`, `PaneVisibility`) is untouched.

`resolve_host_path` gets the general hidden-component rule back. Round 3
replaced it with `HOST_CREDENTIAL_DIRS`, which is allow-by-omission for the
rest of `$HOME`: `~/.local/bin` (write there and you own the user's next shell
command), `~/.password-store`, browser profiles and `~/.pki/nssdb` were all
reachable through a planted symlink with a visible name — verified against a
real home directory, and all five refused now. It over-catches `.pnpm` and
`~/.cache`; for two occasional callers that is the cheaper mistake, and the
refusal says which folder it resolved through.

Two defects fixed while in here:

  * A symlinked directory listed as empty. `find` defaults to `-P`, which does
    not follow a symlink even as the starting point, so `-mindepth 1` discarded
    the only match and a real directory rendered as "Empty directory" — a
    first-order defect now that browsing *is* the feature. `-H` follows the
    starting point and nothing else, so a loop is `ELOOP` rather than a walk
    that does not end; verified against a live container for a symlinked
    directory, a broken link and a loop. `find`'s errno for the loop case is
    now a sentence.
  * `finish_download`'s replace path fired on *any* rename failure with a
    destination present — a vanished partial, a permission error, a directory
    at the destination — and deleted the user's file to complete a move that
    could not complete. It is now fenced to Windows (where a rename onto an
    existing path genuinely fails) and to a partial that still exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
2026-08-23 17:05:56 -07:00

173 lines
6.5 KiB
TypeScript

import { useCallback, useRef, useState } from "react";
import type { FileEntry } from "../lib/types";
import * as commands from "../lib/tauri-commands";
import { useAppState } from "../store/appState";
import { errorText, readableRefusal } from "../lib/refusalText";
/**
* ## Where failures are reported
*
* Two audiences, two places, and the split is deliberate.
*
* The **initial listing** failure stays in `error`, rendered inline above the
* (empty) grid. It is on screen, it is in context, it explains why there are
* no rows, and it is not transient — it stands until the directory lists.
*
* Every **transient operation** failure — rename, create folder — goes to
* `ToastHost` instead. Those used to land in the same inline `error` div, which
* is the first child of the *scrolling* list: three hundred rows down, a
* refused rename produced no visible change at all, just a rename box that
* stayed open for no stated reason. The toast host is a persistent `aria-live`
* region at `z-[60]`, i.e. the one place in the app that is above a modal and
* does not scroll away.
*
* ## Where the current directory lives
*
* `currentPath` is state (the UI renders it) *and* a ref (async work reads it
* after an await). Every long operation captures the directory it targets at
* the start and compares it against the ref at the end: a slow rename in
* `/workspace` must not drag the pane back out of `src/` because that is where
* the closure happened to be created. The ref moves at the *start* of a
* navigation rather than when the listing lands, because the question being
* asked is "where is the user going", not "what is on screen right now" — and
* it is put back if that navigation fails.
*/
export function useFileManager(projectId: string) {
const [currentPath, setCurrentPath] = useState("/workspace");
const [entries, setEntries] = useState<FileEntry[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
/**
* What just finished, for the live region — a rename or a new folder is a
* change a sighted user sees in the grid and a screen reader user does not.
*/
const [completed, setCompleted] = useState<string | null>(null);
const currentPathRef = useRef(currentPath);
/**
* A slow listing can land after a newer one and set both the rows and the
* breadcrumb back to a directory the user already left. Same generation
* guard `useContainerMigration` uses: every async write
* checks it is still the newest before it lands.
*/
const navGeneration = useRef(0);
/**
* Report a failed operation, given the headline this hook would write and the
* raw failure behind it.
*
* The headline is what the *hook* knows ("Could not rename …"); it is a
* category, not an explanation. Some backend refusals are already a finished
* sentence written for the person reading it — a container path outside the
* roots this panel may change, a name it will not create — and those used to
* arrive as the toast's `detail`, which `ToastHost` renders as collapsed
* monospace behind a "Details" button. So the sentence that said what was
* wrong and what to do about it was hidden under a headline that said
* neither. When there is such a sentence it becomes the headline, and there
* is nothing left to hide.
*/
const report = useCallback((message: string, cause: unknown) => {
const promoted = readableRefusal(cause);
useAppState.getState().pushToast({
kind: "error",
message: promoted ?? message,
detail: promoted ? undefined : errorText(cause),
});
}, []);
const navigate = useCallback(
async (path: string) => {
const mine = ++navGeneration.current;
const previous = currentPathRef.current;
currentPathRef.current = path;
setLoading(true);
setError(null);
try {
const result = await commands.listContainerFiles(projectId, path);
if (navGeneration.current !== mine) return;
setEntries(result);
setCurrentPath(path);
} catch (e) {
if (navGeneration.current !== mine) return;
// The move did not happen, so the pane is still where it was — the ref
// has to agree with the breadcrumb or the next operation will decide
// it targeted a directory nobody is looking at.
currentPathRef.current = previous;
setError(String(e));
} finally {
if (navGeneration.current === mine) setLoading(false);
}
},
[projectId],
);
const goUp = useCallback(() => {
const here = currentPathRef.current;
if (here === "/") return;
const parent = here.replace(/\/[^/]+$/, "") || "/";
navigate(parent);
}, [navigate]);
const refresh = useCallback(() => {
navigate(currentPathRef.current);
}, [navigate]);
/**
* Rename in place. `newName` is a bare name — Rust rejects anything with a
* `/` in it, so this can never turn into a move. Resolves true on success so
* the caller knows whether to leave edit mode.
*/
const renameEntry = useCallback(
async (entry: FileEntry, newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === entry.name) return true;
const target = currentPathRef.current;
try {
await commands.renameContainerPath(projectId, entry.path, trimmed);
setCompleted(`Renamed "${entry.name}" to "${trimmed}".`);
if (currentPathRef.current === target) await navigate(target);
return true;
} catch (e) {
report(`Could not rename "${entry.name}"`, e);
return false;
}
},
[projectId, navigate, report],
);
const createFolder = useCallback(
async (name: string) => {
const trimmed = name.trim();
if (!trimmed) return true;
const target = currentPathRef.current;
try {
await commands.createContainerDirectory(projectId, target, trimmed);
setCompleted(`Created "${trimmed}".`);
if (currentPathRef.current === target) await navigate(target);
return true;
} catch (e) {
report(`Could not create "${trimmed}"`, e);
return false;
}
},
[projectId, navigate, report],
);
return {
currentPath,
entries,
loading,
/** Inline, in-context: why the listing on screen is empty. */
error,
/** What the last operation finished doing, for the live region. */
completed,
setError,
navigate,
goUp,
refresh,
renameEntry,
createFolder,
};
}