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:
Generated
-10
@@ -12,7 +12,6 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
@@ -2010,15 +2009,6 @@
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@tauri-apps/plugin-store": {
|
||||
"version": "2.4.2",
|
||||
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-store/-/plugin-store-2.4.2.tgz",
|
||||
"integrity": "sha512-0ClHS50Oq9HEvLPhNzTNFxbWVOqoAp3dRvtewQBeqfIQ0z5m3JRnOISIn2ZVPCrQC0MyGyhTS9DWhHjpigQE7A==",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@testing-library/dom": {
|
||||
"version": "10.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-dialog": "^2.7.0",
|
||||
"@tauri-apps/plugin-opener": "^2.5.3",
|
||||
"@tauri-apps/plugin-store": "^2",
|
||||
"@xterm/addon-fit": "^0.10",
|
||||
"@xterm/addon-web-links": "^0.12.0",
|
||||
"@xterm/addon-webgl": "^0.18",
|
||||
|
||||
@@ -571,4 +571,57 @@ describe("BrowserTab", () => {
|
||||
// The view is still in the tab, where it was.
|
||||
expect(screen.getByTitle("Playwright browser view for api-server")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("pins the pane's sandbox: same-origin kept, top-navigation never granted", async () => {
|
||||
await renderLive();
|
||||
const frame = await screen.findByTitle("Playwright browser view for api-server");
|
||||
const tokens = new Set(
|
||||
(frame.getAttribute("sandbox") ?? "").split(/\s+/).filter(Boolean),
|
||||
);
|
||||
|
||||
// What is framed here is served by a process *inside* the container, which
|
||||
// is the untrusted side of this app. A top-navigation grant would let that
|
||||
// page set `top.location` and steer the whole Triple-C app window away from
|
||||
// itself — a sandbox escape from the app's point of view — and
|
||||
// `allow-popups-to-escape-sandbox` is the same hole one step removed: it
|
||||
// hands a popup an entirely unsandboxed context. Checked token by token so
|
||||
// the failure names the one that was added.
|
||||
for (const forbidden of [
|
||||
"allow-top-navigation",
|
||||
"allow-top-navigation-by-user-activation",
|
||||
"allow-top-navigation-to-custom-protocols",
|
||||
"allow-popups-to-escape-sandbox",
|
||||
]) {
|
||||
expect(
|
||||
tokens.has(forbidden),
|
||||
`FORBIDDEN iframe sandbox token "${forbidden}" on the browser view pane. ` +
|
||||
"A page served from inside the container could then navigate the whole " +
|
||||
"Triple-C app window away from itself (or run a popup unsandboxed) — a " +
|
||||
"sandbox escape. Remove it from the iframe in BrowserTab.tsx.",
|
||||
).toBe(false);
|
||||
}
|
||||
|
||||
// `allow-same-origin` must stay. The browser_view proxy's token gate
|
||||
// recognises the pane's own sub-resource requests by their `Origin`/
|
||||
// `Referer` header; dropping this token gives the frame an opaque origin,
|
||||
// which sends `null`, so the proxy refuses those requests and the pane
|
||||
// renders blank.
|
||||
expect(
|
||||
tokens.has("allow-same-origin"),
|
||||
'REQUIRED iframe sandbox token "allow-same-origin" is missing from the ' +
|
||||
"browser view pane. Without it the frame has an opaque origin and sends " +
|
||||
"`Origin: null`, which the browser_view proxy's token gate refuses — the " +
|
||||
"pane goes blank.",
|
||||
).toBe(true);
|
||||
|
||||
// And the exact set, so any *other* new grant is a deliberate edit here too.
|
||||
expect([...tokens].sort()).toEqual([
|
||||
"allow-downloads",
|
||||
"allow-forms",
|
||||
"allow-modals",
|
||||
"allow-popups",
|
||||
"allow-same-origin",
|
||||
"allow-scripts",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,6 +26,29 @@ function cell(bytes: number, present: boolean) {
|
||||
return present ? formatBytes(bytes) : "—";
|
||||
}
|
||||
|
||||
const SNAPSHOT_HELP =
|
||||
"This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from.";
|
||||
|
||||
/** Why a snapshot figure is the whole image rather than a share of one. */
|
||||
const SPLIT_UNKNOWN_HELP =
|
||||
"Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape.";
|
||||
|
||||
/**
|
||||
* How `snapshot_attributed_bytes` was arrived at, in the row's own terms.
|
||||
*
|
||||
* Rust computes the number in one function so the column and the Total cannot
|
||||
* be derived from two different rules again — but the branches do not mean the
|
||||
* same thing to a reader, so the sub-line has to say which one this row is.
|
||||
* `snapshot_above_base_bytes` is `null` in exactly the branch where the figure
|
||||
* *is* the whole image, which is what makes it the test.
|
||||
*/
|
||||
function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } {
|
||||
if (row.snapshot_above_base_bytes !== null) {
|
||||
return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null };
|
||||
}
|
||||
return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP };
|
||||
}
|
||||
|
||||
/**
|
||||
* The per-project table — the mental model users actually have of this app.
|
||||
*
|
||||
@@ -64,8 +87,10 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
<th scope="col" className="font-medium py-1.5 pr-3">
|
||||
Project
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right">
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Snapshot
|
||||
<Tooltip text={SNAPSHOT_HELP} />
|
||||
<span className="sr-only"> — {SNAPSHOT_HELP}</span>
|
||||
</th>
|
||||
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
|
||||
Layers
|
||||
@@ -121,22 +146,33 @@ export default function DiskProjectTable({ rows, destructive, onDestroy }: Props
|
||||
</span>
|
||||
</th>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
|
||||
{/* `null` means the split could not be measured. Rendering it
|
||||
as 0 B would be the one guessed number in this table. */}
|
||||
{cell(
|
||||
row.snapshot_above_base_bytes ?? -1,
|
||||
row.snapshot_exists && row.snapshot_above_base_bytes !== null,
|
||||
)}
|
||||
{row.snapshot_exists && (
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
{/* The base is shared by every project, so charging it to
|
||||
each row would show the same 4.7 GB eight times. The
|
||||
headline figure is what is unique to this project;
|
||||
the total is here for anyone reconciling against
|
||||
`docker images`. */}
|
||||
{formatBytes(row.snapshot_bytes)} with base
|
||||
</span>
|
||||
)}
|
||||
{/* `snapshot_attributed_bytes`, and nothing else. This column
|
||||
used to render `snapshot_above_base_bytes` and fall back
|
||||
to `—` while the Total was computed from
|
||||
`snapshot_bytes - snapshot_shared_bytes` regardless — so a
|
||||
row could show `—` here and still carry a whole 4.7 GB
|
||||
base image in its Total, once per project. One field, one
|
||||
rule, computed once in Rust: the parts add up. */}
|
||||
{row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"}
|
||||
{row.snapshot_exists && (() => {
|
||||
const { note, help } = attributionNote(row);
|
||||
return help === null ? (
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
{note}
|
||||
</span>
|
||||
) : (
|
||||
// Same treatment as the Layers column: `Tooltip` portals
|
||||
// a plain div with no `role` and no `aria-describedby`,
|
||||
// so the explanation is also emitted as screen-reader
|
||||
// text rather than living in the tooltip alone.
|
||||
<span className="block text-[11px] text-[var(--text-secondary)]">
|
||||
<Tooltip text={help}>
|
||||
<span>{note}</span>
|
||||
</Tooltip>
|
||||
<span className="sr-only"> — {help}</span>
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</td>
|
||||
<td className="py-1.5 px-3 text-right tabular-nums">
|
||||
{!row.snapshot_exists ? (
|
||||
|
||||
@@ -2,6 +2,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
|
||||
import DiskSettings from "./DiskSettings";
|
||||
import type {
|
||||
DestructiveItem,
|
||||
DiskUsageReport,
|
||||
ProjectDiskRow,
|
||||
ReclaimItem,
|
||||
@@ -45,6 +46,9 @@ const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
|
||||
home_volume_present: true,
|
||||
config_volume_bytes: 427_000_000,
|
||||
config_volume_present: true,
|
||||
// The one figure the Snapshot column shows and the Total is built from.
|
||||
// 8.44 + 0.868 + 4.86 + 0.427 == 14.596, and the table is expected to add up.
|
||||
snapshot_attributed_bytes: 8_440_966_715,
|
||||
total_bytes: 14_595_966_715,
|
||||
migrating: false,
|
||||
...over,
|
||||
@@ -115,6 +119,26 @@ const result = (over: Partial<ReclaimResult> = {}): ReclaimResult => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
/** An orphaned volume, as `list_reclaimable` now describes it: a
|
||||
* `DestructiveItem`, never a `ReclaimItem`. `project_name` carries the
|
||||
* *volume* name, because there is no project to name — that is the definition
|
||||
* of the variant, and it is what `destroy` compares the typed string against. */
|
||||
const orphan = (over: Partial<DestructiveItem> = {}): DestructiveItem => ({
|
||||
target: {
|
||||
kind: "orphan_volume",
|
||||
name: "triple-c-claude-config-gone",
|
||||
project_id: "gone",
|
||||
},
|
||||
project_id: "gone",
|
||||
project_name: "triple-c-claude-config-gone",
|
||||
label: "triple-c-claude-config-gone (config volume)",
|
||||
loses:
|
||||
"Named for project id gone, which is not in Triple-C's project list, and no container is attached to it. Docker created it on 2026-03-14T09:00:00Z. This is a `.claude` volume — it held that project's Claude credential, plugins and session transcripts. Not recoverable. Type the volume name to confirm.",
|
||||
bytes: 900_000,
|
||||
blocked: null,
|
||||
...over,
|
||||
});
|
||||
|
||||
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
|
||||
items: [item()],
|
||||
destructive: [],
|
||||
@@ -196,14 +220,43 @@ describe("DiskSettings", () => {
|
||||
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
|
||||
it("builds the Snapshot column and the Total from the same attributed figure", async () => {
|
||||
// The bug this pins: the column rendered `snapshot_above_base_bytes` and
|
||||
// fell back to `—`, while the total was `snapshot_bytes -
|
||||
// snapshot_shared_bytes` regardless — which in the fallback branch is the
|
||||
// whole 4.7 GB base image, charged to every row and then added again as a
|
||||
// base-image row in the globals. One field, computed once in Rust.
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
|
||||
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("says a snapshot figure is the whole image rather than passing it off as a share", async () => {
|
||||
// `snapshot_above_base_bytes` is null in exactly one branch: nothing
|
||||
// measurably shares layers with the snapshot *and* its base is gone. The
|
||||
// attributed figure is then the whole image — an honest cost, not a guess
|
||||
// and not zero — but it does not mean what the other rows' figures mean,
|
||||
// so the sub-line has to say which one this is.
|
||||
getDockerDiskUsage.mockResolvedValue(
|
||||
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
|
||||
report({
|
||||
projects: [
|
||||
row({
|
||||
snapshot_shared_bytes: 0,
|
||||
snapshot_above_base_bytes: null,
|
||||
snapshot_attributed_bytes: 12_273_392_374,
|
||||
total_bytes: 18_428_392_374,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
);
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
|
||||
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
|
||||
expect(within(projectRow).getByText("12.3 GB")).toBeInTheDocument();
|
||||
expect(projectRow.textContent).toMatch(/whole image — base unknown/);
|
||||
// And it must not still claim the "N with base" split it cannot measure.
|
||||
expect(projectRow.textContent).not.toMatch(/with base/);
|
||||
});
|
||||
|
||||
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
|
||||
@@ -424,6 +477,79 @@ describe("DiskSettings", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("never offers an orphaned volume as a tick in the safe bucket", async () => {
|
||||
// It used to be a `ReclaimTarget` at `Safety::Safe` — a tick and the group
|
||||
// Reclaim button, no confirmation — for a volume holding a Claude
|
||||
// credential and every transcript a project ever had. The Rust variant is
|
||||
// gone; this pins that the frontend cannot resurrect it.
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const safe = await screen.findByTestId("disk-safe-bucket");
|
||||
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
|
||||
expect(safe.textContent).not.toMatch(/triple-c-claude-config-gone/);
|
||||
// And it is reachable — an item that matches no project row would
|
||||
// otherwise simply vanish from the UI.
|
||||
expect(await screen.findByTestId("disk-orphan-bucket")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps orphaned volumes out of the per-project table", async () => {
|
||||
// The table keys off `project_id`, and an orphan's id matches no row by
|
||||
// definition. Passing them in anyway is how one would leak into the wrong
|
||||
// project's overflow menu if a row ever shared the id.
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const projectRow = await screen.findByTestId("disk-row-p-whp");
|
||||
expect(projectRow.textContent).not.toMatch(/triple-c-claude-config-gone/);
|
||||
});
|
||||
|
||||
it("says what a config volume actually holds, not 'volume data'", async () => {
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
await renderAndScan();
|
||||
const bucket = await screen.findByTestId("disk-orphan-bucket");
|
||||
expect(bucket.textContent).toMatch(/Claude login credential/i);
|
||||
expect(bucket.textContent).toMatch(/every plugin and skill installed into it/i);
|
||||
expect(bucket.textContent).toMatch(/every conversation transcript it ever had/i);
|
||||
// The derivation caveat travels with the offer, not only with the totals.
|
||||
expect(bucket.textContent).toMatch(/not.*inferred from a project being stopped/i);
|
||||
});
|
||||
|
||||
it("confirms an orphaned volume against its own name, never a project's", async () => {
|
||||
listReclaimable.mockResolvedValue(plan({ destructive: [orphan()] }));
|
||||
destroyProjectDiskObject.mockResolvedValue({ results: [], total_freed_bytes: 0 });
|
||||
await renderAndScan();
|
||||
const bucket = await screen.findByTestId("disk-orphan-bucket");
|
||||
await act(async () => {
|
||||
fireEvent.click(within(bucket).getByRole("button", { name: /Delete/ }));
|
||||
});
|
||||
|
||||
const dialog = screen.getByRole("dialog");
|
||||
// Asking for "the exact project name" would be asking for a string that
|
||||
// does not exist.
|
||||
expect(within(dialog).getByRole("status")).toHaveTextContent(
|
||||
"Waiting for the exact volume name.",
|
||||
);
|
||||
const input = within(dialog).getByLabelText(/Type/);
|
||||
const confirm = within(dialog).getByRole("button", { name: "Delete volume" });
|
||||
|
||||
// The project id parsed out of the name is display only and must not open
|
||||
// the gate.
|
||||
fireEvent.change(input, { target: { value: "gone" } });
|
||||
expect(confirm).toBeDisabled();
|
||||
|
||||
fireEvent.change(input, { target: { value: "triple-c-claude-config-gone" } });
|
||||
expect(confirm).toBeEnabled();
|
||||
await act(async () => {
|
||||
fireEvent.click(confirm);
|
||||
});
|
||||
|
||||
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
|
||||
{ kind: "orphan_volume", name: "triple-c-claude-config-gone", project_id: "gone" },
|
||||
"triple-c-claude-config-gone",
|
||||
);
|
||||
// One volume, one confirmation — `reclaim` never sees it.
|
||||
expect(reclaim).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("explains a suppressed orphan list instead of showing an empty one", async () => {
|
||||
// With the project store unreadable every project's volumes look
|
||||
// unclaimed. Showing nothing is right; showing nothing *silently* is not.
|
||||
@@ -647,7 +773,7 @@ describe("DiskSettings", () => {
|
||||
result({ target: { kind: "migration_pins" } }),
|
||||
result({ target: { kind: "probe_containers" } }),
|
||||
result({ target: { kind: "build_cache", all: true }, ok: false }),
|
||||
result({ target: { kind: "orphan_volume", name: "v" }, ok: false }),
|
||||
result({ target: { kind: "scrub_containers" }, ok: false }),
|
||||
],
|
||||
total_freed_bytes: 1_200_000_000,
|
||||
});
|
||||
|
||||
@@ -13,6 +13,23 @@ function targetKey(target: ReclaimTarget): string {
|
||||
return JSON.stringify(target);
|
||||
}
|
||||
|
||||
/** The same, for a destructive object — never ticked, but still listed. */
|
||||
function destructiveKey(item: DestructiveItem): string {
|
||||
return JSON.stringify(item.target);
|
||||
}
|
||||
|
||||
/**
|
||||
* An orphaned volume is confirmed against **its own name**, not a project's.
|
||||
*
|
||||
* There is no project to name: the whole definition of the variant is that its
|
||||
* id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan
|
||||
* arm before it ever looks a project up. `DestructiveItem.project_name` carries
|
||||
* the volume name for exactly these items, which is what the gate compares.
|
||||
*/
|
||||
function isOrphanVolume(item: DestructiveItem): boolean {
|
||||
return item.target.kind === "orphan_volume";
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the disk went, and how to get it back.
|
||||
*
|
||||
@@ -25,17 +42,31 @@ function targetKey(target: ReclaimTarget): string {
|
||||
*
|
||||
* ## Why the buckets are separated the way they are
|
||||
*
|
||||
* Safe work (dangling images, ownerless pins, build cache, volumes whose
|
||||
* project id is not in the project store) gets one list of ticks and one
|
||||
* button, because none of it can lose anything a user has. Note what the last
|
||||
* of those is derived from: membership in Triple-C's own project list, never
|
||||
* "this project has no container" — an idle live project looks exactly like a
|
||||
* deleted one from the daemon's side, and mistaking the two would delete
|
||||
* credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a
|
||||
* re-download and is confirmed one at a time. Destructive work — a live
|
||||
* project's volumes, its snapshot, a live rollback pin — is not in either list:
|
||||
* it is reached only from that project's own row, behind a typed confirmation,
|
||||
* and the backend refuses it in bulk by taking a different type entirely.
|
||||
* Safe work (dangling images, ownerless pins, build cache) gets one list of
|
||||
* ticks and one button, because none of it can lose anything a user has.
|
||||
* Semi-safe work (compaction, cache clearing) is a rewrite or a re-download and
|
||||
* is confirmed one at a time. Destructive work — a live project's volumes, its
|
||||
* snapshot, a live rollback pin, **and an orphaned volume** — is not in either
|
||||
* list: it is reached one object at a time, behind a typed confirmation, and
|
||||
* the backend refuses it in bulk by taking a different type entirely.
|
||||
*
|
||||
* ## Why orphaned volumes are down there and not in the tick list
|
||||
*
|
||||
* They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group
|
||||
* Reclaim button, no confirmation. The object behind that tick is a
|
||||
* `triple-c-claude-config-*` volume holding a Claude OAuth credential, every
|
||||
* plugin and skill installed into that project, and every conversation
|
||||
* transcript it ever had — and the *same volume* for a project still in the
|
||||
* store required typing the project's name. The only difference between the two
|
||||
* is a lookup against `projects.json`, which this app has been wrong about
|
||||
* before: a second instance's project is absent from an in-memory list, a
|
||||
* corrupt store empties it, a restored data directory empties it too. It once
|
||||
* flagged two live projects as orphaned.
|
||||
*
|
||||
* So "no matching project" means one thing only — the id is not in the project
|
||||
* list. It is never inferred from a project being stopped, having no container
|
||||
* or having no image; an idle live project looks identical from the daemon's
|
||||
* side. Each volume is deleted on its own, against its own name typed out.
|
||||
*/
|
||||
export default function DiskSettings() {
|
||||
const {
|
||||
@@ -68,6 +99,14 @@ export default function DiskSettings() {
|
||||
if (!plan) setTicked(new Set());
|
||||
}, [plan]);
|
||||
|
||||
// Split before anything renders. The per-project table keys off
|
||||
// `project_id`, and an orphan's id matches no row by definition — so without
|
||||
// this split those items are simply invisible, which is how a variant that
|
||||
// moved from the tick list to the destructive list can vanish from the UI
|
||||
// entirely rather than reappear behind a confirmation.
|
||||
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
|
||||
const projectDestructive = plan?.destructive.filter((d) => !isOrphanVolume(d)) ?? [];
|
||||
|
||||
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
|
||||
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
|
||||
const selected = safeItems.filter(
|
||||
@@ -201,7 +240,7 @@ export default function DiskSettings() {
|
||||
</h3>
|
||||
<DiskProjectTable
|
||||
rows={report.projects}
|
||||
destructive={plan?.destructive ?? []}
|
||||
destructive={projectDestructive}
|
||||
onDestroy={openDestroying}
|
||||
/>
|
||||
</section>
|
||||
@@ -282,8 +321,9 @@ export default function DiskSettings() {
|
||||
volume’s project id is not in your project list — it is{" "}
|
||||
<em>not</em> inferred from a project being stopped or having no image. A project you have not opened in a
|
||||
while has no container and no snapshot either, and that is normal, so
|
||||
each of these is ticked individually and shows the date Docker created
|
||||
it.
|
||||
nothing here is deleted in a group: each one is listed below on its own,
|
||||
with the date Docker created it, and removing it takes typing that
|
||||
volume’s name.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-[var(--text-secondary)]">
|
||||
@@ -455,6 +495,72 @@ export default function DiskSettings() {
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
|
||||
{orphanVolumes.length > 0 && (
|
||||
<section className="space-y-2" data-testid="disk-orphan-bucket">
|
||||
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
|
||||
Volumes with no matching project
|
||||
</h3>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
A volume here is one whose project id is not in your project list. That
|
||||
is <em>all</em> it means — it is <em>not</em> inferred from a
|
||||
project being stopped, having no container or having no image. An idle
|
||||
live project looks exactly the same from Docker’s side, and that
|
||||
inference has already flagged two live projects here once.
|
||||
</p>
|
||||
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
|
||||
Deleting a{" "}
|
||||
<span className="font-mono">triple-c-claude-config-*</span> volume
|
||||
deletes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
the Claude login credential that project signed in with, every plugin
|
||||
and skill installed into it, and every conversation transcript it ever
|
||||
had
|
||||
</strong>
|
||||
. A <span className="font-mono">triple-c-home-*</span> volume holds its
|
||||
dotfiles, shell history and installed toolchains. There is no other copy
|
||||
of either and nothing regenerates, so each one is deleted on its own,
|
||||
against that volume’s name typed out — never as part of a
|
||||
group.
|
||||
</p>
|
||||
<ul className="space-y-1.5">
|
||||
{orphanVolumes.map((item) => (
|
||||
<li
|
||||
key={destructiveKey(item)}
|
||||
className="flex items-start justify-between gap-3"
|
||||
data-testid={`disk-orphan-${item.project_name}`}
|
||||
>
|
||||
<span className="flex-1 min-w-0">
|
||||
<span className="block text-[var(--text-primary)] font-mono break-all">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
|
||||
{item.loses}
|
||||
</span>
|
||||
{item.blocked && (
|
||||
<span className="block text-xs text-[var(--text-disabled)]">
|
||||
{item.blocked}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="flex items-center gap-2 whitespace-nowrap">
|
||||
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
|
||||
{formatBytes(item.bytes)}
|
||||
</span>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={item.blocked !== null || working}
|
||||
onClick={() => openDestroying(item)}
|
||||
>
|
||||
Delete…
|
||||
</Button>
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{/* --- Sweep ------------------------------------------------------ */}
|
||||
<section className="flex items-center gap-3 flex-wrap">
|
||||
<Button size="sm" disabled={working} onClick={runSweep}>
|
||||
@@ -590,11 +696,24 @@ export default function DiskSettings() {
|
||||
)}
|
||||
|
||||
{/* --- Destructive confirmation --------------------------------------- */}
|
||||
{destroying && (
|
||||
{destroying && (() => {
|
||||
// An orphaned volume has no project, so nothing about this dialog can
|
||||
// be phrased in terms of one: the gate takes the volume's own name (as
|
||||
// `disk.rs`'s `destroy` does), and the name is never lower-cased on its
|
||||
// way to the title, because the comparison the backend makes is
|
||||
// case-sensitive and a mangled name in the heading is a name the user
|
||||
// cannot type.
|
||||
const orphan = isOrphanVolume(destroying);
|
||||
return (
|
||||
<TypedConfirmModal
|
||||
title={`Delete ${destroying.label.toLowerCase()}`}
|
||||
title={
|
||||
orphan
|
||||
? `Delete volume ${destroying.project_name}`
|
||||
: `Delete ${destroying.label.toLowerCase()}`
|
||||
}
|
||||
expected={destroying.project_name}
|
||||
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
|
||||
subject={orphan ? "volume name" : "project name"}
|
||||
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
|
||||
busy={working}
|
||||
// A failure here has to land inside the dialog. The panel's own
|
||||
// error line is at the top of several screens of scroll, and this
|
||||
@@ -610,20 +729,39 @@ export default function DiskSettings() {
|
||||
if (ok) setDestroying(null);
|
||||
}}
|
||||
>
|
||||
<p>
|
||||
This removes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
’s {destroying.label.toLowerCase()}, freeing{" "}
|
||||
{formatBytes(destroying.bytes)}.
|
||||
</p>
|
||||
{orphan ? (
|
||||
<p>
|
||||
This removes the volume{" "}
|
||||
<strong className="text-[var(--text-primary)] font-mono break-all">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
, freeing {formatBytes(destroying.bytes)}. It is offered here for one
|
||||
reason only: no project in your list has its id. That is a lookup against
|
||||
a file, not a judgement about whether anything is using the volume.
|
||||
</p>
|
||||
) : (
|
||||
<p>
|
||||
This removes{" "}
|
||||
<strong className="text-[var(--text-primary)]">
|
||||
{destroying.project_name}
|
||||
</strong>
|
||||
’s {destroying.label.toLowerCase()}, freeing{" "}
|
||||
{formatBytes(destroying.bytes)}.
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[var(--error)]">{destroying.loses}</p>
|
||||
{orphan && (
|
||||
<p>
|
||||
Nothing here can undo this. If you recognise that project id, close this
|
||||
and leave the volume alone until you are certain.
|
||||
</p>
|
||||
)}
|
||||
<p>
|
||||
Your mounted project folders live on the host and are not affected by this.
|
||||
</p>
|
||||
</TypedConfirmModal>
|
||||
)}
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -71,6 +71,16 @@ describe("TypedConfirmModal", () => {
|
||||
expect(screen.getByRole("status")).toHaveTextContent("Name matches.");
|
||||
});
|
||||
|
||||
it("names what it is waiting for, when that is not a project", () => {
|
||||
// An orphaned volume has no project — its id matches nothing in the store,
|
||||
// which is the definition of the variant — so the gate takes the volume's
|
||||
// own name and must not ask for a string that does not exist.
|
||||
renderModal({ expected: "triple-c-claude-config-gone", subject: "volume name" });
|
||||
expect(screen.getByRole("status")).toHaveTextContent(
|
||||
"Waiting for the exact volume name.",
|
||||
);
|
||||
});
|
||||
|
||||
it("spells out what is lost, from the caller's copy", () => {
|
||||
renderModal();
|
||||
expect(screen.getByText("Everything goes.")).toBeInTheDocument();
|
||||
|
||||
@@ -7,6 +7,16 @@ interface Props {
|
||||
title: string;
|
||||
/** What must be typed, verbatim, before the confirm button enables. */
|
||||
expected: string;
|
||||
/**
|
||||
* What `expected` *is*, for the waiting message — "project name" unless the
|
||||
* caller says otherwise.
|
||||
*
|
||||
* An orphaned volume has no project by definition, so its gate takes the
|
||||
* volume's own name (that is what `disk.rs`'s `destroy` compares against),
|
||||
* and telling that user we are "waiting for the exact project name" would be
|
||||
* asking for a string that does not exist.
|
||||
*/
|
||||
subject?: string;
|
||||
/** The verb on the confirm button. Repeat the action — never "OK". */
|
||||
confirmLabel: string;
|
||||
/** What is about to be lost, in full. */
|
||||
@@ -46,6 +56,7 @@ interface Props {
|
||||
export default function TypedConfirmModal({
|
||||
title,
|
||||
expected,
|
||||
subject = "project name",
|
||||
confirmLabel,
|
||||
children,
|
||||
onConfirm,
|
||||
@@ -115,7 +126,7 @@ export default function TypedConfirmModal({
|
||||
// Not disabled content — the gate is live and waiting on the
|
||||
// user. `--text-disabled` is ~4.1:1 and fails AA at 12px.
|
||||
<span className="text-[var(--text-secondary)]">
|
||||
Waiting for the exact project name.
|
||||
Waiting for the exact {subject}.
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
|
||||
+41
-4
@@ -861,6 +861,22 @@ export interface ProjectDiskRow {
|
||||
home_volume_present: boolean;
|
||||
config_volume_bytes: number;
|
||||
config_volume_present: boolean;
|
||||
/** **The one snapshot figure a row adds up from.** The Snapshot column shows
|
||||
* this and `total_bytes` is computed from it, so the Total reconciles with
|
||||
* its parts. It did not before: the total used `snapshot_bytes -
|
||||
* snapshot_shared_bytes` unconditionally while the column fell back to
|
||||
* `snapshot_above_base_bytes` or to `—`, and in that fallback branch the
|
||||
* subtraction is the *whole base image* — 4.7 GB charged to every row.
|
||||
*
|
||||
* Rust computes it in one function (`snapshot_attribution`), in this order:
|
||||
* a `df()` shared size gives `size - shared`; failing that a known base
|
||||
* lineage gives the layer arithmetic; failing both it is the full size,
|
||||
* which is the honest answer for an image nothing shares with.
|
||||
*
|
||||
* It is always a number — never null. "Unknown" applies to
|
||||
* `snapshot_above_base_bytes` (the *split*, which really can be
|
||||
* unmeasurable) and to the layer count, not to this. */
|
||||
snapshot_attributed_bytes: number;
|
||||
total_bytes: number;
|
||||
migrating: boolean;
|
||||
}
|
||||
@@ -953,17 +969,38 @@ export type ReclaimTarget =
|
||||
| { kind: "migration_staging" }
|
||||
| { kind: "probe_containers" }
|
||||
| { kind: "scrub_containers" }
|
||||
| { kind: "orphan_volume"; name: string }
|
||||
| { kind: "compact_snapshot"; project_id: string }
|
||||
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
|
||||
|
||||
/** Mirrors Rust `DestructiveTarget`. Every one of these deletes something with
|
||||
* no other copy, and needs the project's name typed to confirm. */
|
||||
/** Mirrors Rust `DestructiveTarget`, an internally tagged enum (serde
|
||||
* `tag = "kind"`, snake_case). Every one of these deletes something with no
|
||||
* other copy, and needs a name typed to confirm — the *project's* name for
|
||||
* every variant except `orphan_volume`, which has no project and takes the
|
||||
* volume's own name. `DestructiveItem.project_name` carries whichever string
|
||||
* is the one to type. */
|
||||
export type DestructiveTarget =
|
||||
| { kind: "home_volume"; project_id: string }
|
||||
| { kind: "config_volume"; project_id: string }
|
||||
| { kind: "snapshot_image"; project_id: string }
|
||||
| { kind: "rollback_pin"; project_id: string; tag: string };
|
||||
| { kind: "rollback_pin"; project_id: string; tag: string }
|
||||
/** A `triple-c-home-*` / `triple-c-claude-config-*` volume whose project id
|
||||
* is in no `projects.json` this app can find.
|
||||
*
|
||||
* **This was a `ReclaimTarget` at `Safety::Safe`** — a tick and a group
|
||||
* Reclaim button, no confirmation at all. The object behind that tick is a
|
||||
* `triple-c-claude-config-*` volume holding a Claude OAuth credential,
|
||||
* every installed plugin and skill, and every conversation transcript that
|
||||
* project ever had; the *same volume* for a project still in the store
|
||||
* required typing the project's name. The only difference between the two
|
||||
* is a lookup against a file this app has been wrong about before — a
|
||||
* second instance's project is absent from an in-memory list, a corrupt
|
||||
* `projects.json` empties it, a restored data directory empties it too.
|
||||
*
|
||||
* `project_id` is parsed out of the volume name and is display only: it
|
||||
* names no project in the store, which is the entire definition of this
|
||||
* variant. Rust's `destroy` takes the orphan arm *before* looking a project
|
||||
* up, and compares the typed string against `name`. */
|
||||
| { kind: "orphan_volume"; name: string; project_id: string };
|
||||
|
||||
export interface ReclaimItem {
|
||||
target: ReclaimTarget;
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
errorText,
|
||||
FILE_EXISTS_MARKER,
|
||||
fileExistsPath,
|
||||
isFileExistsError,
|
||||
readableRefusal,
|
||||
} from "./uploadErrors";
|
||||
|
||||
/**
|
||||
@@ -79,3 +81,68 @@ describe("fileExistsPath", () => {
|
||||
expect(fileExistsPath(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The other half of the contract: refusals that are *not* a name clash, but are
|
||||
* a sentence the backend wrote for the person reading it. They used to arrive
|
||||
* as a toast's `detail`, which renders as collapsed monospace behind a
|
||||
* "Details" button — so the only part of the message that explained anything
|
||||
* was the part nobody saw.
|
||||
*/
|
||||
describe("readableRefusal", () => {
|
||||
const hidden =
|
||||
'".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.';
|
||||
const outside =
|
||||
"Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc";
|
||||
|
||||
it("recognises the hidden-host-folder refusal, in both directions", () => {
|
||||
expect(readableRefusal(hidden)).toBe(hidden);
|
||||
expect(
|
||||
readableRefusal('".aws" is a hidden folder — Triple-C will not read there. Choose a visible location.'),
|
||||
).toContain("hidden folder");
|
||||
});
|
||||
|
||||
it("recognises the container write-root refusal", () => {
|
||||
expect(readableRefusal(outside)).toBe(outside);
|
||||
});
|
||||
|
||||
it("strips a wrapper a JS layer put in front of the sentence", () => {
|
||||
// `invoke` rejects with the bare string today, but an `Error` anywhere in
|
||||
// between would otherwise put "Error: " in front of prose meant to be read.
|
||||
expect(readableRefusal(new Error(hidden))).toBe(hidden);
|
||||
expect(readableRefusal(`Error: ${hidden}`)).toBe(hidden);
|
||||
expect(readableRefusal(`Uncaught (in promise) Error: ${outside}`)).toBe(outside);
|
||||
expect(readableRefusal({ message: `invoke failed: ${outside}` })).toBe(outside);
|
||||
});
|
||||
|
||||
it("says nothing about failures that are not a written refusal", () => {
|
||||
// Promotion is an improvement, not a fallback: anything unrecognised keeps
|
||||
// reporting exactly as it did before.
|
||||
expect(readableRefusal("File too large to upload (900 MB; limit 256 MB)")).toBeNull();
|
||||
expect(readableRefusal("FILE_EXISTS: /workspace/a.txt already exists")).toBeNull();
|
||||
expect(readableRefusal("cp: Permission denied")).toBeNull();
|
||||
expect(readableRefusal(null)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("errorText", () => {
|
||||
it("keeps an ordinary message intact", () => {
|
||||
expect(errorText("cp: cannot create regular file: Permission denied")).toBe(
|
||||
"cp: cannot create regular file: Permission denied",
|
||||
);
|
||||
});
|
||||
|
||||
it("reads a message out of a shape `String()` would render as [object Object]", () => {
|
||||
expect(errorText({ message: "Container not running" })).toBe("Container not running");
|
||||
expect(errorText({ kind: "NotRunning" })).toBe("NotRunning");
|
||||
expect(errorText(new Error("Failed to upload file to container: no space left"))).toBe(
|
||||
"Failed to upload file to container: no space left",
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the written refusal when there is one", () => {
|
||||
expect(errorText(new Error("Folder path is outside the folders this panel can change (/workspace): /etc"))).toBe(
|
||||
"Folder path is outside the folders this panel can change (/workspace): /etc",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -114,3 +114,84 @@ export function fileExistsPath(e: unknown): string | null {
|
||||
* the kind of dialog people dismiss without reading.
|
||||
*/
|
||||
export type OverwriteChoice = "replace" | "skip" | "replace-all" | "skip-all";
|
||||
|
||||
/**
|
||||
* Fragments that identify a refusal the backend already wrote **for a person**.
|
||||
*
|
||||
* The file commands guard two policies that a user can trip over by accident,
|
||||
* and both answer with a finished sentence that names the offending path and
|
||||
* says what to do instead:
|
||||
*
|
||||
* ".ssh" is a hidden folder — Triple-C will not save there. Choose a visible location.
|
||||
* Folder path is outside the folders this panel can change (/workspace, /home/claude, /tmp): /etc
|
||||
*
|
||||
* Those sentences were being used as the *detail* of a generic toast
|
||||
* ("A file could not be uploaded"), and `ToastHost` renders a detail as
|
||||
* collapsed monospace behind a "Details" button — so the one part of the
|
||||
* message that explained anything was the part nobody saw. Matching them here
|
||||
* lets the caller promote the sentence to the toast's headline.
|
||||
*
|
||||
* Matched on a stable fragment rather than the whole string, because the path
|
||||
* and the verb ("save"/"read", "file"/"folder") vary per call. Deliberately a
|
||||
* short list: an error that is *not* recognised still reports exactly as it
|
||||
* did before, so a wrong guess here can only fail to promote, never mangle.
|
||||
*/
|
||||
const REFUSAL_MARKERS = [
|
||||
// `validate_host_path` — hidden host component, and system locations.
|
||||
"Triple-C will not",
|
||||
// `validate_container_write_path` — outside /workspace, /home/claude, /tmp.
|
||||
"outside the folders this panel can change",
|
||||
] as const;
|
||||
|
||||
/**
|
||||
* `Error: …`, `TypeError: …`, `invoke failed: …` — wrappers a JS layer may have
|
||||
* put in front of the backend's sentence on the way through. Stripped so the
|
||||
* prose starts where the backend started it; applied twice at most, because a
|
||||
* doubly-wrapped error is the realistic worst case and looping on user text is
|
||||
* not.
|
||||
*/
|
||||
const WRAPPER_PREFIX = /^(?:uncaught\s*(?:\(in promise\)\s*)?)?(?:[a-z]*error|invoke(?:\s+failed)?)\s*:\s*/i;
|
||||
|
||||
function stripWrapper(text: string): string {
|
||||
let out = text.trim();
|
||||
for (let i = 0; i < 2; i++) {
|
||||
const next = out.replace(WRAPPER_PREFIX, "").trim();
|
||||
if (next === out) break;
|
||||
out = next;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The backend's own user-facing sentence, when this failure is one — otherwise
|
||||
* `null`, and the caller reports it however it reported everything else.
|
||||
*/
|
||||
export function readableRefusal(e: unknown): string | null {
|
||||
for (const s of stringsIn(e)) {
|
||||
const text = stripWrapper(s);
|
||||
if (REFUSAL_MARKERS.some((marker) => text.includes(marker))) return text;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The most human form of any failure, for the places that show one verbatim.
|
||||
*
|
||||
* `String(e)` is what these used to be, which turns a serialised error object
|
||||
* into `[object Object]` and leaves a JS wrapper prefix on a sentence that
|
||||
* reads perfectly well without it.
|
||||
*/
|
||||
export function errorText(e: unknown): string {
|
||||
const readable = readableRefusal(e);
|
||||
if (readable) return readable;
|
||||
if (typeof e === "string") return stripWrapper(e);
|
||||
if (e instanceof Error) return stripWrapper(e.message);
|
||||
const record = asRecord(e);
|
||||
if (record) {
|
||||
for (const field of [...MESSAGE_FIELDS, ...KIND_FIELDS]) {
|
||||
const value = record[field];
|
||||
if (typeof value === "string" && value.trim().length > 0) return stripWrapper(value);
|
||||
}
|
||||
}
|
||||
return String(e);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user