Hold back the Disk panel and OS drag-out from the ship branch

This is a scope reduction, not an abandonment. Both subsystems are
preserved in full on `hold/disk-and-dragout` and are intended to come
back once they have been hardened separately. Nothing here is a
judgement that the features are unwanted — three successive
audit-and-fix cycles each closed a critical defect in these two areas
and each opened a new one, so the rest of the round ships now and these
two get their own cycle rather than holding it up.

Removed: the Disk settings panel and its whole reclaim / destroy /
compaction surface — `DiskSettings`, `DiskProjectTable`, `useDiskUsage`,
`docker/disk.rs`, `disk_tests.rs`, the disk commands in
`docker_commands.rs`, and their `generate_handler!` entries. Dropping
the IPC entries is the point: a UI-only removal would have left five
commands callable by a compromised webview, one of them a verified
arbitrary-DELETE primitive. `sweep_orphaned_snapshots`'s *command* goes
with them (the panel was its only caller); the sweep itself stays.

Removed: OS drag-out from the Files tab — `stage_container_file_for_drag`
and its host staging lifecycle, the pointer gesture and `dragPreview`,
`stageForDrag` / `isStagedHostPath`, the `tauri-plugin-drag` and
`@crabnebula/tauri-plugin-drag` dependencies, and the
`drag:allow-start-drag` capability grant, which could not be scoped.
The capability test's expected list is updated; its `*:default` and
`store:*` assertions are untouched.

Kept, deliberately: drag-and-drop *into* the app (Files pane and
terminal) and "Save to host…", which is now the only route out of a
container. The prevention work is untouched — the pre-commit scrub and
`SNAPSHOT_SCRUB_PATHS`, capped container logs, the `triple-c.base` /
`triple-c.managed` labels, `sweep_orphaned_snapshots` and the startup
housekeeping, the migration pin/probe reapers, scheduler log pruning,
`formatBytes.ts`, and `project_lock.rs` in full with every acquisition
site outside `disk.rs`.

Entanglements, resolved rather than deleted blind:
* `container.rs`'s `a_compaction_runs_this_module_s_scrub_script_byte_for_byte`
  pinned the compaction Dockerfile against `snapshot_scrub_script()`.
  Dropped — it existed only for compaction. `snapshot_scrub_script` and
  its containment tests are untouched.
* `lib.rs`'s startup reap of `:compacting` tags and `triple-c-compact-*`
  containers is dropped: nothing on this branch creates them.
* `project_lock`'s `Compaction` / `CacheClear` variants and
  `any_held_excluding`, `migration_commands::is_migrating`, and
  `formatBytes{Delta,Ceiling}` lose their last production caller but are
  kept and still tested, annotated with why.
* `projects_store::corrupt_since` and `migration_store::peek_ownerless_since`
  were read only by the disk survey and are removed. The corrupt-load
  marker and `.bak` are still written.

