Merge branch 'fix/reconcile' into integration/round-1
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 && (
|
||||
{/* `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)]">
|
||||
{/* 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
|
||||
{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,6 +729,17 @@ export default function DiskSettings() {
|
||||
if (ok) setDestroying(null);
|
||||
}}
|
||||
>
|
||||
{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)]">
|
||||
@@ -618,12 +748,20 @@ export default function DiskSettings() {
|
||||
’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;
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useProjects } from "./useProjects";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { Project, ProjectStatus } from "../lib/types";
|
||||
|
||||
const startProjectContainer = vi.fn();
|
||||
const stopProjectContainer = vi.fn();
|
||||
const rebuildProjectContainer = vi.fn();
|
||||
const listProjects = vi.fn();
|
||||
|
||||
vi.mock("../lib/tauri-commands", () => ({
|
||||
startProjectContainer: (id: string) => startProjectContainer(id),
|
||||
stopProjectContainer: (id: string) => stopProjectContainer(id),
|
||||
rebuildProjectContainer: (id: string) => rebuildProjectContainer(id),
|
||||
listProjects: () => listProjects(),
|
||||
addProject: vi.fn(),
|
||||
removeProject: vi.fn(),
|
||||
updateProject: vi.fn(),
|
||||
}));
|
||||
|
||||
const project = (status: ProjectStatus): Project =>
|
||||
({ id: "p1", name: "whp", status }) as unknown as Project;
|
||||
|
||||
/** The status the sidebar row and Project Home both read. */
|
||||
const statusOf = () => useAppState.getState().projects.find((p) => p.id === "p1")?.status;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useAppState.setState({ projects: [project("running")] });
|
||||
});
|
||||
|
||||
/**
|
||||
* The optimistic status is what makes a click move the row immediately. It is
|
||||
* also what strands the row when the command it was betting on never ran.
|
||||
*
|
||||
* Since every lifecycle command started taking the per-project lock and failing
|
||||
* fast, all three can be refused *before* the backend changes anything — and
|
||||
* `stop` could not fail this way at all before, because it took no exclusion.
|
||||
* `isTransitioning` disables both Start and Stop, and the only thing that
|
||||
* clears it is `reconcileProjectStatuses`, which runs once when Docker first
|
||||
* appears. So a stale optimistic status is not a cosmetic problem: it is a row
|
||||
* the user cannot operate again until the app is restarted.
|
||||
*/
|
||||
describe("useProjects puts the status back when a refused command never ran", () => {
|
||||
const refusal =
|
||||
"This project's snapshot is being compacted. Wait for it to finish before starting or recreating its container.";
|
||||
|
||||
it("does not leave a refused Stop showing 'stopping' forever", async () => {
|
||||
stopProjectContainer.mockRejectedValue(refusal);
|
||||
// The lock is taken before `update_status`, so the backend still holds the
|
||||
// truth — which is why re-reading it is the correction, not a guess.
|
||||
listProjects.mockResolvedValue([project("running")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.stop("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("does not leave a refused Start showing 'starting' forever", async () => {
|
||||
useAppState.setState({ projects: [project("stopped")] });
|
||||
startProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockResolvedValue([project("stopped")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.start("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
|
||||
it("does not leave a refused Reset showing 'starting' forever", async () => {
|
||||
rebuildProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockResolvedValue([project("running")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.rebuild("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("falls back to what was on screen when even the re-read fails", async () => {
|
||||
// Two failures in a row must not land on the one state there is no way out
|
||||
// of. The backend's answer is preferred, but "unknown" is never a reason to
|
||||
// keep showing a transition that is not happening.
|
||||
stopProjectContainer.mockRejectedValue(refusal);
|
||||
listProjects.mockRejectedValue("Docker is not running");
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.stop("p1")).rejects.toBe(refusal);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("running");
|
||||
});
|
||||
|
||||
it("prefers the backend's answer over the status it captured", async () => {
|
||||
// A start that dies half-way really has changed the world, so the captured
|
||||
// status would be a lie. `listProjects` is the thing that knows.
|
||||
useAppState.setState({ projects: [project("stopped")] });
|
||||
startProjectContainer.mockRejectedValue("container exited during startup");
|
||||
listProjects.mockResolvedValue([project("error")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
await act(async () => {
|
||||
await expect(result.current.start("p1")).rejects.toBe(
|
||||
"container exited during startup",
|
||||
);
|
||||
});
|
||||
|
||||
expect(statusOf()).toBe("error");
|
||||
});
|
||||
|
||||
it("still paints the optimistic status on the way in", async () => {
|
||||
let release: (() => void) | undefined;
|
||||
stopProjectContainer.mockReturnValue(
|
||||
new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
}),
|
||||
);
|
||||
listProjects.mockResolvedValue([project("stopped")]);
|
||||
|
||||
const { result } = renderHook(() => useProjects());
|
||||
let pending: Promise<void> | undefined;
|
||||
act(() => {
|
||||
pending = result.current.stop("p1");
|
||||
});
|
||||
expect(statusOf()).toBe("stopping");
|
||||
|
||||
await act(async () => {
|
||||
release?.();
|
||||
await pending;
|
||||
});
|
||||
expect(statusOf()).toBe("stopped");
|
||||
});
|
||||
});
|
||||
@@ -2,7 +2,7 @@ import { useCallback } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
import { useAppState } from "../store/appState";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import type { ProjectPath } from "../lib/types";
|
||||
import type { ProjectPath, ProjectStatus } from "../lib/types";
|
||||
|
||||
export function useProjects() {
|
||||
const {
|
||||
@@ -61,34 +61,85 @@ export function useProjects() {
|
||||
[updateProjectInList],
|
||||
);
|
||||
|
||||
/**
|
||||
* Paint the optimistic status, run the command, and **put the status back if
|
||||
* the command never happened.**
|
||||
*
|
||||
* The optimistic write exists so a click moves the row immediately. It used
|
||||
* to be safe to leave in place on failure, because the only way these three
|
||||
* commands could fail was after the backend had already started changing
|
||||
* things — so a stale "starting" was at worst premature.
|
||||
*
|
||||
* That stopped being true when every lifecycle command started taking the
|
||||
* per-project lock and failing fast: a compaction, a reset or another start
|
||||
* holding the project now refuses `start`, `stop` **and** `rebuild` before
|
||||
* one byte of state changes. `stop` in particular could not fail this way at
|
||||
* all before — it took no exclusion. The optimistic paint then has nothing to
|
||||
* become, and `isTransitioning` disables both Start and Stop, so the row is
|
||||
* stuck: the only thing that clears it is `reconcileProjectStatuses`, which
|
||||
* runs once, from `App.tsx`, when Docker first appears. A restart.
|
||||
*
|
||||
* Re-reading the list is preferred over restoring what was on screen, because
|
||||
* a refusal is not the only way these throw — a start that dies half-way
|
||||
* really has changed the world, and `listProjects` is the thing that knows.
|
||||
* The captured status is only the fallback for when that call fails too:
|
||||
* leaving the row transitioning is the one outcome the user cannot get out
|
||||
* of, so it must not be what a second failure lands on.
|
||||
*/
|
||||
const withOptimisticStatus = useCallback(
|
||||
async <T,>(
|
||||
id: string,
|
||||
status: "starting" | "stopping",
|
||||
run: () => Promise<T>,
|
||||
): Promise<T> => {
|
||||
const previous: ProjectStatus | null =
|
||||
useAppState.getState().projects.find((p) => p.id === id)?.status ?? null;
|
||||
setOptimisticStatus(id, status);
|
||||
try {
|
||||
return await run();
|
||||
} catch (e) {
|
||||
try {
|
||||
setProjects(await commands.listProjects());
|
||||
} catch {
|
||||
const project = useAppState.getState().projects.find((p) => p.id === id);
|
||||
if (project && previous) updateProjectInList({ ...project, status: previous });
|
||||
}
|
||||
// Rethrown unchanged: `useProjectActions` is what turns this into a
|
||||
// toast, and it must still see the original failure.
|
||||
throw e;
|
||||
}
|
||||
},
|
||||
[setOptimisticStatus, setProjects, updateProjectInList],
|
||||
);
|
||||
|
||||
const start = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.startProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const stop = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "stopping");
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "stopping", async () => {
|
||||
await commands.stopProjectContainer(id);
|
||||
const list = await commands.listProjects();
|
||||
setProjects(list);
|
||||
},
|
||||
[setProjects, setOptimisticStatus],
|
||||
}),
|
||||
[setProjects, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const rebuild = useCallback(
|
||||
async (id: string) => {
|
||||
setOptimisticStatus(id, "starting");
|
||||
(id: string) =>
|
||||
withOptimisticStatus(id, "starting", async () => {
|
||||
const updated = await commands.rebuildProjectContainer(id);
|
||||
updateProjectInList(updated);
|
||||
return updated;
|
||||
},
|
||||
[updateProjectInList, setOptimisticStatus],
|
||||
}),
|
||||
[updateProjectInList, withOptimisticStatus],
|
||||
);
|
||||
|
||||
const update = useCallback(
|
||||
|
||||
+71
-10
@@ -52,7 +52,13 @@ export interface Project {
|
||||
* certificate file or a directory of them). null falls back to
|
||||
* `AppSettings.ca_cert_path`. Changing it recreates the container. */
|
||||
ca_cert_path: string | null;
|
||||
git_token: string | null;
|
||||
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||
* absent from every project the backend hands back — reading it gives
|
||||
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||
* `=== null` test against it is a compile error rather than a branch that
|
||||
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||
* is set. */
|
||||
git_token?: string | null;
|
||||
git_user_name: string | null;
|
||||
git_user_email: string | null;
|
||||
custom_env_vars: EnvVar[];
|
||||
@@ -96,11 +102,23 @@ export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token
|
||||
export interface BedrockConfig {
|
||||
auth_method: BedrockAuthMethod;
|
||||
aws_region: string;
|
||||
aws_access_key_id: string | null;
|
||||
aws_secret_access_key: string | null;
|
||||
aws_session_token: string | null;
|
||||
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||
* absent from every project the backend hands back — reading it gives
|
||||
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||
* `=== null` test against it is a compile error rather than a branch that
|
||||
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||
* is set. */
|
||||
aws_access_key_id?: string | null;
|
||||
aws_secret_access_key?: string | null;
|
||||
aws_session_token?: string | null;
|
||||
aws_profile: string | null;
|
||||
aws_bearer_token: string | null;
|
||||
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||
* absent from every project the backend hands back — reading it gives
|
||||
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||
* `=== null` test against it is a compile error rather than a branch that
|
||||
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||
* is set. */
|
||||
aws_bearer_token?: string | null;
|
||||
model_id: string | null;
|
||||
disable_prompt_caching: boolean;
|
||||
service_tier: string | null;
|
||||
@@ -127,7 +145,13 @@ export interface LlamaCppConfig {
|
||||
* implement the **Anthropic** Messages API — e.g. LiteLLM. */
|
||||
export interface OpenAiCompatibleConfig {
|
||||
base_url: string;
|
||||
api_key: string | null;
|
||||
/** **Write-only.** Rust marks this `#[serde(skip_serializing)]`, so it is
|
||||
* absent from every project the backend hands back — reading it gives
|
||||
* `undefined`, never `null`. Optional here so that is the type, and so a
|
||||
* `=== null` test against it is a compile error rather than a branch that
|
||||
* silently never runs. Still sent on the way *in*: this is how the secret
|
||||
* is set. */
|
||||
api_key?: string | null;
|
||||
model_id: string | null;
|
||||
/** See `OllamaConfig.haiku_model_id`. */
|
||||
haiku_model_id: string | null;
|
||||
@@ -861,6 +885,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 +993,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