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:
@@ -824,3 +824,76 @@ describe("FilesTab overwrite prompt", () => {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Dismissal. `Modal` gives every dialog Escape, a ✕ and click-outside for free,
|
||||
* and `OverwriteConfirmModal` maps all three onto `onChoose("skip")` — because
|
||||
* the destructive answer has to be chosen, and because a dialog that is closed
|
||||
* rather than answered must not leave the batch waiting forever or throw away
|
||||
* the files behind it.
|
||||
*/
|
||||
describe("FilesTab overwrite prompt dismissal", () => {
|
||||
/**
|
||||
* Drop two files where the first name is taken, and stop at the dialog. The
|
||||
* unsettled batch comes back wrapped — returning it bare from an `async`
|
||||
* helper would adopt it, and awaiting the helper would then wait for an
|
||||
* upload that cannot proceed until the helper has returned.
|
||||
*/
|
||||
async function dropIntoConflict(): Promise<{ batch: Promise<void> | undefined }> {
|
||||
uploadFileToContainer.mockRejectedValueOnce("FILE_EXISTS: /workspace/a.txt already exists");
|
||||
await renderTab();
|
||||
const batch = dropWithoutWaiting(["/host/a.txt", "/host/b.txt"]);
|
||||
await screen.findByRole("dialog");
|
||||
return { batch };
|
||||
}
|
||||
|
||||
/** What every dismissal has to leave behind: one skip, one upload, no clobber. */
|
||||
function expectSkippedAndCarriedOn() {
|
||||
expect(screen.queryByRole("dialog")).toBeNull();
|
||||
expect(uploadFileToContainer).toHaveBeenCalledTimes(2);
|
||||
expect(uploadFileToContainer).toHaveBeenLastCalledWith("p1", "/host/b.txt", "/workspace");
|
||||
expect(uploadFileToContainer.mock.calls.some((call) => call[3] === true)).toBe(false);
|
||||
expect(screen.getByRole("status").textContent).toContain("skipped 1");
|
||||
}
|
||||
|
||||
it("counts Escape as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
await act(async () => {
|
||||
fireEvent.keyDown(document, { key: "Escape" });
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("counts the ✕ as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Close dialog" }));
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("counts a click on the backdrop as a Skip", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
// The overlay is the dialog panel's parent — `Modal` only closes when the
|
||||
// click landed on the overlay itself, not on anything inside the panel.
|
||||
const overlay = screen.getByRole("dialog").parentElement!;
|
||||
await act(async () => {
|
||||
fireEvent.click(overlay);
|
||||
await batch;
|
||||
});
|
||||
expectSkippedAndCarriedOn();
|
||||
});
|
||||
|
||||
it("does not dismiss on a click inside the dialog", async () => {
|
||||
const { batch } = await dropIntoConflict();
|
||||
fireEvent.click(screen.getByRole("dialog"));
|
||||
expect(screen.queryByRole("dialog")).not.toBeNull();
|
||||
await act(async () => {
|
||||
fireEvent.click(screen.getByRole("button", { name: "Replace" }));
|
||||
await batch;
|
||||
});
|
||||
expect(uploadFileToContainer).toHaveBeenNthCalledWith(2, "p1", "/host/a.txt", "/workspace", true);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user