Verified: `npm run test` 611 passing, `npx tsc --noEmit` clean,
`npm run build` green; `cargo test` 419 passed / 2 ignored,
`cargo build` 0 warnings. Every test removed belongs to a removed
feature — no kept-behaviour test was weakened or deleted.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 15:20:22 -07:00
co-authored by Claude Opus 5
parent 6a8972980d
commit ed91423666
41 changed files with 126 additions and 11404 deletions
@@ -9,7 +9,6 @@ const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt");
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -19,19 +18,6 @@ vi.mock("../../../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/**
* The OS-level drag. Nothing in jsdom can start one, so it is only observed —
* including its `onEvent` channel, which is how the plugin reports that the
* gesture ended and therefore how the pane knows to start accepting drops
* again. `endDragOut` below drives it.
*/
type DragCallback = (payload: { result: "Dropped" | "Cancelled" }) => void;
const startDrag = vi.fn(async (_opts: unknown, _onEvent?: DragCallback) => {});
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
}));
/** Transient failures land in `ToastHost`, not in an inline string. */
@@ -103,39 +89,6 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) {
});
}
/**
* A pointer event carrying real coordinates.
*
* jsdom implements no `PointerEvent` and Testing Library's synthesized one has
* no coordinates — which is the whole gesture here, since the drag only starts
* once the pointer has travelled past the threshold. `MouseEvent` has them, and
* React dispatches on the type name either way.
*/
function pointer(el: Element, type: string, clientX: number, clientY: number) {
fireEvent(
el,
new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }),
);
}
/** Press on a row and move far enough to become a drag, leaving the button down. */
function dragRow(el: Element) {
pointer(el, "pointerdown", 10, 10);
pointer(el, "pointermove", 60, 10);
}
/**
* Tell the pane the OS finished with the drag it started — what the plugin's
* `onEvent` channel does for real. Until this arrives the pane deliberately
* ignores drops, because a drag-out released back over the app arrives as one.
*/
function endDragOut(result: "Dropped" | "Cancelled" = "Dropped") {
const onEvent = startDrag.mock.calls.at(-1)?.[1];
act(() => {
onEvent?.({ result });
});
}
/** Every row that is part of the grid's roving tabindex, in order. */
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
/** The rows that are actually tab stops. There must never be more than one. */
@@ -153,8 +106,6 @@ function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt");
startDrag.mockResolvedValue(undefined);
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
@@ -167,10 +118,6 @@ beforeEach(() => {
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
// Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom
// would return anyway, minus the "not implemented" noise on every drag —
// `dragPreview.test.ts` covers what the fallback then produces.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
describe("FilesTab listing", () => {
@@ -503,191 +450,6 @@ describe("FilesTab save to host", () => {
});
});
describe("FilesTab drag-out", () => {
it("stages the file on the host and starts the native drag on that copy", async () => {
// The container path is not draggable — only the host copy is — so the
// thing handed to the OS must be what staging returned.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
expect(startDrag).toHaveBeenCalledWith(
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
// The `onEvent` channel: without it, "the drag finished" is unobservable
// and a drag released back over the pane reads as a host drop.
expect.any(Function),
);
});
it("never drags a directory, which cannot be staged as one file", async () => {
await renderTab();
dragRow(screen.getByText("src").closest("tr")!);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
expect(startDrag).not.toHaveBeenCalled();
});
it("stays a click until the pointer has actually travelled", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
pointer(row, "pointerdown", 10, 10);
pointer(row, "pointermove", 12, 11);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
});
it("says what went wrong instead of leaving a gesture that did nothing", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(toastText()).toContain("too large"));
expect(startDrag).not.toHaveBeenCalled();
});
it("points at the fallback when the platform refuses the drag itself", async () => {
startDrag.mockRejectedValue("drag image not found");
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(toastText()).toContain("Save to host"));
});
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
// Staging is a whole-file copy, and the OS only adopts a drag while the
// button is down. Releasing mid-copy used to be — and must not be — a
// gesture that did nothing and explained nothing.
let release: (path: string) => void = () => {};
stageContainerFileForDrag.mockReturnValue(
new Promise<string>((resolve) => {
release = resolve;
}),
);
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
pointer(row, "pointerup", 60, 10);
await act(async () => {
release("/tmp/triple-c-drag-out/s1/notes.txt");
await Promise.resolve();
});
await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt"));
expect(startDrag).not.toHaveBeenCalled();
});
it("drags immediately on the retry, reusing the copy it already made", async () => {
// The instruction "drag it again" is only honest if the second attempt does
// not repeat the copy that made the first one too slow.
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1));
pointer(row, "pointerup", 60, 10);
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2));
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
});
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByLabelText("Save to host… — notes.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
expect(startDrag).not.toHaveBeenCalled();
});
it("still accepts a drop into the pane — the two directions coexist", async () => {
// The drag-out gesture is pointer-driven precisely so it does not need the
// HTML5 machinery that Tauri's native drop listener rules out.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
// The OS is done with it — anything arriving now is a genuine host drop.
endDragOut();
await drop(["/host/a.txt"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
});
});
describe("FilesTab drag-out released back over the app", () => {
it("does not re-import its own staged copy while the drag is in flight", async () => {
// The damaging case, and the reason this is HIGH: the staged copy is keyed
// off the *last listing*, so uploading it back is not even idempotent — a
// file an agent rewrote since then would be replaced by a stale snapshot.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("still refuses the staged copy after the drag has ended", async () => {
// Second line of defence, and the one that survives a platform whose
// `onEvent` never arrives: the path is known to be ours, exactly.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
endDragOut("Cancelled");
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("uploads the rest of a mixed drop, minus our own copy", async () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
endDragOut();
await drop(["/tmp/triple-c-drag-out/s1/notes.txt", "/host/real.png"]);
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/real.png", "/workspace");
});
it("does not offer to accept files during its own export", async () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
await act(async () => {
await dragHandler?.({ payload: { type: "enter", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
// …and it comes back once the gesture is over.
endDragOut();
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
});
});
describe("FilesTab drop hit test", () => {
it("uploads nothing when a dialog is covering the pane", async () => {
// The pane still has its rect underneath the viewer's `fixed inset-0`
+5 -193
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { startDrag } from "@crabnebula/tauri-plugin-drag";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
@@ -8,32 +7,12 @@ import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import OverwriteConfirmModal from "./OverwriteConfirmModal";
import { dragPreviewIcon } from "./dragPreview";
import { formatBytes } from "./format";
interface Props {
project: Project;
}
/**
* How far the pointer must travel before a press becomes a drag. Same few
* pixels of slop as the tab strip, so a click that trembles stays a click.
*/
const DRAG_THRESHOLD = 4;
/**
* Belt and braces for the in-flight drag-out flag.
*
* The flag is cleared by the drag plugin's own `onEvent` channel, which fires
* `Dropped` or `Cancelled` for every gesture the OS finishes. A platform that
* never fires it would leave the flag stuck and this pane deaf to drops, so it
* also times out. Long enough that a deliberate, slow drag across two monitors
* is not cut short; short enough that a wedged flag heals within one coffee
* sip. The staged-path filter below is the real protection either way — this
* only decides how long the *hint* stays suppressed.
*/
const DRAG_OUT_WATCHDOG_MS = 30_000;
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
const PARENT_ROW = "..";
@@ -73,8 +52,6 @@ export default function FilesTab({ project }: Props) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
} = useFileManager(project.id);
@@ -90,8 +67,6 @@ export default function FilesTab({ project }: Props) {
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
const [dragNotice, setDragNotice] = useState<string | null>(null);
/** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null);
@@ -108,7 +83,6 @@ export default function FilesTab({ project }: Props) {
useEffect(() => {
setSelected(null);
setRenaming(null);
setDragNotice(null);
}, [currentPath]);
useEffect(() => {
@@ -260,153 +234,6 @@ export default function FilesTab({ project }: Props) {
goUp();
}, [currentPath, goUp]);
// Container → host drag-out.
//
// The mirror image of the drop path below, and it has the same constraint
// pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so
// `draggable` + `DataTransfer` is not available and the gesture is driven
// from pointer events into the native drag plugin — exactly the shape the tab
// strip uses, and for the same reason.
//
// What makes it more than a pointer gesture is that the file being dragged
// does not exist on the host at all: it lives in the container, and the OS
// can only drag a real host path. So every drag-out is a copy first (see
// `stageForDrag`) and a drag second, which is why the gesture has an async
// gap in the middle of something that feels instantaneous.
const dragOut = useRef<{
path: string;
x: number;
y: number;
down: boolean;
started: boolean;
} | null>(null);
/**
* A drag-out the OS has taken and not yet finished.
*
* Without this, releasing a drag-out back over the Files pane fed the app its
* own export as if it were a host drop: the staged copy was uploaded straight
* back over the container file it came from. Not even idempotent — the staged
* copy is cached against the *last listing*, so a file rewritten in the
* container since then was replaced by a minutes-old snapshot. The `enter`
* and `over` branches consult it too, so the pane does not offer to accept
* files during its own export.
*
* Cleared from the drag plugin's `onEvent` channel, which reports `Dropped`
* or `Cancelled` when the gesture ends — the installed
* `@crabnebula/tauri-plugin-drag` (2.1.0) takes it as `startDrag`'s second
* argument. `startDrag`'s own promise is *not* the signal: on some platforms
* it resolves as soon as the OS adopts the drag, i.e. while it is still in
* flight. See `DRAG_OUT_WATCHDOG_MS` for what happens if `onEvent` never
* arrives.
*/
const dragOutInFlight = useRef(false);
const dragOutWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const endDragOut = useCallback(() => {
dragOutInFlight.current = false;
if (dragOutWatchdog.current !== null) {
clearTimeout(dragOutWatchdog.current);
dragOutWatchdog.current = null;
}
}, []);
useEffect(() => endDragOut, [endDragOut]);
// Pointer-up almost never lands on the row it started on — the pointer has
// moved off it by definition, and once the OS takes the drag the webview stops
// seeing the pointer at all, which is what makes a lost focus the only
// "the button came up" signal left.
useEffect(() => {
const release = () => {
if (dragOut.current) dragOut.current.down = false;
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
return () => {
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
};
}, []);
const beginDragOut = useCallback(
async (entry: FileEntry) => {
setDragNotice(null);
const staged = await stageForDrag(entry);
// `stageForDrag` has already reported the reason through the toast host.
if (!staged) return;
// The OS only adopts a drag while the button is still down, and the copy
// that just ran can easily outlast a flick of the wrist. Say so rather
// than leaving a gesture that did nothing and explained nothing — and it
// is a real instruction, not an apology: the copy is kept, so the second
// attempt starts immediately.
if (dragOut.current?.path !== entry.path || !dragOut.current.down) {
setDragNotice(entry.name);
return;
}
dragOutInFlight.current = true;
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
try {
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }, () =>
endDragOut(),
);
} catch (e) {
endDragOut();
// Drag-out is the enhancement; "Save to host…" is the path that always
// works, so a platform that refuses the drag says where to go instead.
useAppState.getState().pushToast({
kind: "error",
message: 'Could not start the drag — use "Save to host…" instead.',
detail: String(e),
});
}
},
[stageForDrag, endDragOut],
);
/**
* Pointer wiring for one row. Directories get none of it: staging copies a
* single regular file, and a folder would only ever produce an error.
*/
const dragOutProps = (entry: FileEntry) => {
if (entry.is_directory) return {};
return {
onPointerDown: (e: React.PointerEvent<HTMLTableRowElement>) => {
if (e.button !== 0 || renaming === entry.name) return;
// The row's own controls, and the rename input, where a drag is a text
// selection.
if ((e.target as HTMLElement).closest("button, input")) return;
dragOut.current = {
path: entry.path,
x: e.clientX,
y: e.clientY,
down: true,
started: false,
};
// Deliberately no `setPointerCapture` — unlike the tab strip, which
// draws its own ghost. Here the OS has to take the pointer over, and a
// capture held in the webview is exactly what stops it.
},
onPointerMove: (e: React.PointerEvent<HTMLTableRowElement>) => {
const gesture = dragOut.current;
if (!gesture || gesture.started || !gesture.down) return;
if (gesture.path !== entry.path) return;
if (
Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD &&
Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD
) {
return;
}
gesture.started = true;
void beginDragOut(entry);
},
};
};
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
@@ -418,10 +245,6 @@ export default function FilesTab({ project }: Props) {
// point? (Not "is that element mine": chrome painted over a pane — a toast,
// a button — is not something that swallows a drop, and treating it as such
// made permanent dead zones.)
//
// Two further filters sit in front of it, both about our own drag-out:
// `dragOutInFlight`, and the staged-path check, which is exact because
// `useFileManager` remembers every host path it staged.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
@@ -435,14 +258,11 @@ export default function FilesTab({ project }: Props) {
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
);
setDragOver(isDropTarget(paneRef.current, payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
if (dragOutInFlight.current) return;
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
@@ -460,10 +280,7 @@ export default function FilesTab({ project }: Props) {
return;
}
if (verdict !== "accept") return;
// Anything we staged for a drag-out is our own copy of a file that is
// already in the container; re-importing it would overwrite the
// original with a snapshot.
const paths = (payload.paths ?? []).filter((path) => !isStagedHostPath(path));
const paths = payload.paths ?? [];
if (paths.length === 0) return;
await uploadPaths(paths);
});
@@ -475,7 +292,7 @@ export default function FilesTab({ project }: Props) {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths, isStagedHostPath]);
}, [running, uploadPaths]);
const breadcrumbs =
currentPath === "/"
@@ -518,11 +335,7 @@ export default function FilesTab({ project }: Props) {
* frequently not announced at all, which is how "uploading 3 items…" and
* every completion notice used to go by in silence.
*/
const liveText = busy
? busy
: dragNotice
? `"${dragNotice}" is ready — drag it again to drop it on the desktop.`
: (completed ?? "");
const liveText = busy ? busy : (completed ?? "");
return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
@@ -568,7 +381,7 @@ export default function FilesTab({ project }: Props) {
{/* The one failure that stays inline: it explains why the grid below is
empty, it is in context, and there are no rows for it to scroll
behind. Every *transient* failure — upload, rename, mkdir,
save-to-host, staging — goes to `ToastHost` instead, which is above
save-to-host — goes to `ToastHost` instead, which is above
the file viewer's overlay and does not scroll away. */}
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
@@ -665,7 +478,6 @@ export default function FilesTab({ project }: Props) {
setActiveRow(entry.name);
}}
onDoubleClick={() => openEntry(entry)}
{...dragOutProps(entry)}
onKeyDown={(e) => {
if (isRenaming) return;
if (e.key === "Enter") {
@@ -79,7 +79,7 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
/**
* Which write to `status` is the newest — the same "is this still mine?"
* guard `useDiskUsage` and `useContainerMigration` use around their async
* guard `useContainerMigration` uses around its async
* writes, and needed here for a reason that is easy to miss.
*
* There are two sources of truth for this row and only one of them is
@@ -1,79 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { dragPreviewIcon } from "./dragPreview";
afterEach(() => {
vi.restoreAllMocks();
});
describe("dragPreviewIcon", () => {
it("falls back to a PNG data URL when there is no 2D context", () => {
// jsdom has no canvas, and a webview can refuse one. `startDrag` requires
// an image and the Rust side accepts nothing but a PNG data URL, so a
// fallback that is not one takes the whole drag down with it.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("falls back rather than throwing when the canvas throws", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("no canvas here");
});
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,/);
});
it("refuses a canvas that encoded nothing", () => {
// jsdom's `toDataURL` answers `data:,` — which the Rust side rejects
// outright, so returning it would be worse than not drawing at all.
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:,");
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("uses what the canvas drew when there is one", () => {
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
const drawn = "data:image/png;base64,AAAA";
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(drawn);
expect(dragPreviewIcon("notes.txt")).toBe(drawn);
// A long name is elided rather than drawn off the edge of the preview.
expect(dragPreviewIcon("a-really-quite-long-file-name-indeed.txt")).toBe(drawn);
expect(ctx.fillText).toHaveBeenLastCalledWith(
expect.stringContaining("…"),
expect.any(Number),
expect.any(Number),
);
});
});
@@ -1,85 +0,0 @@
/**
* The image the OS shows under the cursor during a drag-out.
*
* `startDrag` requires one — the plugin's `image` argument is not optional, and
* it only accepts a `data:image/png;base64,` URL — so this is drawn rather than
* shipped as an asset. Drawing it is also what keeps the colours honest: the
* palette lives in CSS custom properties, and reading them off the document is
* the only way a raw-pixel preview can still come from the design tokens rather
* than from hard-coded hexes.
*/
/**
* A 1x1 transparent PNG, used when no 2D canvas is available — jsdom has none,
* and a webview can refuse a context under memory pressure. `startDrag` needs
* *an* image, and a drag with an invisible preview is much better than no drag.
*/
const TRANSPARENT_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
/** Longest filename drawn in full; past this the middle is elided. */
const MAX_LABEL = 28;
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Keep both ends of a long name — the extension is the informative half. */
function elide(label: string): string {
if (label.length <= MAX_LABEL) return label;
const head = label.slice(0, MAX_LABEL - 12);
const tail = label.slice(-9);
return `${head}${tail}`;
}
export function dragPreviewIcon(label: string): string {
try {
const text = elide(label);
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
// bytes over IPC.
const scale = Math.min(window.devicePixelRatio || 1, 2);
const height = 24;
const padding = 8;
const canvas = document.createElement("canvas");
// Measuring needs a context, and sizing the canvas resets it — so measure
// on a throwaway pass, then size, then draw.
const probe = canvas.getContext("2d");
if (!probe) return TRANSPARENT_PNG;
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
probe.font = font;
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return TRANSPARENT_PNG;
ctx.scale(scale, scale);
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
ctx.lineWidth = 1;
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
}
ctx.font = font;
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
ctx.textBaseline = "middle";
ctx.fillText(text, padding, height / 2);
const url = canvas.toDataURL("image/png");
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
// Rust side rejects outright, taking the whole drag with it.
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
} catch {
return TRANSPARENT_PNG;
}
}
@@ -1,250 +0,0 @@
import OverflowMenu from "../ui/OverflowMenu";
import Tooltip from "../ui/Tooltip";
import StatusIndicator from "../ui/StatusIndicator";
import { formatBytes, formatBytesDelta } from "../../lib/formatBytes";
import type { DestructiveItem, ProjectDiskRow } from "../../lib/types";
interface Props {
rows: ProjectDiskRow[];
/** Per-project destructive objects, keyed off the same rows. */
destructive: DestructiveItem[];
onDestroy: (item: DestructiveItem) => void;
}
const LAYERS_HELP =
"Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted.";
const NEXT_COMMIT_HELP =
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
/** Why a layer count reads "unknown" rather than as a number. */
const layersUnknownHelp = (layers: number) =>
`${layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`;
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
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.
*
* ## Why "Layers" is a column and not a detail
*
* A total tells a user their disk is full. The layer count tells them *why*:
* every container recreation runs `docker commit`, a commit stacks a layer and
* never rewrites one, and 24 different settings changes trigger a recreation.
* A project sitting at 14 layers has paid for fourteen full copies of whatever
* changed, and no total on its own ever says that.
*
* "Next commit adds" is the same fact from the other end: it is the container's
* writable layer, i.e. exactly what the *next* recreation will bake in
* permanently. Seeing 868 MB there is what makes Compact worth doing before the
* next settings change rather than after it.
*/
export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) {
if (rows.length === 0) {
return (
<p className="text-xs text-[var(--text-secondary)]">
No projects to account for.
</p>
);
}
return (
// Wide content scrolls inside its own container; the panel itself must
// never scroll sideways.
<div className="overflow-x-auto">
<table className="w-full text-[13px] border-collapse">
<caption className="sr-only">
Disk used by each project, largest first
</caption>
<thead>
<tr className="text-left text-xs text-[var(--text-secondary)]">
<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 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
{/* `Tooltip` renders a portalled div with no `role` and no
`aria-describedby`, so its text reaches no assistive tech and
the trigger announces as "Help". These two headers are
meaningless without their explanation, so it is also emitted
as screen-reader-only text. */}
<Tooltip text={LAYERS_HELP} />
<span className="sr-only"> {LAYERS_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Next commit adds
<Tooltip text={NEXT_COMMIT_HELP} />
<span className="sr-only"> {NEXT_COMMIT_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Home vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Config vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Total
</th>
<th scope="col" className="font-medium py-1.5 pl-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const mine = destructive.filter((d) => d.project_id === row.project_id);
return (
<tr
key={row.project_id}
className="border-t border-[var(--border-color)] align-top"
data-testid={`disk-row-${row.project_id}`}
>
<th scope="row" className="font-normal py-1.5 pr-3 text-[var(--text-primary)]">
<div className="flex items-center gap-1.5">
<span className="truncate max-w-[10rem]">{row.project_name}</span>
{row.migrating && (
<StatusIndicator
tone="busy"
label="Migrating"
className="text-[11px]"
/>
)}
</div>
<span className="block text-[11px] text-[var(--text-secondary)] font-mono truncate max-w-[12rem]">
{row.project_id}
</span>
</th>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{/* `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"> &mdash; {help}</span>
</span>
);
})()}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
{!row.snapshot_exists ? (
"—"
) : !row.base_lineage_known ? (
// The base this descends from is unknown, so the count
// includes the base's own layers and does not mean
// "recreations". Saying so beats printing a wrong number.
//
// The explanation is the only thing standing between
// "unknown" and reading as a bug, so it cannot live in the
// tooltip alone: `Tooltip` portals a plain div with no
// `role` and no `aria-describedby`, and wrapped around
// children it has no focus handlers either — so on hover-
// less input it is unreachable and to a screen reader it
// does not exist. Same treatment as the column headers
// above: tooltip for the mouse, `sr-only` text for
// everything else.
<>
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
<span className="sr-only">
{" "}
&mdash; {layersUnknownHelp(row.snapshot_commit_layers)}
</span>
</>
) : (
<span className="text-[var(--text-primary)]">
{row.snapshot_commit_layers}
{/* Never colour alone: a count worth acting on says so in
a word, which is also what a screen reader gets. */}
{row.snapshot_commit_layers > 5 && (
<span className="ml-1 text-[11px] text-[var(--warning)]">
stacked
</span>
)}
</span>
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{row.container_exists
? formatBytesDelta(row.container_writable_bytes)
: "—"}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.home_volume_bytes, row.home_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.config_volume_bytes, row.config_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap text-[var(--text-primary)] font-medium">
{formatBytes(row.total_bytes)}
</td>
<td className="py-1.5 pl-3">
{mine.length > 0 && (
<OverflowMenu
label={`Delete ${row.project_name} data`}
items={mine.map((item) => ({
label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`,
onSelect: () => onDestroy(item),
danger: true,
disabled: item.blocked !== null,
}))}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,855 +0,0 @@
import { useEffect, useState } from "react";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import Modal from "../ui/Modal";
import TypedConfirmModal from "../ui/TypedConfirmModal";
import DiskProjectTable from "./DiskProjectTable";
import { useDiskUsage } from "../../hooks/useDiskUsage";
import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes";
import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types";
/** A stable key for a target, so ticks survive a re-plan. */
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.
*
* ## Why the scan is a button
*
* `getDockerDiskUsage` is `GET /system/df`, which walks every image, container
* and volume on the daemon computing shared-layer sizes — seconds on a 100 GB
* store, and the only call that produces those numbers at all. So nothing here
* runs on open, on a timer, or on a re-render.
*
* ## Why the buckets are separated the way they are
*
* 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 {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
} = useDiskUsage();
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
// A dialog whose action failed stays open and says so *inside itself*. The
// hook's `error` is rendered at the top of a panel that is metres of scroll
// long, so a user who reached a project row through the table would have
// watched the dialog vanish and seen nothing take its place. This flag is
// what distinguishes "this dialog's action just failed" from a stale scan
// error that happened to still be sitting in `error` when it opened.
const [actionFailed, setActionFailed] = useState(false);
// The plan is dropped after any reclaim, so a tick can never outlive the row
// it was made against and be re-fired at an object that is already gone.
useEffect(() => {
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) ?? [];
// A destructive item is rendered inside its project's row, so one whose
// project id matches no row would be measured and shown nowhere. That is not
// hypothetical: `survey_rollback_pins` walks *images*, not projects, and
// deliberately tolerates an absent project by falling back to the raw id as
// the display name — so a pin left behind by a deleted project is exactly
// this case, and it is the multi-GB kind. Anything unmatched gets its own
// section rather than being silently dropped.
const rowIds = new Set((report?.projects ?? []).map((r) => r.project_id));
const projectDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && rowIds.has(d.project_id)) ?? [];
const unmatchedDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && !rowIds.has(d.project_id)) ?? [];
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
const selected = safeItems.filter(
(i) => i.blocked === null && ticked.has(targetKey(i.target)),
);
const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0);
// Opening or closing either dialog clears the in-dialog failure with it, so
// one never starts out showing the previous attempt's error.
const openConfirming = (item: ReclaimItem) => {
setConfirming(item);
setActionFailed(false);
};
const openDestroying = (item: DestructiveItem) => {
setDestroying(item);
setActionFailed(false);
};
const closeConfirming = () => {
setConfirming(null);
setActionFailed(false);
};
const closeDestroying = () => {
setDestroying(null);
setActionFailed(false);
};
const toggle = (item: ReclaimItem) => {
setTicked((prev) => {
const next = new Set(prev);
const key = targetKey(item.target);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// Counted from the per-result list rather than from a flag: a reclaim of
// five targets can come back with two failures and a real byte total.
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
// What the backend said about the parts it refused, rendered **verbatim**.
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
// never reaches `error`, and the dialog that asked for the work has nothing
// else to show. Not a sentence of our own: the backend is the only side that
// knows which blocker is actually holding the project, and one written here
// would go stale the day that answer improves.
const refusalText =
outcome?.results
.filter((r) => !r.ok)
.map((r) => r.message)
.join(" ") ?? "";
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
: report
? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}`
: "Not scanned";
return (
<div className="space-y-4 text-[13px]">
{/* --- Why this section exists ------------------------------------- */}
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Every time a container is recreated, Triple-C commits it and a commit{" "}
<strong className="text-[var(--text-primary)]">stacks a new layer</strong> rather
than rewriting the old one. Deleting a file afterwards writes a whiteout; the
bytes underneath stay forever. Twenty-four different settings changes trigger a
recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it
no longer uses any of.
</p>
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
{/* Disabled while a mutation runs, not only while scanning: a scan
started on top of a reclaim measures a daemon that is being changed
underneath it, and the hook can only discard such a result — better
not to spend the seconds. */}
<Button variant="primary" size="md" onClick={scan} disabled={scanning || working}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
{/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not
scanned" with no other signal. The live region is mounted here
unconditionally — wrapping it around the indicator only once there
is something to say would make the region *appear* already
populated, which is the one shape assistive tech does not announce. */}
<span role="status" aria-live="polite">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
</span>
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
</div>
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{!report && !scanning && (
<p className="text-xs text-[var(--text-secondary)]">
Nothing has been measured yet. Scanning is the only thing here that costs
anything, so it is never done for you.
</p>
)}
{report && (
<>
{/* --- Windows / WSL2, mandatory when it applies ----------------- */}
{report.host.vhdx_applies && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
data-testid="disk-vhdx-note"
>
{/* `StatusIndicator` has no warning tone — `error` would put a
red glyph in a warning-toned panel. This is advisory, so it
carries its own glyph beside the words rather than relying on
the panel's colour. */}
<p className="text-xs font-medium text-[var(--text-primary)]">
<span aria-hidden="true">&#9650;</span> Warning: reclaiming here will not
shrink your C: drive
</p>
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
{report.host.vhdx_note}
</p>
<p className="text-xs text-[var(--text-secondary)]">
To actually give the space back to C:, run these in PowerShell as
administrator after reclaiming:
</p>
<pre className="text-[11px] font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2.5 py-2 overflow-x-auto select-text">
{report.host.vhdx_fix.join("\n")}
</pre>
<p className="text-xs text-[var(--text-secondary)]">
Or, without Hyper-V: {report.host.vhdx_fix_gui}.
</p>
</section>
)}
{/* --- Per-project table ---------------------------------------- */}
<section className="space-y-2">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
By project
</h3>
<DiskProjectTable
rows={report.projects}
destructive={projectDestructive}
onDestroy={openDestroying}
/>
</section>
{/* --- Globals --------------------------------------------------- */}
<section className="space-y-2" data-testid="disk-globals">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Shared and left over
</h3>
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs">
<dt className="text-[var(--text-secondary)]">
Base images ({report.base_images.length}) shared by every project
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.base_images_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Superseded images from past recreations ({report.orphan_image_count})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_image_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Volumes with no matching project in Triple-C (
{report.orphan_volumes.length})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_volume_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
{/* Live information about where the figure came from, not a
disabled control — `--text-disabled` is ~4.1:1 and fails AA
at this size. */}
<span className="text-[var(--text-secondary)]">
(via {report.build_cache.source})
</span>
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "}
{formatBytes(report.build_cache.total_bytes)}
</dd>
<dt className="text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
Attributable to Triple-C
</dt>
<dd className="text-right tabular-nums text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
{formatBytes(report.triple_c_total_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Everything on this daemon, yours included
</dt>
<dd className="text-right tabular-nums">
{formatBytes(
report.images_total_bytes +
report.containers_total_bytes +
report.volumes_total_bytes,
)}
</dd>
</dl>
{report.build_cache.cli_error && (
<p className="text-[11px] text-[var(--warning)]">
{/* Without this the panel silently shows `docker system df`'s
under-reported build-cache figure and the user has no way
to know why it disagrees with their terminal. */}
Build-cache figures fell back to <code>docker system df</code>, which
under-reports what a prune would free: {report.build_cache.cli_error}
</p>
)}
{report.orphan_volumes.length > 0 && (
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
&ldquo;Volumes with no matching project&rdquo; above means only that the
volume&rsquo;s project id is not in your project list &mdash; 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
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&rsquo;s name.
</p>
)}
<p className="text-[11px] text-[var(--text-secondary)]">
Docker stores this at{" "}
<span className="font-mono">{report.host.docker_root_dir || "an unknown path"}</span>
{report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.
</p>
</section>
{/* --- Store failure, if any ------------------------------------ */}
{report.orphan_volumes_unavailable && (
<section
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
data-testid="disk-store-error"
>
<StatusIndicator
tone="error"
label="Could not read the project list"
className="text-xs"
/>
<p className="mt-1.5 text-xs text-[var(--text-primary)] leading-relaxed">
{report.orphan_volumes_unavailable}
</p>
</section>
)}
{/* --- The plan was dropped by a reclaim -------------------------- */}
{!plan && (
<p className="text-xs text-[var(--text-secondary)]" data-testid="disk-plan-stale">
The totals above were measured before that last action. Scan again to see
what is left to reclaim.
</p>
)}
{/* --- Safe reclaim ---------------------------------------------- */}
{plan && (
<section className="space-y-2" data-testid="disk-safe-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Safe to reclaim
</h3>
{safeItems.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
Nothing here no leftovers were found.
</p>
) : (
<>
<p className="text-xs text-[var(--text-secondary)]">
None of this is reachable any more, or all of it regenerates on demand.
Nothing you have made is in this list.
</p>
<ul className="space-y-1.5">
{safeItems.map((item) => {
const key = targetKey(item.target);
return (
<li key={key}>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
// A tick that survived onto a now-blocked row is
// excluded from `selected`, so showing it checked
// would make the count disagree with the screen.
checked={item.blocked === null && ticked.has(key)}
disabled={item.blocked !== null}
onChange={() => toggle(item)}
className="mt-0.5 accent-[var(--accent-emphasis)]"
/>
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span
className={
item.blocked
? "text-[var(--text-disabled)]"
: "text-[var(--text-primary)]"
}
>
{item.label}
{item.daemon_wide && (
<span className="ml-1.5 text-[11px] text-[var(--warning)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] px-1 py-px">
whole daemon
</span>
)}
</span>
<span className="tabular-nums whitespace-nowrap text-[var(--text-secondary)]">
{formatBytes(item.bytes)}
</span>
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
</label>
</li>
);
})}
</ul>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
disabled={selected.length === 0 || working}
onClick={() => runReclaim(selected.map((i) => i.target))}
>
{working ? "Reclaiming…" : "Reclaim"}
</Button>
<span className="text-xs text-[var(--text-secondary)]">
{selected.length === 0
? "Nothing ticked."
: `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
</span>
</div>
</>
)}
</section>
)}
{/* --- Semi-safe -------------------------------------------------- */}
{semiItems.length > 0 && (
<section className="space-y-2" data-testid="disk-semi-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Worth doing, one at a time
</h3>
<p className="text-xs text-[var(--text-secondary)]">
Nothing here loses anything you have installed. Compacting rewrites a
project&rsquo;s stacked layers into one; clearing caches deletes files
that refill themselves. Both take a moment and both are confirmed
separately.
</p>
<ul className="space-y-1.5">
{semiItems.map((item) => (
<li
key={targetKey(item.target)}
className="flex items-start justify-between gap-3"
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)]">{item.label}</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</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">
{/* A bound, not a measurement — rendered through a
different helper so it cannot read as a promise. */}
{item.bytes_are_exact
? formatBytes(item.bytes)
: formatBytesCeiling(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openConfirming(item)}
>
Run
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Destructive leftovers with no project row ------------------- */}
{unmatchedDestructive.length > 0 && (
<section className="space-y-2" data-testid="disk-unmatched-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Leftovers from projects no longer in Triple-C
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
These belong to a project id that is not in your project list, so there
is no row above to show them under. The same caveat as the volumes below
applies: &ldquo;not in your project list&rdquo; is the only thing this
means, and an idle live project is indistinguishable from a deleted one
from Docker&rsquo;s side. Because there is no project name to type, each
one is confirmed against its project <em>id</em>.
</p>
<ul className="space-y-1.5">
{unmatchedDestructive.map((item) => (
<li
key={destructiveKey(item)}
className="flex items-start justify-between gap-3"
data-testid={`disk-unmatched-${destructiveKey(item)}`}
>
<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-secondary)]">
{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&hellip;
</Button>
</span>
</li>
))}
</ul>
</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 &mdash; 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&rsquo;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&rsquo;s name typed out &mdash; 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&hellip;
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Sweep ------------------------------------------------------ */}
<section className="flex items-center gap-3 flex-wrap">
<Button size="sm" disabled={working} onClick={runSweep}>
Sweep superseded images now
</Button>
<span className="text-xs text-[var(--text-secondary)]">
The same sweep that runs at startup and after every recreation. Unlike the
tick above it also reports what it <em>refused</em> to remove, which is how
a superseded image pinned by a stopped project shows itself.
</span>
</section>
</>
)}
{/* --- Outcome ------------------------------------------------------- */}
{outcome && (
<section
className="border border-[var(--border-color)] bg-[var(--bg-primary)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-1.5"
role="status"
aria-live="polite"
data-testid="disk-outcome"
>
<div className="flex items-center justify-between gap-3">
{/* The headline has to carry the failure in words. A partial
reclaim that freed something still has a byte figure worth
printing, so the count is appended to it rather than replacing
it — and the per-result lines below say *which* ones and why,
so this stops at how many. */}
<StatusIndicator
tone={failedCount === 0 ? "ok" : "error"}
label={
failedCount === 0
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)}${failedCount} of ${outcome.results.length} failed`
}
className="text-xs"
/>
<Button size="sm" variant="ghost" onClick={clearOutcome}>
Dismiss
</Button>
</div>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{outcome.results.map((result, index) => (
<li key={index}>
{result.message}
{result.projected_bytes !== null && (
<>
{" "}
{/* The comparison that makes a compaction's yield
readable — live information, so not the disabled ink. */}
<span className="text-[var(--text-secondary)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
</>
)}
</li>
))}
</ul>
</section>
)}
{/* --- Semi-safe confirmation ---------------------------------------- */}
{confirming && (
<Modal
title={confirming.label}
onClose={closeConfirming}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={closeConfirming}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={working}
onClick={async () => {
// Same reasoning as the destructive modal: a compaction takes
// minutes, and the dialog reporting it beats it vanishing —
// and if it fails, the dialog is the only place the user is
// still looking, so it stays open and reports it here.
// `false` covers both a throw and a refusal that came back
// inside `Ok`; either way the work did not happen, so the
// dialog stays put and reports it where the user is looking.
const ok = await runReclaim([confirming.target]);
setActionFailed(!ok);
if (ok) setConfirming(null);
}}
>
{working ? "Working…" : "Run it"}
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
{/* The failure lands here rather than only in the panel's error
line, which this dialog is covering. */}
{actionFailed && (
<p role="alert" className="text-[var(--error)]">
{error ?? (refusalText || "That did not run. Nothing was changed.")}
</p>
)}
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
<p>
The snapshot is rebuilt into a single layer while the old one is left
in place, so a failure at any point leaves this project exactly as it
is now.
</p>
<p>
How much comes back depends on how much of those layers a later one
already replaced &mdash; it could be{" "}
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
You will be told the real figure when it finishes.
</p>
<p>
One thing worth knowing: the rewritten image no longer shares the base
image with your other projects, so it carries its own copy of it. That
cost is already subtracted from the figure above, and if the rewrite
turns out not to come out ahead it is thrown away and the snapshot is
left exactly as it is.
</p>
</>
)}
{confirming.target.kind === "clear_caches" &&
confirming.target.include_rustup && (
<p>
Rust toolchains are included in this one. They are regenerable, but
getting them back is a download rather than a rebuild.
</p>
)}
</div>
</Modal>
)}
{/* --- Destructive confirmation --------------------------------------- */}
{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);
// A leftover whose project is gone has no name either. `project_name`
// is the raw id in that case — which is deliberate on the Rust side and
// is exactly what `destroy` compares against — so the gate works, but
// the label has to say "id" or it asks for something that does not
// exist.
const ownerless = !orphan && !rowIds.has(destroying.project_id);
return (
<TypedConfirmModal
title={
orphan
? `Delete volume ${destroying.project_name}`
: `Delete ${destroying.label.toLowerCase()}`
}
expected={destroying.project_name}
subject={orphan ? "volume name" : ownerless ? "project id" : "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
// dialog was reached from a project row far below it.
error={
actionFailed
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
: null
}
onCancel={closeDestroying}
onConfirm={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
// state is what the user sees while a multi-second volume removal
// runs. Clearing it first made the whole busy path dead code.
const ok = await destroy(destroying.target, typed);
setActionFailed(!ok);
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)]">
{destroying.project_name}
</strong>
&rsquo;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>
);
}
@@ -19,7 +19,6 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import DiskSettings from "./DiskSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -174,10 +173,6 @@ export default function SettingsPanel() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
<DiskSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
@@ -1,133 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import TypedConfirmModal from "./TypedConfirmModal";
const onConfirm = vi.fn();
const onCancel = vi.fn();
function renderModal(props: Partial<React.ComponentProps<typeof TypedConfirmModal>> = {}) {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
return {
input: screen.getByLabelText(/Type/),
confirm: screen.getByRole("button", { name: "Delete config volume" }),
};
}
beforeEach(() => vi.clearAllMocks());
describe("TypedConfirmModal", () => {
it("is a real dialog, from the Modal primitive", () => {
renderModal();
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
});
it("keeps the confirm button shut until the name is typed exactly", () => {
const { input, confirm } = renderModal();
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "wh" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "whp" } });
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
expect(onConfirm).toHaveBeenCalledWith("whp");
});
it("is case-sensitive, because Api and api are different projects", () => {
// This gate is the only thing between a misclick on a sorted table of
// numbers and a project's transcripts, so a near-miss is a miss.
const { input, confirm } = renderModal({ expected: "Api" });
fireEvent.change(input, { target: { value: "api" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "Api" } });
expect(confirm).toBeEnabled();
});
it("forgives surrounding whitespace from a paste", () => {
const { input, confirm } = renderModal();
fireEvent.change(input, { target: { value: " whp " } });
expect(confirm).toBeEnabled();
});
it("announces the gate's state in words rather than only by the button fill", () => {
const { input } = renderModal();
expect(screen.getByRole("status")).toHaveTextContent(
"Waiting for the exact project name.",
);
fireEvent.change(input, { target: { value: "whp" } });
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();
});
it("locks itself while the deletion is running", () => {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
busy
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
// The confirm button reports the work in a word rather than only going
// grey, so it is found by its busy label, not its idle one.
expect(screen.getByLabelText(/Type/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("cancels without confirming", () => {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it("carries a failed attempt inside the dialog, as an alert", () => {
// The caller keeps this dialog open when the deletion fails, because the
// panel behind it is several screens long and its error line sits at the
// top — nowhere near the row this was opened from.
renderModal({ error: "volume triple-c-home-p-whp is in use by a running container" });
expect(screen.getByRole("alert")).toHaveTextContent(/in use by a running container/);
});
it("says nothing about failure when there has been none", () => {
renderModal();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("cannot be satisfied by an empty box when there is no name to type", () => {
const { confirm } = renderModal({ expected: "" });
expect(confirm).toBeDisabled();
});
});
-145
View File
@@ -1,145 +0,0 @@
import { useId, useRef, useState, type ReactNode } from "react";
import Modal from "./Modal";
import Button from "./Button";
import { inputClass } from "./Field";
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. */
children: ReactNode;
onConfirm: (typed: string) => void;
onCancel: () => void;
busy?: boolean;
/**
* Why the last attempt did not happen. The caller keeps the dialog open when
* its action fails, so the failure has to be readable *here* — the panel
* behind this one is several screens long and its error line is at the top
* of it, which is not where the user is looking.
*/
error?: string | null;
}
/**
* The confirmation gate for something that has no other copy.
*
* ## Why this exists when `ConfirmResetModal` already did
*
* Reset and Remove are reached from a project's own overflow menu, one project
* at a time, by a user who went looking for them. The Disk panel lists every
* project's volumes side by side in a table of numbers, sorted by size — which
* is exactly the layout that invites a misclick on the wrong row. A two-button
* dialog does not survive that, because the thing being confirmed (*which*
* project) is the thing the user got wrong.
*
* Typing the name fixes the failure mode rather than adding friction to it: the
* gate is not "are you sure", it is "name the project you mean".
*
* The comparison is `expected.trim() === typed.trim()` and **case-sensitive** —
* mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that
* actually holds, since this one is only a UI affordance. The backend refuses a
* mismatch on its own.
*/
export default function TypedConfirmModal({
title,
expected,
subject = "project name",
confirmLabel,
children,
onConfirm,
onCancel,
busy = false,
error = null,
}: Props) {
const [typed, setTyped] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Every other `ui/` component uses `useId`; a hardcoded id breaks the
// label association as soon as two of these are mounted at once.
const inputId = useId();
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
return (
<Modal
title={title}
onClose={onCancel}
widthClassName="w-[30rem]"
initialFocusRef={inputRef}
dismissible={!busy}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
size="md"
onClick={() => onConfirm(typed)}
disabled={!matches || busy}
className={
matches && !busy
? "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
: "bg-[var(--bg-tertiary)] text-[var(--text-disabled)] border border-[var(--border-color)]"
}
>
{busy ? "Working…" : confirmLabel}
</Button>
</>
}
>
<div className="space-y-3 text-[13px] text-[var(--text-secondary)]">
{children}
<div>
<label
htmlFor={inputId}
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
>
Type <strong className="font-mono">{expected}</strong> to confirm
</label>
<input
id={inputId}
ref={inputRef}
value={typed}
onChange={(e) => setTyped(e.target.value)}
disabled={busy}
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
{/* Announced rather than only coloured — the gate's state has to be
readable without relying on the button's fill. */}
<p role="status" aria-live="polite" className="mt-1.5 text-xs">
{matches ? (
<span className="text-[var(--text-secondary)]">Name matches.</span>
) : (
// 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 {subject}.
</span>
)}
</p>
</div>
{error && (
// Rendered last, next to the button that was just pressed, and as an
// `alert` so it is announced on arrival rather than waiting to be
// found.
<p role="alert" className="text-[var(--error)]">
{error}
</p>
)}
</div>
</Modal>
);
}
-473
View File
@@ -1,473 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useDiskUsage, type DiskUsageState } from "./useDiskUsage";
import type { DiskUsageReport } from "../lib/types";
const getDockerDiskUsage = vi.fn();
const listReclaimable = vi.fn();
const reclaim = vi.fn();
const destroyProjectDiskObject = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
getDockerDiskUsage: () => getDockerDiskUsage(),
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
reclaim: (targets: unknown) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
}));
const sweepOrphanedSnapshots = vi.fn();
const report = (scanned_at: string): DiskUsageReport =>
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
const plan = { items: [], destructive: [], store_error: null };
beforeEach(() => {
vi.clearAllMocks();
listReclaimable.mockResolvedValue(plan);
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
describe("useDiskUsage", () => {
it("holds no report until a scan is asked for", () => {
const { result } = renderHook(() => useDiskUsage());
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(getDockerDiskUsage).not.toHaveBeenCalled();
});
it("scans, then plans off the same report rather than scanning again", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(report("first"));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.plan).toEqual(plan);
});
it("lets the newest scan win when two are in flight", async () => {
// A user pressing Scan twice can have two `df()` calls outstanding, and
// the second is not necessarily the slower one. A stale response must not
// overwrite a fresher one.
let resolveFirst: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage
.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = Promise.resolve();
act(() => {
firstScan = result.current.scan();
});
await act(async () => {
await result.current.scan();
});
expect(result.current.report?.scanned_at).toBe("second");
// The slow first scan lands afterwards and is discarded.
await act(async () => {
resolveFirst(report("first"));
await firstScan;
});
expect(result.current.report?.scanned_at).toBe("second");
expect(result.current.scanning).toBe(false);
});
it("passes the ticked targets straight through", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
expect(reclaim).toHaveBeenCalledWith([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
it("does not call the backend for an empty selection", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([]);
});
expect(reclaim).not.toHaveBeenCalled();
});
it("does not re-scan after a reclaim", async () => {
// Another `df()` costs seconds, and the outcome already carries measured
// bytes for every target. A user who wants fresh totals asks for them.
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("clears the previous outcome when a new scan starts", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 });
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.outcome?.total_freed_bytes).toBe(42);
await act(async () => {
await result.current.scan();
});
expect(result.current.outcome).toBeNull();
});
it("forwards the typed confirmation verbatim", async () => {
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "dangling_snapshots" },
ok: true,
freed_bytes: 100,
projected_bytes: null,
message: "gone",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp");
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p1" },
"whp",
);
expect(result.current.outcome?.total_freed_bytes).toBe(100);
});
it("reports a scan failure and keeps the last good measurement", async () => {
// The old report is still an accurate measurement of an earlier moment,
// and the error says the refresh failed. Blanking it would leave the panel
// with nothing while telling the user nothing more.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.scanning).toBe(false);
});
it("never shows fresh totals beside a stale tick list", async () => {
// `setReport` used to land before the plan call was awaited, so a plan
// failure rendered this scan's numbers above the previous scan's rows.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockResolvedValueOnce(report("second"));
listReclaimable.mockRejectedValueOnce("planner exploded");
await act(async () => {
await result.current.scan();
});
expect(result.current.error).toMatch(/planner exploded/);
expect(result.current.report?.scanned_at).toBe("first");
});
it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(result.current.plan).toEqual(plan);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The totals stay — they were measured before the reclaim and the outcome
// says what changed.
expect(result.current.report?.scanned_at).toBe("first");
});
it("runs the sweep through its own command and reports what it refused", async () => {
// The sweep's `in_use` count — orphans Docker refused to delete because a
// stopped project still needs them — is invisible everywhere else in the
// app, because every other caller throws the report away.
sweepOrphanedSnapshots.mockResolvedValue({
removed: ["sha256:a", "sha256:b"],
reclaimed_bytes: 11_900_000_000,
in_use: 3,
failed: [],
unavailable: null,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(sweepOrphanedSnapshots).toHaveBeenCalled();
expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000);
expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/);
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
});
// -------------------------------------------------------------------------
// The scan-versus-mutation race
// -------------------------------------------------------------------------
it("throws away a scan that a reclaim overtook", async () => {
// The live race the generation counter used to miss entirely. A scan takes
// seconds and does not set `working`, so nothing stopped the user
// reclaiming on top of one — and when the scan landed it repainted the
// pre-reclaim report *and* a fresh, clickable plan listing objects the
// reclaim had just deleted.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
expect(result.current.scanning).toBe(true);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The overtaken scan finishes last, and must land nothing at all.
await act(async () => {
resolveScan(report("measured before the reclaim"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
// It does not even get as far as re-planning: a plan built from a report
// this stale is the clickable half of the bug.
expect(listReclaimable).not.toHaveBeenCalled();
});
it("does not strand `scanning` when a mutation retires the scan", async () => {
// `scanning` is cleared against the newest *scan*, not the newest
// generation — a mutation bumps the generation without starting a scan, so
// guarding on that would leave the button reading "Scanning…" forever.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.scanning).toBe(false);
});
it("retires an in-flight scan for a destroy and a sweep too", async () => {
// Every mutation invalidates a measurement, not just the bulk one.
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: true,
freed_bytes: 1,
projected_bytes: null,
message: "gone",
});
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: null,
});
for (const mutate of [
(r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"),
(r: DiskUsageState) => r.runSweep(),
]) {
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await mutate(result.current);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(result.current.scanning).toBe(false);
}
});
// -------------------------------------------------------------------------
// Reporting failure back to the caller
// -------------------------------------------------------------------------
it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => {
// The confirmation dialogs close on completion. Without a return value
// they closed on failure too, leaving the error at the top of a panel the
// user had scrolled well past.
reclaim.mockRejectedValueOnce("compaction failed: no space left on device");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/no space left on device/);
});
it("tells the caller a destroy failed", async () => {
destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/in use by a running container/);
});
it("calls a refusal that came back inside `Ok` a failure, and keeps the plan", async () => {
// `reclaim` reports per-target results, and a compaction the backend
// declined is `ok: false` with a sentence saying why — not a thrown error.
// Treating that as success closed the dialog that asked for it and took
// the tick list away, even though every object it listed is still there.
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p1" },
destroyed: null,
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "Cannot compact p1: a terminal session is still attached.",
},
],
total_freed_bytes: 0,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
expect(result.current.outcome?.results[0].message).toMatch(/still attached/);
});
it("drops the plan when part of a batch did happen", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{ target: { kind: "dangling_snapshots" }, destroyed: null, ok: true, freed_bytes: 12, projected_bytes: null, message: "Removed 3 images" },
{ target: { kind: "compact_snapshot", project_id: "p1" }, destroyed: null, ok: false, freed_bytes: 0, projected_bytes: null, message: "Refused" },
],
total_freed_bytes: 12,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "compact_snapshot", project_id: "p1" },
]);
});
expect(ok).toBe(false);
expect(result.current.plan).toBeNull();
});
it("calls a refused destroy a failure and leaves its row in the plan", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "The volume is still attached to a running container.",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
});
it("reports success when the call came back", async () => {
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(ok).toBe(true);
});
it("treats an unreachable daemon in the sweep report as an error", async () => {
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: "Could not reach the Docker engine",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(result.current.error).toMatch(/Could not reach the Docker engine/);
expect(result.current.outcome).toBeNull();
});
});
-270
View File
@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type {
DestructiveTarget,
DiskUsageReport,
ReclaimOutcome,
ReclaimPlan,
ReclaimTarget,
} from "../lib/types";
/**
* State for the Disk section.
*
* ## Why nothing here runs on mount
*
* A scan is `GET /system/df`, which walks every image, container and volume on
* the daemon and computes shared-layer sizes. On a 100 GB store that is
* seconds. `AccordionSection` unmounts its body when collapsed, so a
* `useEffect` scan would re-run every single time the user opened the section.
* The scan is therefore only ever what the Scan button calls.
*
* Note what that does *not* buy: this hook lives inside `DiskSettings`, which
* the accordion unmounts on collapse, so its state goes with it and reopening
* the section shows an unscanned panel again. That is the honest behaviour —
* a stale total is worse than an absent one — but it means collapsing and
* reopening discards a scan the user paid for. Lifting the report into
* `appState` would fix that and is deliberately not done here: it would put a
* multi-megabyte, rapidly-stale blob into the app-wide store for one panel.
*
* ## The generation guard
*
* A user who hits Scan twice can have two `df()` calls in flight, and they can
* land out of order — the second one is not necessarily slower. Every async
* write in `scan` checks it is still the newest before it lands, the same
* pattern `useContainerMigration` uses.
*
* The race that actually bites, though, is not scan-versus-scan: it is
* scan-versus-**mutation**. A scan takes seconds and does not set `working`, so
* nothing stopped a reclaim starting on top of one. The reclaim correctly drops
* the plan — and then the still-running scan landed, passed its own generation
* check, and repainted a pre-reclaim report *plus a fresh, clickable plan
* listing objects that had just been deleted*. So every mutation bumps the
* counter as well: whatever a scan is holding was measured before the mutation
* and is now a lie, and throwing it away is the only honest thing to do with
* it. (The Scan button is disabled while `working` for the mirror-image case,
* so a scan can never start *during* a mutation.)
*
* That is also why `scanning` is not cleared against the same counter: a
* mutation bumping it mid-scan would strand the flag at true and leave the
* button reading "Scanning…" forever. `latestScan` records the generation the
* newest *scan* owns — only a newer scan may take the flag away — and that is
* what the `finally` compares against.
*/
export interface DiskUsageState {
report: DiskUsageReport | null;
plan: ReclaimPlan | null;
/** A scan is in flight. */
scanning: boolean;
/** A reclaim or a destroy is in flight. */
working: boolean;
error: string | null;
/** The outcome of the last reclaim, kept on screen until the next scan. */
outcome: ReclaimOutcome | null;
scan: () => Promise<void>;
/**
* Resolves `true` only when the work actually happened.
*
* Two different failures reach here and both have to answer `false`. One is
* the call throwing, which lands in `error`. The other is the backend coming
* back inside `Ok` with a *refusal* — `reclaim` reports per-target results,
* and a compaction declined because the project is busy is a `ReclaimResult`
* with `ok: false` and a sentence saying why. Reading only "did it throw"
* treated that refusal as a success: the confirmation dialog closed, the plan
* was dropped, and the explanation appeared in the outcome panel several
* screens above the row the user had clicked.
*
* Callers that dismiss UI on completion — the confirmation dialogs — must
* only dismiss on `true`, and take the wording from `outcome`'s per-result
* `message` rather than writing their own: the backend's sentence is the one
* that names the real blocker.
*/
runReclaim: (targets: ReclaimTarget[]) => Promise<boolean>;
/** Same contract as `runReclaim`: `false` means it did not happen, and either
* `error` or the outcome's `message` says why. */
destroy: (target: DestructiveTarget, confirmation: string) => Promise<boolean>;
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
runSweep: () => Promise<void>;
clearOutcome: () => void;
}
export function useDiskUsage(): DiskUsageState {
const [report, setReport] = useState<DiskUsageReport | null>(null);
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
const [scanning, setScanning] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
const generation = useRef(0);
/** The generation belonging to the most recently *started* scan. */
const latestScan = useRef(0);
/**
* Retire every in-flight scan. Called at the top of each mutation, because
* the moment we start deleting things, a measurement taken before that is no
* longer describing the daemon the user is looking at.
*/
const invalidateScans = useCallback(() => {
generation.current += 1;
}, []);
const scan = useCallback(async () => {
const mine = ++generation.current;
latestScan.current = mine;
setScanning(true);
setError(null);
// The previous outcome describes a state that no longer holds once a new
// scan starts, so it goes rather than sitting beside fresh numbers.
setOutcome(null);
try {
const next = await commands.getDockerDiskUsage();
if (generation.current !== mine) return;
// Planning is cheap and always wanted: the classification is what makes
// the numbers actionable, and it reuses the report rather than scanning
// again.
const nextPlan = await commands.listReclaimable(next);
if (generation.current !== mine) return;
// Both land together, or neither does. Setting the report before
// awaiting the plan would render this scan's totals above the *previous*
// scan's still-clickable tick list if the plan call failed.
setReport(next);
setPlan(nextPlan);
} catch (e) {
if (generation.current !== mine) return;
setError(String(e));
// The old report is left on screen deliberately — it is still an
// accurate measurement of an earlier moment, and the error says the
// refresh failed. What must not survive is a plan describing a scan the
// user can no longer see the totals for, but that cannot happen: the two
// only ever move together.
} finally {
// Deliberately `latestScan`, not `generation`: a mutation that retired
// this scan did not start another one, so this scan is still the last
// word on whether a scan is running.
if (latestScan.current === mine) setScanning(false);
}
}, []);
const runReclaim = useCallback(
async (targets: ReclaimTarget[]): Promise<boolean> => {
// Nothing was asked for, so nothing failed — a caller gating a dialog on
// this must not be left staring at an error that has no cause.
if (targets.length === 0) return true;
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.reclaim(targets);
setOutcome(result);
// **The plan is now stale and must not stay clickable.** Its rows
// describe objects this call just removed, so leaving them ticked lets
// the user fire the same reclaim again against nothing. Dropping the plan
// (not the report) leaves the totals on screen, marked as measured before
// the reclaim, with the tick list gone.
//
// Deliberately no automatic re-scan: it costs another `df()`, and the
// outcome already reports measured bytes for every target — a user who
// wants the new totals asks for them.
//
// The exception is a call that removed *nothing at all* because every
// target was refused: those objects are all still there, so the plan
// still describes the daemon accurately and taking it away would leave
// the user re-scanning to get back a list that never went stale.
const everythingRefused =
result.results.length > 0 && result.results.every((r) => !r.ok);
if (!everythingRefused) setPlan(null);
return result.results.every((r) => r.ok);
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
const destroy = useCallback(
async (target: DestructiveTarget, confirmation: string): Promise<boolean> => {
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.destroyProjectDiskObject(target, confirmation);
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
// Same reasoning as `runReclaim`, refusal included: the destructive
// list named an object that is now gone — unless the backend declined,
// in which case it is still there and so is the row for it.
if (result.ok) setPlan(null);
return result.ok;
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
/**
* The startup sweep, on demand.
*
* Not the same as ticking "superseded snapshot layers", even though both end
* up removing the same images: this reports `in_use` — the orphans Docker
* *refused* to delete because a stopped project's container still needs
* them. That refusal is the sweep's third safety net and it is invisible
* everywhere else in the app, because every existing caller throws the
* report away.
*/
const runSweep = useCallback(async () => {
invalidateScans();
setWorking(true);
setError(null);
try {
const sweep = await commands.sweepOrphanedSnapshots();
if (sweep.unavailable) {
setError(sweep.unavailable);
return;
}
const refused =
sweep.in_use > 0
? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.`
: "";
setOutcome({
results: [
{
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: sweep.failed.length === 0,
freed_bytes: sweep.reclaimed_bytes,
projected_bytes: null,
message: `Swept ${sweep.removed.length} superseded image(s).${refused}`,
},
],
total_freed_bytes: sweep.reclaimed_bytes,
});
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, [invalidateScans]);
const clearOutcome = useCallback(() => setOutcome(null), []);
return {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
};
}
-96
View File
@@ -8,7 +8,6 @@ const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
const stageContainerFileForDrag = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -18,7 +17,6 @@ vi.mock("../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: vi.fn(),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/**
@@ -231,85 +229,6 @@ describe("useFileManager save to host", () => {
});
});
describe("useFileManager drag-out staging", () => {
it("copies the file onto the host and hands back the host path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/a.txt");
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
// The note is transient — it must not still be sitting there afterwards.
expect(result.current.busy).toBeNull();
});
it("reuses the copy on a second drag of the same entry", async () => {
// The whole point of the cache: the copy is the slow half of the gesture,
// and a retry after a drag the OS missed has to be immediate.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let second: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
second = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
expect(second).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: true });
});
it("re-stages once the entry has changed underneath it", async () => {
// Keyed on size and mtime, so a file edited in the container is copied
// again rather than dragged out at its old contents.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.stageForDrag(file("a.txt", { size: 10 }));
await result.current.stageForDrag(file("a.txt", { size: 4096 }));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
});
it("surfaces a refused staging instead of returning a path that is not there", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("huge.bin"));
});
expect(staged).toBeNull();
expect(toastText()).toContain("too large to drag out");
expect(toastText()).toContain("Save to host");
expect(result.current.busy).toBeNull();
});
it("does not cache a failure, so a retry actually retries", async () => {
stageContainerFileForDrag.mockRejectedValueOnce("Container not running");
stageContainerFileForDrag.mockResolvedValueOnce("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
});
});
describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the
@@ -485,21 +404,6 @@ describe("useFileManager overwrite prompt", () => {
});
});
describe("useFileManager staged host paths", () => {
it("recognises a path it staged, and only that path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false);
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
});
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true);
// Same basename, a real host file the user actually wants uploaded.
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
+2 -70
View File
@@ -32,15 +32,6 @@ function baseName(path: string): string {
return parts[parts.length - 1] || path;
}
/**
* Host paths compare on separators, not on case: the OS hands a dropped path
* back in whatever form its file dialog produced, and on Windows that is not
* reliably the form `stage_container_file_for_drag` returned.
*/
function normaliseHostPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "");
}
/**
* ## Where failures are reported
*
@@ -51,7 +42,7 @@ function normaliseHostPath(path: string): string {
* no rows, and it is not transient — it stands until the directory lists.
*
* Every **transient operation** failure — upload, rename, create folder,
* save-to-host, drag staging — goes to `ToastHost` instead. Those used to land
* save-to-host — goes to `ToastHost` instead. Those used to land
* in the same inline `error` div, which is the first child of the *scrolling*
* list: three hundred rows down, a refused rename produced no visible change
* at all, just a rename box that stayed open for no stated reason. Worse, the
@@ -91,7 +82,7 @@ export function useFileManager(projectId: string) {
/**
* A slow listing can land after a newer one and set both the rows and the
* breadcrumb back to a directory the user already left. Same generation
* guard `useDiskUsage` and `useContainerMigration` use: every async write
* guard `useContainerMigration` uses: every async write
* checks it is still the newest before it lands.
*/
const navGeneration = useRef(0);
@@ -320,63 +311,6 @@ export function useFileManager(projectId: string) {
[projectId, navigate, startWork, report, askOverwrite],
);
/**
* Host paths already copied out this session, keyed by the entry they came
* from. Size and mtime are in the key, so an entry that changed since the
* last listing re-stages rather than dragging a stale copy.
*/
const stagedRef = useRef(new Map<string, string>());
/**
* The same paths the other way round, as a set.
*
* A drag-out released back inside the app arrives as an ordinary host drop
* carrying the staged copy's path, and uploading that would write the app's
* own temp copy over the container file it came from — which is worse than a
* no-op, because the key above is built from the *last listing*, so a file an
* agent rewrote since then would be replaced by a minutes-old snapshot. This
* set is what makes the "is this ours?" test exact instead of a guess at the
* temp directory's name.
*/
const stagedHostPathsRef = useRef(new Set<string>());
/** True when `path` is a copy this pane staged for a drag-out. */
const isStagedHostPath = useCallback(
(path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)),
[],
);
/**
* Copy an entry onto the host so the OS can drag it, and return the absolute
* host path — or `null`, having reported why, if it could not be staged.
*
* `cached` is what the caller needs to tell a gesture that will feel
* instantaneous from one that has a whole-file copy in front of it: the copy
* is the slow half of a drag-out, and the OS only picks a drag up while the
* button is still down.
*/
const stageForDrag = useCallback(
async (entry: FileEntry): Promise<{ hostPath: string; cached: boolean } | null> => {
const key = `${entry.path}|${entry.size}|${entry.modified}`;
const cached = stagedRef.current.get(key);
if (cached) return { hostPath: cached, cached: true };
startWork(`Preparing "${entry.name}"…`);
try {
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
stagedRef.current.set(key, hostPath);
stagedHostPathsRef.current.add(normaliseHostPath(hostPath));
setCompleted(`"${entry.name}" is ready to drag.`);
return { hostPath, cached: false };
} catch (e) {
report(`Could not prepare "${entry.name}" for dragging`, e);
return null;
} finally {
setBusy(null);
}
},
[projectId, startWork, report],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
@@ -447,8 +381,6 @@ export function useFileManager(projectId: string) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
};
+6 -6
View File
@@ -3,9 +3,9 @@ import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes
describe("formatBytes", () => {
it("defaults to base 1000, because that is what Docker prints", () => {
// The Disk panel exists to explain `docker system df`, which formats with
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
// 28.0 GB for the same build cache reads as a bug in the panel.
// Anything explaining `docker system df` has to match it, and Docker
// formats with `units.HumanSize` — base 1000. Showing 26.1 GB against a
// terminal saying 28.0 GB for the same object reads as a bug in the app.
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
expect(formatBytes(1_000)).toBe("1.0 KB");
expect(formatBytes(1_500_000)).toBe("1.5 MB");
@@ -108,9 +108,9 @@ describe("formatBytesDelta", () => {
describe("formatBytesCeiling", () => {
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
// Every other figure in the Disk panel is measured. This one cannot be
// known until the rewrite runs, and rendering it through a separate
// function is what stops it being read as a guarantee.
// A projected yield cannot be known until the work runs, unlike every
// measured figure beside it — rendering it through a separate function is
// what stops it being read as a guarantee.
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
});
+16 -9
View File
@@ -17,11 +17,11 @@
*
* ## Why the default is base 1000
*
* The Disk panel exists to explain what `docker system df` reports, and Docker
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
* same build cache would read as a bug in the panel. So decimal is the default
* and binary is opt-in, rather than the other way round.
* Anything explaining what Docker reports has to match it, and Docker formats
* every size it prints with `units.HumanSize`, which is **base 1000**. Showing
* 26.1 GB where the user's terminal said 28.0 GB for the same object would read
* as a bug in the app. So decimal is the default and binary is opt-in, rather
* than the other way round.
*
* Both existing conventions are preserved for every size either call site can
* realistically produce a file size or a payload size, i.e. a non-negative
@@ -90,6 +90,10 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
* `12.3 GB` `+12.3 GB`, for a figure that is being *added* rather than
* measured. Used for "next commit adds …", which is the number that explains
* why a snapshot grows.
*
* **No caller on this branch**, for the same reason as [`formatBytesCeiling`]:
* the Disk panel's per-project table was the last one, and it went to
* `hold/disk-and-dragout`.
*/
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
const formatted = formatBytes(bytes, options);
@@ -99,10 +103,13 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s
/**
* `up to 12.3 GB` for a bound rather than a measurement.
*
* The Disk panel is careful about this distinction: every figure it shows is
* measured except a compaction's yield, which cannot be known until it runs.
* Rendering that one through a different function is what stops it being read
* as a promise.
* A figure that cannot be known until an operation runs must not render like
* one that was measured; going through a different function is what stops it
* being read as a promise.
*
* **No caller on this branch.** Its last one was the Disk panel's projected
* compaction yield, which went to `hold/disk-and-dragout`. Kept with its tests
* because the distinction it encodes is the reusable part.
*/
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
+1 -39
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -97,13 +97,6 @@ export const renameContainerPath = (projectId: string, fromPath: string, toPath:
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
invoke<string>("create_container_directory", { projectId, parentPath, name });
/**
* Copy a container file into an app-owned host temp directory and return the
* absolute host path. The OS can only drag a file that exists on the host, so
* this is the first half of every drag-out.
*/
export const stageContainerFileForDrag = (projectId: string, path: string) =>
invoke<string>("stage_container_file_for_drag", { projectId, path });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
@@ -369,34 +362,3 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
// Disk
/** Measure where the daemon's bytes have gone.
*
* **Expensive keep it behind an explicit Scan button.** This is
* `GET /system/df`, which walks every image, container and volume on the
* daemon to compute shared-layer sizes, plus an `image_history` per image.
* Seconds on a 100 GB store. Never call it on mount and never poll it. */
export const getDockerDiskUsage = () => invoke<DiskUsageReport>("get_docker_disk_usage");
/** Classify what could be reclaimed, with measured bytes. Takes the report
* from `getDockerDiskUsage` so re-planning costs no second scan. */
export const listReclaimable = (report: DiskUsageReport) =>
invoke<ReclaimPlan>("list_reclaimable", { report });
/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action,
* so no selection built here can delete a live project's data. */
export const reclaim = (targets: ReclaimTarget[]) =>
invoke<ReclaimOutcome>("reclaim", { targets });
/** Delete one object that has no other copy. `confirmation` must be the
* project's name, typed by the user. One target per call, never bulk. */
export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) =>
invoke<ReclaimResult>("destroy_project_disk_object", { target, confirmation });
/** Run the orphaned-snapshot sweep on demand and see its report the same
* sweep that runs at startup and after every recreation, whose result every
* existing caller throws away. */
export const sweepOrphanedSnapshots = () =>
invoke<SnapshotSweepReport>("sweep_orphaned_snapshots");
-242
View File
@@ -853,245 +853,3 @@ export interface MigrationState {
options: MigrationOptions;
plan: MigrationPlan | null;
}
// ---------------------------------------------------------------------------
// Disk
// ---------------------------------------------------------------------------
//
// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every
// other IPC struct in this app.
/** One row of the per-project disk table. */
export interface ProjectDiskRow {
project_id: string;
project_name: string;
snapshot_image: string;
snapshot_exists: boolean;
/** Total size of the snapshot image, base image included. */
snapshot_bytes: number;
/** Bytes shared with another image — almost always the base. */
snapshot_shared_bytes: number;
/** Layers stacked above the base image: **one per container recreation**.
* This is the number that explains why a snapshot grows but only when
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
snapshot_commit_layers: number;
/** Whether the base image this snapshot descends from could be identified.
* False is the normal case for a project created before the
* `triple-c.base-image-id` label existed; the layer count must not be
* presented as a recreation count then. */
base_lineage_known: boolean;
/** Bytes those layers account for. `null` when the base image is gone and
* the split cannot be measured never a guess. */
snapshot_above_base_bytes: number | null;
container_exists: boolean;
container_running: boolean;
/** The writable layer, i.e. exactly what the next commit will add. */
container_writable_bytes: number;
home_volume_bytes: number;
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;
}
export interface BaseImageRow {
reference: string;
bytes: number;
shared_bytes: number;
containers: number;
is_labelled_base: boolean;
}
/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies.
* The vhdx copy comes from Rust so the wording cannot drift from the
* constants its tests pin. */
export interface HostStorage {
docker_root_dir: string;
operating_system: string;
is_docker_desktop: boolean;
is_windows_host: boolean;
vhdx_applies: boolean;
/** Empty unless `vhdx_applies`. */
vhdx_note: string;
vhdx_fix: string[];
vhdx_fix_gui: string;
}
export interface BuildCacheUsage {
total_bytes: number;
reclaimable_bytes: number;
/** What a `--filter until=168h` prune would reach. */
stale_bytes: number;
/** `"buildx du"` or `"system df"` `docker system df` under-reports build
* cache, so which one produced the number is worth showing. */
source: string;
cli_error: string | null;
}
/** A per-project volume whose project id is not in Triple-C's project store.
*
* **Not "a volume with no container".** From the daemon's side an idle live
* project and a deleted one look identical volumes present, no container,
* nothing running so only the project store can tell them apart. */
export interface OrphanVolume {
name: string;
project_id: string;
bytes: number;
/** `"home"` or `"config"`. */
role: string;
/** When Docker created it. Evidence a user can recognise a project by; a
* size and a UUID identify nothing. From `df()` metadata volumes are
* never mounted to inspect them, because `docker run -v` *creates* a
* volume that does not exist. */
created_at: string | null;
}
/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */
export interface DiskUsageReport {
scanned_at: string;
projects: ProjectDiskRow[];
base_images: BaseImageRow[];
base_images_bytes: number;
orphan_image_bytes: number;
orphan_image_count: number;
orphan_volumes: OrphanVolume[];
orphan_volume_bytes: number;
/** Why orphan detection was suppressed, when it was. */
orphan_volumes_unavailable: string | null;
build_cache: BuildCacheUsage;
images_total_bytes: number;
containers_total_bytes: number;
volumes_total_bytes: number;
triple_c_total_bytes: number;
host: HostStorage;
}
/** Mirrors Rust `Safety` (serde snake_case). */
export type ReclaimSafety = "safe" | "semi_safe";
/** Mirrors Rust `ReclaimTarget`, an internally tagged enum.
*
* This type **cannot express a destructive action** that is
* `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one.
* The separation is structural on both sides on purpose. */
export type ReclaimTarget =
| { kind: "dangling_snapshots" }
| { kind: "superseded_base_images" }
| { kind: "build_cache"; all: boolean }
| { kind: "migration_pins" }
| { kind: "migration_staging" }
| { kind: "probe_containers" }
| { kind: "scrub_containers" }
| { kind: "compact_snapshot"; project_id: string }
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
/** 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 }
/** 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;
safety: ReclaimSafety;
/** Reaches beyond Triple-C's own objects true only for the build cache,
* and the UI must say so. */
daemon_wide: boolean;
label: string;
detail: string;
bytes: number;
/** `false` means `bytes` is a bound, not a measurement. Render it as
* "up to …" only snapshot compaction sets this. */
bytes_are_exact: boolean;
bytes_floor: number | null;
/** Why this cannot run right now. */
blocked: string | null;
}
export interface DestructiveItem {
target: DestructiveTarget;
project_id: string;
project_name: string;
label: string;
/** Spelled out in full — this is the confirmation copy. */
loses: string;
bytes: number;
blocked: string | null;
}
export interface ReclaimPlan {
items: ReclaimItem[];
/** Display only. `reclaim` cannot act on these. */
destructive: DestructiveItem[];
store_error: string | null;
}
export interface ReclaimResult {
/** The reclaim target this reports on, or `null` when it reports a destroy.
* Exactly one of `target` / `destroyed` is ever set a destroy used to come
* back wearing a `ReclaimTarget` that named work it had not done. */
target: ReclaimTarget | null;
destroyed: DestructiveTarget | null;
ok: boolean;
freed_bytes: number;
/** What was projected beforehand, for the one action that projects. */
projected_bytes: number | null;
message: string;
}
export interface ReclaimOutcome {
results: ReclaimResult[];
total_freed_bytes: number;
}
/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of
* `[reference, error]` pairs a Rust tuple serialises as an array. */
export interface SnapshotSweepReport {
removed: string[];
reclaimed_bytes: number;
/** Refused because a container is still built from them. Normal. */
in_use: number;
failed: [string, string][];
unavailable: string | null;
}