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:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user