Merge branch 'feat/disk-ui' into integration/round-1

This commit is contained in:
2026-08-23 09:51:44 -07:00
20 changed files with 6797 additions and 20 deletions
+10 -4
View File
@@ -1,10 +1,16 @@
/** Shared formatting helpers for the Project Home views. */
import { formatBytes as shared } from "../../../lib/formatBytes";
/**
* File sizes in Project Home, ÷1024 with `KB`/`MB`/`GB` labels.
*
* Kept as a named re-export rather than deleted: three modules import it from
* here, and the binary/decimal-label pairing is a Project Home convention
* rather than the app-wide default. The implementation is `lib/formatBytes`.
*/
export function formatBytes(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
return shared(bytes, { binary: true });
}
/** "2h ago" / "3d ago". Returns null for unparseable timestamps. */
+2 -8
View File
@@ -7,6 +7,7 @@
*/
import type { PackageFailure } from "../../lib/types";
import { formatBytes } from "../../lib/formatBytes";
/**
* What re-attaches untouched. These are not copied, rebuilt or re-authenticated
@@ -62,14 +63,7 @@ export const REPLAY_COST =
/** `41.0 MB`. Sizes here are informational, so the friendlier decimal unit. */
export function formatDataSize(bytes: number): string {
const units = ["B", "KB", "MB", "GB", "TB"];
let value = bytes;
let unit = 0;
while (value >= 1000 && unit < units.length - 1) {
value /= 1000;
unit += 1;
}
return unit === 0 ? `${bytes} B` : `${value.toFixed(1)} ${units[unit]}`;
return formatBytes(bytes);
}
/** `1 Mar` — short enough to sit inline in the banner sentence. */
@@ -0,0 +1,196 @@
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.";
/** `—` 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) : "—";
}
/**
* 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">
Snapshot
</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">
{/* `null` means the split could not be measured. Rendering it
as 0 B would be the one guessed number in this table. */}
{cell(
row.snapshot_above_base_bytes ?? -1,
row.snapshot_exists && row.snapshot_above_base_bytes !== null,
)}
{row.snapshot_exists && (
<span className="block text-[11px] text-[var(--text-secondary)]">
{/* The base is shared by every project, so charging it to
each row would show the same 4.7 GB eight times. The
headline figure is what is unique to this project;
the total is here for anyone reconciling against
`docker images`. */}
{formatBytes(row.snapshot_bytes)} with base
</span>
)}
</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.
<Tooltip
text={`${row.snapshot_commit_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.`}
>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
) : (
<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>
);
}
@@ -0,0 +1,631 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act, waitFor, within } from "@testing-library/react";
import DiskSettings from "./DiskSettings";
import type {
DiskUsageReport,
ProjectDiskRow,
ReclaimItem,
ReclaimPlan,
ReclaimTarget,
} 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: ReclaimTarget[]) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: vi.fn(async () => ({})),
}));
// ---------------------------------------------------------------------------
// Fixtures
// ---------------------------------------------------------------------------
const row = (over: Partial<ProjectDiskRow> = {}): ProjectDiskRow => ({
project_id: "p-whp",
project_name: "whp",
snapshot_image: "triple-c-snapshot-p-whp:latest",
snapshot_exists: true,
snapshot_bytes: 12_273_392_374,
snapshot_shared_bytes: 3_832_425_659,
snapshot_commit_layers: 14,
base_lineage_known: true,
snapshot_above_base_bytes: 8_440_966_715,
container_exists: true,
container_running: false,
container_writable_bytes: 868_000_000,
home_volume_bytes: 4_860_000_000,
home_volume_present: true,
config_volume_bytes: 427_000_000,
config_volume_present: true,
total_bytes: 14_595_966_715,
migrating: false,
...over,
});
const report = (over: Partial<DiskUsageReport> = {}): DiskUsageReport => ({
scanned_at: "2026-08-23T10:00:00Z",
projects: [row()],
base_images: [
{
reference: "ghcr.io/shadowdao/triple-c-sandbox:latest",
bytes: 4_724_062_366,
shared_bytes: 4_723_860_396,
containers: 2,
is_labelled_base: true,
},
],
base_images_bytes: 4_724_062_366,
orphan_image_bytes: 11_900_000_000,
orphan_image_count: 3,
orphan_volumes: [],
orphan_volume_bytes: 0,
orphan_volumes_unavailable: null,
build_cache: {
total_bytes: 28_000_000_000,
reclaimable_bytes: 28_000_000_000,
stale_bytes: 20_000_000_000,
source: "buildx du",
cli_error: null,
},
images_total_bytes: 104_500_000_000,
containers_total_bytes: 7_497_000_000,
volumes_total_bytes: 72_890_000_000,
triple_c_total_bytes: 116_000_000_000,
host: {
docker_root_dir: "/var/lib/docker",
operating_system: "Docker Desktop",
is_docker_desktop: true,
is_windows_host: false,
vhdx_applies: false,
vhdx_note: "",
vhdx_fix: [],
vhdx_fix_gui: "",
},
...over,
});
const item = (over: Partial<ReclaimItem> = {}): ReclaimItem => ({
target: { kind: "dangling_snapshots" },
safety: "safe",
daemon_wide: false,
label: "Superseded snapshot layers (3 images)",
detail: "Untagged images left behind by past container recreations.",
bytes: 11_900_000_000,
bytes_are_exact: true,
bytes_floor: null,
blocked: null,
...over,
});
const plan = (over: Partial<ReclaimPlan> = {}): ReclaimPlan => ({
items: [item()],
destructive: [],
store_error: null,
...over,
});
async function renderAndScan() {
render(<DiskSettings />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
}
beforeEach(() => {
vi.clearAllMocks();
getDockerDiskUsage.mockResolvedValue(report());
listReclaimable.mockResolvedValue(plan());
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
// ---------------------------------------------------------------------------
describe("DiskSettings", () => {
it("never scans until the button is pressed", async () => {
// `df()` walks the whole daemon and takes seconds on a large store, and
// AccordionSection unmounts its body when collapsed — so a scan on mount
// would re-run every time the section was opened.
render(<DiskSettings />);
await act(async () => {
await Promise.resolve();
});
expect(getDockerDiskUsage).not.toHaveBeenCalled();
expect(screen.getByText(/never done for you/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("says it is scanning in words, not only in colour", async () => {
let resolve: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValue(
new Promise<DiskUsageReport>((r) => {
resolve = r;
}),
);
render(<DiskSettings />);
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
expect(screen.getByText("Scanning")).toBeInTheDocument();
await act(async () => {
resolve(report());
});
await waitFor(() => expect(screen.getByText(/^Scanned /)).toBeInTheDocument());
});
it("shows the layer count and the cost of the next commit", async () => {
// The two numbers that explain the growth mechanism. A total alone never
// says why the disk filled up.
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("14")).toBeInTheDocument();
expect(within(projectRow).getByText("+868.0 MB")).toBeInTheDocument();
expect(within(projectRow).getByText("14.6 GB")).toBeInTheDocument();
});
it("refuses to present a layer count that does not mean recreations", async () => {
// Without `triple-c.base-image-id` — the normal case for a project created
// before that label existed — the count includes the base's own ~15 layers.
// Printing it beside a header that says "one per recreation" would be a
// wrong number in the column the table exists for.
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ base_lineage_known: false, snapshot_commit_layers: 17 })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("unknown")).toBeInTheDocument();
expect(within(projectRow).queryByText("17")).not.toBeInTheDocument();
});
it("renders an unmeasurable snapshot split as a dash, never as zero", async () => {
getDockerDiskUsage.mockResolvedValue(
report({ projects: [row({ snapshot_above_base_bytes: null })] }),
);
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).queryByText("0 B")).not.toBeInTheDocument();
expect(within(projectRow).getAllByText("—").length).toBeGreaterThan(0);
});
it("marks a heavily stacked snapshot with a word, not just a colour", async () => {
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("stacked")).toBeInTheDocument();
});
it("charges the shared base to the globals, not to every project row", async () => {
// The base is one 4.7 GB image every project descends from. Counting it per
// row would show it eight times and make the column meaningless.
await renderAndScan();
const projectRow = await screen.findByTestId("disk-row-p-whp");
expect(within(projectRow).getByText("8.4 GB")).toBeInTheDocument();
expect(within(projectRow).getByText(/12\.3 GB with base/)).toBeInTheDocument();
});
it("plans from the report it already has rather than scanning twice", async () => {
await renderAndScan();
await waitFor(() => expect(listReclaimable).toHaveBeenCalledTimes(1));
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(expect.objectContaining({ projects: expect.any(Array) }));
});
// -------------------------------------------------------------------------
// Selection plumbing
// -------------------------------------------------------------------------
it("sends exactly the ticked targets and nothing else", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item(),
item({
target: { kind: "migration_staging" },
label: "Migration staging files",
bytes: 500_000_000,
}),
],
}),
);
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
const boxes = screen.getAllByRole("checkbox");
await act(async () => {
fireEvent.click(boxes[1]);
});
expect(screen.getByText(/1 selected, 500\.0 MB/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
expect(reclaim).toHaveBeenCalledWith([{ kind: "migration_staging" }]);
});
it("clears the tick list once the reclaim has run", async () => {
// The plan's rows describe objects the reclaim just removed; leaving them
// ticked lets the user fire the same call again against nothing.
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
expect(screen.getByText(/1 selected/)).toBeInTheDocument();
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
expect(screen.queryByTestId("disk-safe-bucket")).not.toBeInTheDocument();
expect(screen.queryByRole("checkbox")).not.toBeInTheDocument();
// And it says why the list is gone rather than claiming nothing was found.
expect(screen.getByTestId("disk-plan-stale").textContent).toMatch(
/measured before that last action/,
);
});
it("says why the build-cache figure is the under-reporting one", async () => {
// Without this, a `buildx du` failure silently shows `docker system df`'s
// number, which under-reports what a prune would free.
getDockerDiskUsage.mockResolvedValue(
report({
build_cache: {
total_bytes: 28_000_000_000,
reclaimable_bytes: 1_000_000,
stale_bytes: 0,
source: "system df",
cli_error: "`docker buildx du` failed: executable not found",
},
}),
);
await renderAndScan();
const globals = await screen.findByTestId("disk-globals");
expect(globals.textContent).toMatch(/under-reports what a prune would free/);
expect(globals.textContent).toMatch(/executable not found/);
});
it("cannot reclaim with nothing ticked", async () => {
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
expect(screen.getByRole("button", { name: "Reclaim" })).toBeDisabled();
expect(screen.getByText("Nothing ticked.")).toBeInTheDocument();
});
it("refuses to tick a blocked item", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [item({ blocked: "A base-image migration is in flight for this project." })],
}),
);
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
const box = screen.getByRole("checkbox");
expect(box).toBeDisabled();
expect(
screen.getByText("A base-image migration is in flight for this project."),
).toBeInTheDocument();
});
it("keeps semi-safe work out of the one-button bucket", async () => {
// Compaction is a rewrite and cache clearing costs a re-download. Neither
// may be swept up by a Reclaim press aimed at the free wins.
listReclaimable.mockResolvedValue(
plan({
items: [
item(),
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
expect(within(safe).queryByText(/Compact whp/)).not.toBeInTheDocument();
const semi = screen.getByTestId("disk-semi-bucket");
expect(within(semi).getByText("Compact whp's snapshot")).toBeInTheDocument();
expect(within(semi).queryByRole("checkbox")).not.toBeInTheDocument();
});
it("marks a compaction's yield as a bound, never as a measurement", async () => {
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "compact_snapshot", project_id: "p-whp" },
safety: "semi_safe",
label: "Compact whp's snapshot",
bytes: 5_100_000_000,
bytes_are_exact: false,
bytes_floor: 0,
}),
],
}),
);
await renderAndScan();
const semi = await screen.findByTestId("disk-semi-bucket");
expect(within(semi).getByText("up to 5.1 GB")).toBeInTheDocument();
});
it("says out loud when an action reaches the whole daemon", async () => {
// The user's daemon also holds unrelated postgres and site-builder work,
// and a build-cache prune takes their warm cache with ours.
listReclaimable.mockResolvedValue(
plan({
items: [
item({
target: { kind: "build_cache", all: true },
daemon_wide: true,
label: "Build cache, all of it",
bytes: 28_000_000_000,
}),
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
expect(within(safe).getByText("whole daemon")).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Orphan copy — the correction that matters most
// -------------------------------------------------------------------------
it("says what a 'no matching project' volume is derived from", async () => {
// An idle live project has volumes, no container and possibly no image —
// indistinguishable from a deleted one unless you consult the project
// store. The copy must not invite the inference that made that mistake.
getDockerDiskUsage.mockResolvedValue(
report({
orphan_volumes: [
{
name: "triple-c-home-gone",
project_id: "gone",
bytes: 900_000,
role: "home",
created_at: "2026-03-14T09:00:00Z",
},
],
orphan_volume_bytes: 900_000,
}),
);
await renderAndScan();
const globals = await screen.findByTestId("disk-globals");
expect(
within(globals).getByText(/Volumes with no matching project in Triple-C/),
).toBeInTheDocument();
// The sentence is split by an <em>, so match on the container's text.
expect(globals.textContent).toMatch(/is not inferred from a project being stopped/i);
expect(globals.textContent).toMatch(
/A project you have not opened in a while has no container and no snapshot either, and that is normal/i,
);
});
it("explains a suppressed orphan list instead of showing an empty one", async () => {
// With the project store unreadable every project's volumes look
// unclaimed. Showing nothing is right; showing nothing *silently* is not.
getDockerDiskUsage.mockResolvedValue(
report({
orphan_volumes: [],
orphan_volumes_unavailable:
"projects.json could not be read, so there is no way to tell an orphaned volume from a live project's.",
}),
);
await renderAndScan();
const banner = await screen.findByTestId("disk-store-error");
expect(within(banner).getByText("Could not read the project list")).toBeInTheDocument();
expect(within(banner).getByText(/no way to tell/)).toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Windows / WSL2
// -------------------------------------------------------------------------
it("spells out that pruning will not shrink C: on Docker Desktop for Windows", async () => {
getDockerDiskUsage.mockResolvedValue(
report({
host: {
docker_root_dir: "/var/lib/docker",
operating_system: "Docker Desktop",
is_docker_desktop: true,
is_windows_host: true,
vhdx_applies: true,
vhdx_note: "Docker Desktop keeps this daemon inside ext4.vhdx on C:.",
vhdx_fix: ["wsl --shutdown", 'Optimize-VHD -Path "…docker_data.vhdx" -Mode Full'],
vhdx_fix_gui: "Docker Desktop → Settings → Resources → Advanced → Clean up / Purge data",
},
}),
);
await renderAndScan();
const note = await screen.findByTestId("disk-vhdx-note");
expect(note.textContent).toMatch(/Warning: reclaiming here will not shrink your C: drive/);
expect(within(note).getByText(/wsl --shutdown/)).toBeInTheDocument();
expect(within(note).getByText(/Optimize-VHD/)).toBeInTheDocument();
expect(within(note).getByText(/Purge data/)).toBeInTheDocument();
});
it("keeps the vhdx note off a host it does not apply to", async () => {
await renderAndScan();
await screen.findByTestId("disk-globals");
expect(screen.queryByTestId("disk-vhdx-note")).not.toBeInTheDocument();
});
// -------------------------------------------------------------------------
// Destructive path
// -------------------------------------------------------------------------
it("needs the project name typed before it will delete a config volume", async () => {
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "config_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Claude config volume",
loses: "The Claude login credential, plugins, and EVERY conversation transcript.",
bytes: 427_000_000,
blocked: null,
},
],
}),
);
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "config_volume", project_id: "p-whp" },
ok: true,
freed_bytes: 427_000_000,
projected_bytes: null,
message: "Removed volume.",
});
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete claude config volume/ }));
});
const dialog = screen.getByRole("dialog");
const confirm = within(dialog).getByRole("button", { name: "Delete claude config volume" });
expect(confirm).toBeDisabled();
expect(within(dialog).getByText(/EVERY conversation transcript/)).toBeInTheDocument();
// The wrong name does not open the gate.
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "who" } });
expect(confirm).toBeDisabled();
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
expect(confirm).toBeEnabled();
await act(async () => {
fireEvent.click(confirm);
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p-whp" },
"whp",
);
});
it("keeps the confirmation open and busy while the deletion runs", async () => {
// The modal used to be unmounted before the call was awaited, which made
// its whole busy path dead code and left a multi-second volume removal with
// no indication it was happening.
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history and toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
let finish: (value: unknown) => void = () => {};
destroyProjectDiskObject.mockReturnValue(new Promise((r) => (finish = r)));
await renderAndScan();
await screen.findByTestId("disk-row-p-whp");
fireEvent.click(screen.getByRole("button", { name: "Delete whp data" }));
await act(async () => {
fireEvent.click(screen.getByRole("menuitem", { name: /Delete home volume/ }));
});
const dialog = screen.getByRole("dialog");
fireEvent.change(within(dialog).getByLabelText(/Type/), { target: { value: "whp" } });
fireEvent.click(within(dialog).getByRole("button", { name: "Delete home volume" }));
// Still open, and saying so.
await waitFor(() =>
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled(),
);
await act(async () => {
finish({
target: null,
destroyed: { kind: "home_volume", project_id: "p-whp" },
ok: true,
freed_bytes: 4_860_000_000,
projected_bytes: null,
message: "Removed volume.",
});
});
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
});
it("never routes a destructive object through the bulk Reclaim button", async () => {
listReclaimable.mockResolvedValue(
plan({
destructive: [
{
target: { kind: "home_volume", project_id: "p-whp" },
project_id: "p-whp",
project_name: "whp",
label: "Home volume",
loses: "Shell history, dotfiles, toolchains.",
bytes: 4_860_000_000,
blocked: null,
},
],
}),
);
await renderAndScan();
const safe = await screen.findByTestId("disk-safe-bucket");
// One tick, for the dangling images — the home volume is not in this list
// at any price.
expect(within(safe).getAllByRole("checkbox")).toHaveLength(1);
expect(within(safe).queryByText(/Home volume/)).not.toBeInTheDocument();
});
it("reports what was actually freed against what was projected", async () => {
reclaim.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p-whp" },
destroyed: null,
ok: true,
freed_bytes: 5_100_000_000,
projected_bytes: 7_000_000_000,
message: "Rewrote the snapshot into a single layer.",
},
],
total_freed_bytes: 5_100_000_000,
});
await renderAndScan();
await screen.findByTestId("disk-safe-bucket");
fireEvent.click(screen.getAllByRole("checkbox")[0]);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Reclaim" }));
});
const outcome = await screen.findByTestId("disk-outcome");
expect(within(outcome).getByText("Reclaimed 5.1 GB")).toBeInTheDocument();
expect(within(outcome).getByText(/projected up to 7\.0 GB, actually 5\.1 GB/)).toBeInTheDocument();
});
it("surfaces a scan failure as an alert", async () => {
getDockerDiskUsage.mockRejectedValue("Could not read Docker disk usage: no such host");
render(<DiskSettings />);
await act(async () => {
fireEvent.click(screen.getByRole("button", { name: "Scan" }));
});
expect(screen.getByRole("alert")).toHaveTextContent(/no such host/);
});
});
@@ -0,0 +1,559 @@
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);
}
/**
* 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, volumes whose
* project id is not in the project store) gets one list of ticks and one
* button, because none of it can lose anything a user has. Note what the last
* of those is derived from: membership in Triple-C's own project list, never
* "this project has no container" — an idle live project looks exactly like a
* deleted one from the daemon's side, and mistaking the two would delete
* credentials and transcripts. Semi-safe work (compaction, cache clearing) is a rewrite or a
* re-download and is confirmed one at a time. Destructive work — a live
* project's volumes, its snapshot, a live rollback pin — is not in either list:
* it is reached only from that project's own row, behind a typed confirmation,
* and the backend refuses it in bulk by taking a different type entirely.
*/
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);
// 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]);
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);
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;
});
};
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">
<Button variant="primary" size="md" onClick={scan} disabled={scanning}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
<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={plan?.destructive ?? []}
onDestroy={setDestroying}
/>
</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{" "}
<span className="text-[var(--text-disabled)]">
(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
each of these is ticked individually and shows the date Docker created
it.
</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={() => setConfirming(item)}
>
Run
</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">
<StatusIndicator
tone={outcome.results.every((r) => r.ok) ? "ok" : "error"}
label={`Reclaimed ${formatBytes(outcome.total_freed_bytes)}`}
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 && (
<>
{" "}
<span className="text-[var(--text-disabled)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
</>
)}
</li>
))}
</ul>
</section>
)}
{/* --- Semi-safe confirmation ---------------------------------------- */}
{confirming && (
<Modal
title={confirming.label}
onClose={() => setConfirming(null)}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={() => setConfirming(null)}>
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.
await runReclaim([confirming.target]);
setConfirming(null);
}}
>
{working ? "Working…" : "Run it"}
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
<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 && (
<TypedConfirmModal
title={`Delete ${destroying.label.toLowerCase()}`}
expected={destroying.project_name}
confirmLabel={`Delete ${destroying.label.toLowerCase()}`}
busy={working}
onCancel={() => setDestroying(null)}
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.
await destroy(destroying.target, typed);
setDestroying(null);
}}
>
<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>
<p>
Your mounted project folders live on the host and are not affected by this.
</p>
</TypedConfirmModal>
)}
</div>
);
}
@@ -19,6 +19,7 @@ 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();
@@ -173,6 +174,10 @@ export default function SettingsPanel() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
<DiskSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
@@ -0,0 +1,110 @@
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("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("cannot be satisfied by an empty box when there is no name to type", () => {
const { confirm } = renderModal({ expected: "" });
expect(confirm).toBeDisabled();
});
});
+116
View File
@@ -0,0 +1,116 @@
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;
/** 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;
}
/**
* 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,
confirmLabel,
children,
onConfirm,
onCancel,
busy = false,
}: 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>
) : (
<span className="text-[var(--text-disabled)]">
Waiting for the exact project name.
</span>
)}
</p>
</div>
</div>
</Modal>
);
}