Reconcile the frontend with the round-1 backend contracts
Five backend branches merged and the TypeScript still compiled, because
none of this is a type error: a field that arrives `undefined`, a variant
nothing emits any more, a prompt whose loop never closes. Six things.
**Orphaned volumes are destructive now, not safe.** `ReclaimTarget::
OrphanVolume` is gone from Rust; the object is a `DestructiveTarget::
OrphanVolume { name, project_id }` confirmed against the *volume's* name,
there being no project to name. The TS union still listed it under
`ReclaimTarget`, and — worse — `DiskProjectTable` keys destructive items
off `project_id`, which an orphan's never matches. So the item existed in
the plan and appeared nowhere on screen. `DiskSettings` now splits the
plan's destructive list and gives orphans their own section with a
per-volume `TypedConfirmModal`. The copy says what a
`triple-c-claude-config-*` volume actually is — a Claude login
credential, every plugin and skill, every transcript that project had —
and keeps the sentence explaining that "no matching project" is a lookup
against the project list and is never inferred from a project being
stopped or having no image, which is the inference that once flagged two
live projects.
`TypedConfirmModal` grew a `subject` prop: asking a user for "the exact
project name" of a volume that has no project is asking for a string that
does not exist.
**Snapshot and Total reconcile.** `ProjectDiskRow.snapshot_attributed_bytes`
is the single figure `snapshot_attribution()` exists to produce. The
column rendered `snapshot_above_base_bytes` and fell back to `—` while
the Total was `size - shared` regardless — and in that branch `size -
shared` is the whole 4.7 GB base image, charged per project and then
added again as a base-image row. One field, one rule. The one branch
where the figure *is* the whole image says so rather than passing itself
off as a share.
**The overwrite loop closes.** Traced end to end: a `FILE_EXISTS:`
refusal raises the prompt, Replace re-invokes with `overwrite: true`,
Skip advances, "…all" answers the rest without asking, and picker and
host-drop both reach `uploadFileToContainer` through `uploadPaths`. Two
gaps: a second batch's `askOverwrite` overwrote the first's resolver,
leaving that batch awaiting an answer no dialog could produce; and the
backend's written refusals — a hidden host folder, a path outside the
write roots — were passed as a toast `detail`, which `ToastHost` renders
as collapsed monospace behind a "Details" button, so the only sentence
that explained anything was the part nobody saw. `readableRefusal`
promotes it to the headline when a batch failed the same way.
**The browser pane's sandbox is pinned.** `allow-same-origin` must stay
(the proxy's gate reads `Origin`/`Referer`, and an opaque origin sends
`null`); every top-navigation grant and `allow-popups-to-escape-sandbox`
must stay absent, and the test names the offending token rather than
printing a set diff.
**`@tauri-apps/plugin-store` is gone** from `package.json` — its
capability grants were removed as a host-file-write primitive and nothing
in `app/src` imports it. The lockfile was updated with
`--package-lock-only`, deliberately: `node_modules` is a symlink shared
with other worktrees and a real install would have pulled it out from
under them.
Nothing under `src-tauri/` is touched. 663 frontend tests pass (was 635),
`tsc --noEmit` clean, `npm run build` green, `cargo test` 446 unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
@@ -499,3 +499,223 @@ describe("useFileManager staged host paths", () => {
|
||||
expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The loop, end to end. The prompt only earns its place if the *batch* survives
|
||||
* it: one answer, given once, has to leave every other file in the drop exactly
|
||||
* where it would have been.
|
||||
*/
|
||||
describe("useFileManager overwrite prompt closes the loop", () => {
|
||||
const clash = (name: string) => `FILE_EXISTS: /workspace/${name} already exists`;
|
||||
|
||||
/**
|
||||
* Start an upload and wait for it to stop at the prompt, handing back the
|
||||
* still-unsettled batch.
|
||||
*
|
||||
* Wrapped in an object on purpose: an `async` function that returned the
|
||||
* promise itself would *adopt* it, so awaiting the helper would wait for the
|
||||
* whole upload — which cannot finish until the question is answered, which
|
||||
* cannot happen until the helper returns. That deadlock looks exactly like
|
||||
* the hang these tests exist to rule out.
|
||||
*/
|
||||
async function uploadUntilPrompt(
|
||||
result: { current: ReturnType<typeof useFileManager> },
|
||||
paths: string[],
|
||||
): Promise<{ batch: Promise<void> }> {
|
||||
let batch!: Promise<void>;
|
||||
await act(async () => {
|
||||
batch = result.current.uploadPaths(paths);
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict).not.toBeNull());
|
||||
return { batch };
|
||||
}
|
||||
|
||||
it("replaces the file that clashed and still uploads the rest of the batch", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt")) // 1: a.txt, no overwrite
|
||||
.mockResolvedValueOnce(undefined) // 2: a.txt, overwrite: true
|
||||
.mockResolvedValueOnce(undefined); // 3: b.txt, no clash
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
expect(result.current.conflict?.name).toBe("a.txt");
|
||||
expect(result.current.conflict?.remaining).toBe(1);
|
||||
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
||||
// The retry is the whole point: same file, same directory, overwrite on.
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
// …and "Replace" answered for *that* file only, so the next one is offered
|
||||
// to the backend the safe way round.
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(result.current.completed).toContain("Uploaded 2 items");
|
||||
expect(toastText()).not.toContain("could not be uploaded");
|
||||
});
|
||||
|
||||
it("moves on to the next file on Skip rather than ending the batch", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined); // b.txt still goes
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/b.txt", "/workspace");
|
||||
// Nothing was overwritten.
|
||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
||||
expect(result.current.completed).toContain("skipped 1");
|
||||
});
|
||||
|
||||
it("dismissing the dialog is a Skip — the batch carries on", async () => {
|
||||
// `OverwriteConfirmModal` maps Escape / ✕ / click-outside onto this exact
|
||||
// call, so a dismissal must not hang the loop or abort the drop.
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt"]);
|
||||
await act(async () => {
|
||||
// What `Modal`'s `onClose` produces.
|
||||
result.current.resolveConflict("skip");
|
||||
await batch;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(result.current.completed).toContain("Uploaded 1 item, skipped 1");
|
||||
expect(result.current.busy).toBeNull();
|
||||
});
|
||||
|
||||
it("answers every remaining clash with Skip all, asking only once", async () => {
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockRejectedValueOnce(clash("b.txt"))
|
||||
.mockRejectedValueOnce(clash("c.txt"));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt", "/host/b.txt", "/host/c.txt"]);
|
||||
expect(result.current.conflict?.remaining).toBe(2);
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("skip-all");
|
||||
await batch;
|
||||
});
|
||||
|
||||
// Three attempts, no second prompt, nothing replaced.
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(3);
|
||||
expect(uploadFileToContainer.mock.calls.some((c) => c[3] === true)).toBe(false);
|
||||
expect(result.current.conflict).toBeNull();
|
||||
expect(result.current.completed).toContain("skipped 3");
|
||||
});
|
||||
|
||||
it("puts a picked file through exactly the road a dropped one takes", async () => {
|
||||
// The Upload button and the native drop listener are one routine —
|
||||
// `uploadPaths` — so the prompt, the retry and the blanket answers cannot
|
||||
// drift apart between them. This is that claim, from the picker end.
|
||||
openDialog.mockResolvedValueOnce(["/host/a.txt", "/host/b.txt"]);
|
||||
uploadFileToContainer
|
||||
.mockRejectedValueOnce(clash("a.txt"))
|
||||
.mockResolvedValueOnce(undefined)
|
||||
.mockResolvedValueOnce(undefined);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
let picked!: Promise<void>;
|
||||
await act(async () => {
|
||||
picked = result.current.uploadFile();
|
||||
await Promise.resolve();
|
||||
});
|
||||
await waitFor(() => expect(result.current.conflict?.name).toBe("a.txt"));
|
||||
await act(async () => {
|
||||
result.current.resolveConflict("replace");
|
||||
await picked;
|
||||
});
|
||||
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(3, "p1", "/host/b.txt", "/workspace");
|
||||
});
|
||||
|
||||
it("does not leave the batch waiting for an answer that can never arrive", async () => {
|
||||
// The pane unmounted mid-prompt (tab closed, container stopped). The upload
|
||||
// promise has to settle, or `busy` never clears and the loop leaks.
|
||||
uploadFileToContainer.mockRejectedValueOnce(clash("a.txt"));
|
||||
const { result, unmount } = renderHook(() => useFileManager("p1"));
|
||||
|
||||
const { batch } = await uploadUntilPrompt(result, ["/host/a.txt"]);
|
||||
unmount();
|
||||
await expect(batch).resolves.toBeUndefined();
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Refusals the backend already phrased for a person. `ToastHost` renders a
|
||||
* `detail` as collapsed monospace behind a "Details" button, so a sentence
|
||||
* reported that way is a sentence nobody reads.
|
||||
*/
|
||||
describe("useFileManager surfaces written refusals as prose", () => {
|
||||
const hiddenFolder =
|
||||
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
|
||||
const outsideRoots =
|
||||
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||
|
||||
/** The toast this operation pushed. */
|
||||
const lastToast = () => pushToast.mock.calls.at(-1)?.[0];
|
||||
|
||||
it("puts the write-root refusal in the headline, not behind Details", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce(outsideRoots);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt"]);
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
expect(lastToast().detail).toBeUndefined();
|
||||
expect(lastToast().message).not.toMatch(/^Error:/);
|
||||
});
|
||||
|
||||
it("says it once for a whole batch that failed the same way", async () => {
|
||||
// The refusal is about the target directory, so every file in the drop
|
||||
// fails identically — three copies of the same sentence is not detail.
|
||||
uploadFileToContainer.mockRejectedValue(outsideRoots);
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt", "/host/b.txt"]);
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe(outsideRoots);
|
||||
expect(lastToast().detail).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does the same for a refused save to the host", async () => {
|
||||
save.mockResolvedValue("/home/me/.ssh/a.txt");
|
||||
downloadContainerFile.mockRejectedValueOnce(new Error(hiddenFolder));
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.downloadFile(file("a.txt"));
|
||||
});
|
||||
|
||||
// Unwrapped: an `Error` on the way through must not stamp "Error:" on prose.
|
||||
expect(lastToast().message).toBe(hiddenFolder);
|
||||
});
|
||||
|
||||
it("keeps the hook's own headline when the failure is not a written refusal", async () => {
|
||||
uploadFileToContainer.mockRejectedValueOnce("no space left on device");
|
||||
const { result } = renderHook(() => useFileManager("p1"));
|
||||
await act(async () => {
|
||||
await result.current.uploadPaths(["/host/a.txt"]);
|
||||
});
|
||||
|
||||
expect(lastToast().message).toBe("A file could not be uploaded");
|
||||
expect(lastToast().detail).toBe("no space left on device");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,8 +4,10 @@ import type { FileEntry } from "../lib/types";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { useAppState } from "../store/appState";
|
||||
import {
|
||||
errorText,
|
||||
fileExistsPath,
|
||||
isFileExistsError,
|
||||
readableRefusal,
|
||||
type OverwriteChoice,
|
||||
} from "../lib/uploadErrors";
|
||||
|
||||
@@ -99,8 +101,34 @@ export function useFileManager(projectId: string) {
|
||||
setCompleted(null);
|
||||
}, []);
|
||||
|
||||
const report = useCallback((message: string, detail?: string) => {
|
||||
useAppState.getState().pushToast({ kind: "error", message, detail });
|
||||
/**
|
||||
* Report a failed operation, given the headline this hook would write and the
|
||||
* raw failures 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 hidden host folder, a
|
||||
* container path outside the roots this panel may change — 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 every failure reduces to the *same* such sentence — which is
|
||||
* the normal case, since these refusals are about the target directory and so
|
||||
* fail identically for every file in a batch — it becomes the headline and
|
||||
* there is nothing left to hide.
|
||||
*/
|
||||
const report = useCallback((message: string, ...causes: unknown[]) => {
|
||||
const refusals = causes.map(readableRefusal);
|
||||
const shared =
|
||||
causes.length > 0 && refusals.every((r) => r !== null)
|
||||
? [...new Set(refusals as string[])]
|
||||
: [];
|
||||
const promoted = shared.length === 1 ? shared[0] : null;
|
||||
useAppState.getState().pushToast({
|
||||
kind: "error",
|
||||
message: promoted ?? message,
|
||||
detail: promoted || causes.length === 0 ? undefined : causes.map(errorText).join("\n"),
|
||||
});
|
||||
}, []);
|
||||
|
||||
const confirm = useCallback((message: string) => {
|
||||
@@ -161,7 +189,7 @@ export function useFileManager(projectId: string) {
|
||||
setBusy(null);
|
||||
}
|
||||
} catch (e) {
|
||||
report(`Could not save "${entry.name}" to the host`, String(e));
|
||||
report(`Could not save "${entry.name}" to the host`, e);
|
||||
}
|
||||
},
|
||||
[projectId, startWork, report, confirm],
|
||||
@@ -194,6 +222,14 @@ export function useFileManager(projectId: string) {
|
||||
const askOverwrite = useCallback(
|
||||
(hostPath: string, directory: string, remaining: number, containerPath: string | null) =>
|
||||
new Promise<OverwriteChoice>((resolve) => {
|
||||
// One batch asks one question at a time — the loop awaits each answer —
|
||||
// so a resolver still sitting here belongs to a *different* batch (two
|
||||
// drops in flight at once, or a drop landing while the Upload button's
|
||||
// batch is still copying). Installing over it would leave that batch
|
||||
// awaiting an answer no dialog can ever produce: a silent hang, with
|
||||
// its file neither uploaded nor skipped. Skipping it is the same
|
||||
// reading of "the dialog went away" the unmount cleanup uses.
|
||||
conflictResolver.current?.("skip");
|
||||
conflictResolver.current = resolve;
|
||||
setConflict({
|
||||
hostPath,
|
||||
@@ -221,7 +257,8 @@ export function useFileManager(projectId: string) {
|
||||
// the end, because the user is free to walk away while it copies.
|
||||
const target = currentPathRef.current;
|
||||
startWork(`Uploading ${hostPaths.length} item${hostPaths.length > 1 ? "s" : ""}…`);
|
||||
const failures: string[] = [];
|
||||
/** Raw failures, kept unstringified so `report` can read their shape. */
|
||||
const failures: unknown[] = [];
|
||||
let uploaded = 0;
|
||||
let skipped = 0;
|
||||
/** A "…all" answer, applied to every remaining clash without asking. */
|
||||
@@ -235,7 +272,7 @@ export function useFileManager(projectId: string) {
|
||||
continue;
|
||||
} catch (e) {
|
||||
if (!isFileExistsError(e)) {
|
||||
failures.push(String(e));
|
||||
failures.push(e);
|
||||
continue;
|
||||
}
|
||||
const choice: OverwriteChoice =
|
||||
@@ -256,7 +293,7 @@ export function useFileManager(projectId: string) {
|
||||
await commands.uploadFileToContainer(projectId, hostPath, target, true);
|
||||
uploaded++;
|
||||
} catch (e) {
|
||||
failures.push(String(e));
|
||||
failures.push(e);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
@@ -273,7 +310,7 @@ export function useFileManager(projectId: string) {
|
||||
if (failures.length > 0) {
|
||||
report(
|
||||
failures.length === 1 ? "A file could not be uploaded" : `${failures.length} files could not be uploaded`,
|
||||
failures.join("\n"),
|
||||
...failures,
|
||||
);
|
||||
}
|
||||
// Only re-list if the user is still looking at the directory this went
|
||||
@@ -331,7 +368,7 @@ export function useFileManager(projectId: string) {
|
||||
setCompleted(`"${entry.name}" is ready to drag.`);
|
||||
return { hostPath, cached: false };
|
||||
} catch (e) {
|
||||
report(`Could not prepare "${entry.name}" for dragging`, String(e));
|
||||
report(`Could not prepare "${entry.name}" for dragging`, e);
|
||||
return null;
|
||||
} finally {
|
||||
setBusy(null);
|
||||
@@ -346,7 +383,7 @@ export function useFileManager(projectId: string) {
|
||||
if (!selected) return;
|
||||
await uploadPaths(Array.isArray(selected) ? selected : [selected as string]);
|
||||
} catch (e) {
|
||||
report("Could not open the file picker", String(e));
|
||||
report("Could not open the file picker", e);
|
||||
}
|
||||
}, [uploadPaths, report]);
|
||||
|
||||
@@ -366,7 +403,7 @@ export function useFileManager(projectId: string) {
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
report(`Could not rename "${entry.name}"`, String(e));
|
||||
report(`Could not rename "${entry.name}"`, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
@@ -384,7 +421,7 @@ export function useFileManager(projectId: string) {
|
||||
if (currentPathRef.current === target) await navigate(target);
|
||||
return true;
|
||||
} catch (e) {
|
||||
report(`Could not create "${trimmed}"`, String(e));
|
||||
report(`Could not create "${trimmed}"`, e);
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user