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

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GBq2rGum6GX7xXgsas1fDc
This commit is contained in:
2026-08-23 15:20:22 -07:00
co-authored by Claude Opus 5
parent 6a8972980d
commit ed91423666
41 changed files with 126 additions and 11404 deletions
-473
View File
@@ -1,473 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useDiskUsage, type DiskUsageState } from "./useDiskUsage";
import type { DiskUsageReport } from "../lib/types";
const getDockerDiskUsage = vi.fn();
const listReclaimable = vi.fn();
const reclaim = vi.fn();
const destroyProjectDiskObject = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
getDockerDiskUsage: () => getDockerDiskUsage(),
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
reclaim: (targets: unknown) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
}));
const sweepOrphanedSnapshots = vi.fn();
const report = (scanned_at: string): DiskUsageReport =>
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
const plan = { items: [], destructive: [], store_error: null };
beforeEach(() => {
vi.clearAllMocks();
listReclaimable.mockResolvedValue(plan);
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
describe("useDiskUsage", () => {
it("holds no report until a scan is asked for", () => {
const { result } = renderHook(() => useDiskUsage());
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(getDockerDiskUsage).not.toHaveBeenCalled();
});
it("scans, then plans off the same report rather than scanning again", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(report("first"));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.plan).toEqual(plan);
});
it("lets the newest scan win when two are in flight", async () => {
// A user pressing Scan twice can have two `df()` calls outstanding, and
// the second is not necessarily the slower one. A stale response must not
// overwrite a fresher one.
let resolveFirst: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage
.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = Promise.resolve();
act(() => {
firstScan = result.current.scan();
});
await act(async () => {
await result.current.scan();
});
expect(result.current.report?.scanned_at).toBe("second");
// The slow first scan lands afterwards and is discarded.
await act(async () => {
resolveFirst(report("first"));
await firstScan;
});
expect(result.current.report?.scanned_at).toBe("second");
expect(result.current.scanning).toBe(false);
});
it("passes the ticked targets straight through", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
expect(reclaim).toHaveBeenCalledWith([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
it("does not call the backend for an empty selection", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([]);
});
expect(reclaim).not.toHaveBeenCalled();
});
it("does not re-scan after a reclaim", async () => {
// Another `df()` costs seconds, and the outcome already carries measured
// bytes for every target. A user who wants fresh totals asks for them.
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("clears the previous outcome when a new scan starts", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 });
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.outcome?.total_freed_bytes).toBe(42);
await act(async () => {
await result.current.scan();
});
expect(result.current.outcome).toBeNull();
});
it("forwards the typed confirmation verbatim", async () => {
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "dangling_snapshots" },
ok: true,
freed_bytes: 100,
projected_bytes: null,
message: "gone",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp");
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p1" },
"whp",
);
expect(result.current.outcome?.total_freed_bytes).toBe(100);
});
it("reports a scan failure and keeps the last good measurement", async () => {
// The old report is still an accurate measurement of an earlier moment,
// and the error says the refresh failed. Blanking it would leave the panel
// with nothing while telling the user nothing more.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.scanning).toBe(false);
});
it("never shows fresh totals beside a stale tick list", async () => {
// `setReport` used to land before the plan call was awaited, so a plan
// failure rendered this scan's numbers above the previous scan's rows.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockResolvedValueOnce(report("second"));
listReclaimable.mockRejectedValueOnce("planner exploded");
await act(async () => {
await result.current.scan();
});
expect(result.current.error).toMatch(/planner exploded/);
expect(result.current.report?.scanned_at).toBe("first");
});
it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(result.current.plan).toEqual(plan);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The totals stay — they were measured before the reclaim and the outcome
// says what changed.
expect(result.current.report?.scanned_at).toBe("first");
});
it("runs the sweep through its own command and reports what it refused", async () => {
// The sweep's `in_use` count — orphans Docker refused to delete because a
// stopped project still needs them — is invisible everywhere else in the
// app, because every other caller throws the report away.
sweepOrphanedSnapshots.mockResolvedValue({
removed: ["sha256:a", "sha256:b"],
reclaimed_bytes: 11_900_000_000,
in_use: 3,
failed: [],
unavailable: null,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(sweepOrphanedSnapshots).toHaveBeenCalled();
expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000);
expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/);
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
});
// -------------------------------------------------------------------------
// The scan-versus-mutation race
// -------------------------------------------------------------------------
it("throws away a scan that a reclaim overtook", async () => {
// The live race the generation counter used to miss entirely. A scan takes
// seconds and does not set `working`, so nothing stopped the user
// reclaiming on top of one — and when the scan landed it repainted the
// pre-reclaim report *and* a fresh, clickable plan listing objects the
// reclaim had just deleted.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
expect(result.current.scanning).toBe(true);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The overtaken scan finishes last, and must land nothing at all.
await act(async () => {
resolveScan(report("measured before the reclaim"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
// It does not even get as far as re-planning: a plan built from a report
// this stale is the clickable half of the bug.
expect(listReclaimable).not.toHaveBeenCalled();
});
it("does not strand `scanning` when a mutation retires the scan", async () => {
// `scanning` is cleared against the newest *scan*, not the newest
// generation — a mutation bumps the generation without starting a scan, so
// guarding on that would leave the button reading "Scanning…" forever.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.scanning).toBe(false);
});
it("retires an in-flight scan for a destroy and a sweep too", async () => {
// Every mutation invalidates a measurement, not just the bulk one.
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: true,
freed_bytes: 1,
projected_bytes: null,
message: "gone",
});
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: null,
});
for (const mutate of [
(r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"),
(r: DiskUsageState) => r.runSweep(),
]) {
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await mutate(result.current);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(result.current.scanning).toBe(false);
}
});
// -------------------------------------------------------------------------
// Reporting failure back to the caller
// -------------------------------------------------------------------------
it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => {
// The confirmation dialogs close on completion. Without a return value
// they closed on failure too, leaving the error at the top of a panel the
// user had scrolled well past.
reclaim.mockRejectedValueOnce("compaction failed: no space left on device");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/no space left on device/);
});
it("tells the caller a destroy failed", async () => {
destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/in use by a running container/);
});
it("calls a refusal that came back inside `Ok` a failure, and keeps the plan", async () => {
// `reclaim` reports per-target results, and a compaction the backend
// declined is `ok: false` with a sentence saying why — not a thrown error.
// Treating that as success closed the dialog that asked for it and took
// the tick list away, even though every object it listed is still there.
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p1" },
destroyed: null,
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "Cannot compact p1: a terminal session is still attached.",
},
],
total_freed_bytes: 0,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
expect(result.current.outcome?.results[0].message).toMatch(/still attached/);
});
it("drops the plan when part of a batch did happen", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{ target: { kind: "dangling_snapshots" }, destroyed: null, ok: true, freed_bytes: 12, projected_bytes: null, message: "Removed 3 images" },
{ target: { kind: "compact_snapshot", project_id: "p1" }, destroyed: null, ok: false, freed_bytes: 0, projected_bytes: null, message: "Refused" },
],
total_freed_bytes: 12,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "compact_snapshot", project_id: "p1" },
]);
});
expect(ok).toBe(false);
expect(result.current.plan).toBeNull();
});
it("calls a refused destroy a failure and leaves its row in the plan", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "The volume is still attached to a running container.",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
});
it("reports success when the call came back", async () => {
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(ok).toBe(true);
});
it("treats an unreachable daemon in the sweep report as an error", async () => {
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: "Could not reach the Docker engine",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(result.current.error).toMatch(/Could not reach the Docker engine/);
expect(result.current.outcome).toBeNull();
});
});
-270
View File
@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type {
DestructiveTarget,
DiskUsageReport,
ReclaimOutcome,
ReclaimPlan,
ReclaimTarget,
} from "../lib/types";
/**
* State for the Disk section.
*
* ## Why nothing here runs on mount
*
* A scan is `GET /system/df`, which walks every image, container and volume on
* the daemon and computes shared-layer sizes. On a 100 GB store that is
* seconds. `AccordionSection` unmounts its body when collapsed, so a
* `useEffect` scan would re-run every single time the user opened the section.
* The scan is therefore only ever what the Scan button calls.
*
* Note what that does *not* buy: this hook lives inside `DiskSettings`, which
* the accordion unmounts on collapse, so its state goes with it and reopening
* the section shows an unscanned panel again. That is the honest behaviour —
* a stale total is worse than an absent one — but it means collapsing and
* reopening discards a scan the user paid for. Lifting the report into
* `appState` would fix that and is deliberately not done here: it would put a
* multi-megabyte, rapidly-stale blob into the app-wide store for one panel.
*
* ## The generation guard
*
* A user who hits Scan twice can have two `df()` calls in flight, and they can
* land out of order — the second one is not necessarily slower. Every async
* write in `scan` checks it is still the newest before it lands, the same
* pattern `useContainerMigration` uses.
*
* The race that actually bites, though, is not scan-versus-scan: it is
* scan-versus-**mutation**. A scan takes seconds and does not set `working`, so
* nothing stopped a reclaim starting on top of one. The reclaim correctly drops
* the plan — and then the still-running scan landed, passed its own generation
* check, and repainted a pre-reclaim report *plus a fresh, clickable plan
* listing objects that had just been deleted*. So every mutation bumps the
* counter as well: whatever a scan is holding was measured before the mutation
* and is now a lie, and throwing it away is the only honest thing to do with
* it. (The Scan button is disabled while `working` for the mirror-image case,
* so a scan can never start *during* a mutation.)
*
* That is also why `scanning` is not cleared against the same counter: a
* mutation bumping it mid-scan would strand the flag at true and leave the
* button reading "Scanning…" forever. `latestScan` records the generation the
* newest *scan* owns — only a newer scan may take the flag away — and that is
* what the `finally` compares against.
*/
export interface DiskUsageState {
report: DiskUsageReport | null;
plan: ReclaimPlan | null;
/** A scan is in flight. */
scanning: boolean;
/** A reclaim or a destroy is in flight. */
working: boolean;
error: string | null;
/** The outcome of the last reclaim, kept on screen until the next scan. */
outcome: ReclaimOutcome | null;
scan: () => Promise<void>;
/**
* Resolves `true` only when the work actually happened.
*
* Two different failures reach here and both have to answer `false`. One is
* the call throwing, which lands in `error`. The other is the backend coming
* back inside `Ok` with a *refusal* — `reclaim` reports per-target results,
* and a compaction declined because the project is busy is a `ReclaimResult`
* with `ok: false` and a sentence saying why. Reading only "did it throw"
* treated that refusal as a success: the confirmation dialog closed, the plan
* was dropped, and the explanation appeared in the outcome panel several
* screens above the row the user had clicked.
*
* Callers that dismiss UI on completion — the confirmation dialogs — must
* only dismiss on `true`, and take the wording from `outcome`'s per-result
* `message` rather than writing their own: the backend's sentence is the one
* that names the real blocker.
*/
runReclaim: (targets: ReclaimTarget[]) => Promise<boolean>;
/** Same contract as `runReclaim`: `false` means it did not happen, and either
* `error` or the outcome's `message` says why. */
destroy: (target: DestructiveTarget, confirmation: string) => Promise<boolean>;
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
runSweep: () => Promise<void>;
clearOutcome: () => void;
}
export function useDiskUsage(): DiskUsageState {
const [report, setReport] = useState<DiskUsageReport | null>(null);
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
const [scanning, setScanning] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
const generation = useRef(0);
/** The generation belonging to the most recently *started* scan. */
const latestScan = useRef(0);
/**
* Retire every in-flight scan. Called at the top of each mutation, because
* the moment we start deleting things, a measurement taken before that is no
* longer describing the daemon the user is looking at.
*/
const invalidateScans = useCallback(() => {
generation.current += 1;
}, []);
const scan = useCallback(async () => {
const mine = ++generation.current;
latestScan.current = mine;
setScanning(true);
setError(null);
// The previous outcome describes a state that no longer holds once a new
// scan starts, so it goes rather than sitting beside fresh numbers.
setOutcome(null);
try {
const next = await commands.getDockerDiskUsage();
if (generation.current !== mine) return;
// Planning is cheap and always wanted: the classification is what makes
// the numbers actionable, and it reuses the report rather than scanning
// again.
const nextPlan = await commands.listReclaimable(next);
if (generation.current !== mine) return;
// Both land together, or neither does. Setting the report before
// awaiting the plan would render this scan's totals above the *previous*
// scan's still-clickable tick list if the plan call failed.
setReport(next);
setPlan(nextPlan);
} catch (e) {
if (generation.current !== mine) return;
setError(String(e));
// The old report is left on screen deliberately — it is still an
// accurate measurement of an earlier moment, and the error says the
// refresh failed. What must not survive is a plan describing a scan the
// user can no longer see the totals for, but that cannot happen: the two
// only ever move together.
} finally {
// Deliberately `latestScan`, not `generation`: a mutation that retired
// this scan did not start another one, so this scan is still the last
// word on whether a scan is running.
if (latestScan.current === mine) setScanning(false);
}
}, []);
const runReclaim = useCallback(
async (targets: ReclaimTarget[]): Promise<boolean> => {
// Nothing was asked for, so nothing failed — a caller gating a dialog on
// this must not be left staring at an error that has no cause.
if (targets.length === 0) return true;
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.reclaim(targets);
setOutcome(result);
// **The plan is now stale and must not stay clickable.** Its rows
// describe objects this call just removed, so leaving them ticked lets
// the user fire the same reclaim again against nothing. Dropping the plan
// (not the report) leaves the totals on screen, marked as measured before
// the reclaim, with the tick list gone.
//
// Deliberately no automatic re-scan: it costs another `df()`, and the
// outcome already reports measured bytes for every target — a user who
// wants the new totals asks for them.
//
// The exception is a call that removed *nothing at all* because every
// target was refused: those objects are all still there, so the plan
// still describes the daemon accurately and taking it away would leave
// the user re-scanning to get back a list that never went stale.
const everythingRefused =
result.results.length > 0 && result.results.every((r) => !r.ok);
if (!everythingRefused) setPlan(null);
return result.results.every((r) => r.ok);
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
const destroy = useCallback(
async (target: DestructiveTarget, confirmation: string): Promise<boolean> => {
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.destroyProjectDiskObject(target, confirmation);
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
// Same reasoning as `runReclaim`, refusal included: the destructive
// list named an object that is now gone — unless the backend declined,
// in which case it is still there and so is the row for it.
if (result.ok) setPlan(null);
return result.ok;
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
/**
* The startup sweep, on demand.
*
* Not the same as ticking "superseded snapshot layers", even though both end
* up removing the same images: this reports `in_use` — the orphans Docker
* *refused* to delete because a stopped project's container still needs
* them. That refusal is the sweep's third safety net and it is invisible
* everywhere else in the app, because every existing caller throws the
* report away.
*/
const runSweep = useCallback(async () => {
invalidateScans();
setWorking(true);
setError(null);
try {
const sweep = await commands.sweepOrphanedSnapshots();
if (sweep.unavailable) {
setError(sweep.unavailable);
return;
}
const refused =
sweep.in_use > 0
? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.`
: "";
setOutcome({
results: [
{
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: sweep.failed.length === 0,
freed_bytes: sweep.reclaimed_bytes,
projected_bytes: null,
message: `Swept ${sweep.removed.length} superseded image(s).${refused}`,
},
],
total_freed_bytes: sweep.reclaimed_bytes,
});
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, [invalidateScans]);
const clearOutcome = useCallback(() => setOutcome(null), []);
return {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
};
}
-96
View File
@@ -8,7 +8,6 @@ const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
const stageContainerFileForDrag = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -18,7 +17,6 @@ vi.mock("../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: vi.fn(),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/**
@@ -231,85 +229,6 @@ describe("useFileManager save to host", () => {
});
});
describe("useFileManager drag-out staging", () => {
it("copies the file onto the host and hands back the host path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/a.txt");
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
// The note is transient — it must not still be sitting there afterwards.
expect(result.current.busy).toBeNull();
});
it("reuses the copy on a second drag of the same entry", async () => {
// The whole point of the cache: the copy is the slow half of the gesture,
// and a retry after a drag the OS missed has to be immediate.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let second: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
second = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
expect(second).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: true });
});
it("re-stages once the entry has changed underneath it", async () => {
// Keyed on size and mtime, so a file edited in the container is copied
// again rather than dragged out at its old contents.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.stageForDrag(file("a.txt", { size: 10 }));
await result.current.stageForDrag(file("a.txt", { size: 4096 }));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
});
it("surfaces a refused staging instead of returning a path that is not there", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("huge.bin"));
});
expect(staged).toBeNull();
expect(toastText()).toContain("too large to drag out");
expect(toastText()).toContain("Save to host");
expect(result.current.busy).toBeNull();
});
it("does not cache a failure, so a retry actually retries", async () => {
stageContainerFileForDrag.mockRejectedValueOnce("Container not running");
stageContainerFileForDrag.mockResolvedValueOnce("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
});
});
describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the
@@ -485,21 +404,6 @@ describe("useFileManager overwrite prompt", () => {
});
});
describe("useFileManager staged host paths", () => {
it("recognises a path it staged, and only that path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false);
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
});
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true);
// Same basename, a real host file the user actually wants uploaded.
expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false);
});
});
/**
* The loop, end to end. The prompt only earns its place if the *batch* survives
* it: one answer, given once, has to leave every other file in the drop exactly
+2 -70
View File
@@ -32,15 +32,6 @@ function baseName(path: string): string {
return parts[parts.length - 1] || path;
}
/**
* Host paths compare on separators, not on case: the OS hands a dropped path
* back in whatever form its file dialog produced, and on Windows that is not
* reliably the form `stage_container_file_for_drag` returned.
*/
function normaliseHostPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "");
}
/**
* ## Where failures are reported
*
@@ -51,7 +42,7 @@ function normaliseHostPath(path: string): string {
* no rows, and it is not transient — it stands until the directory lists.
*
* Every **transient operation** failure — upload, rename, create folder,
* save-to-host, drag staging — goes to `ToastHost` instead. Those used to land
* save-to-host — goes to `ToastHost` instead. Those used to land
* in the same inline `error` div, which is the first child of the *scrolling*
* list: three hundred rows down, a refused rename produced no visible change
* at all, just a rename box that stayed open for no stated reason. Worse, the
@@ -91,7 +82,7 @@ export function useFileManager(projectId: string) {
/**
* A slow listing can land after a newer one and set both the rows and the
* breadcrumb back to a directory the user already left. Same generation
* guard `useDiskUsage` and `useContainerMigration` use: every async write
* guard `useContainerMigration` uses: every async write
* checks it is still the newest before it lands.
*/
const navGeneration = useRef(0);
@@ -320,63 +311,6 @@ export function useFileManager(projectId: string) {
[projectId, navigate, startWork, report, askOverwrite],
);
/**
* Host paths already copied out this session, keyed by the entry they came
* from. Size and mtime are in the key, so an entry that changed since the
* last listing re-stages rather than dragging a stale copy.
*/
const stagedRef = useRef(new Map<string, string>());
/**
* The same paths the other way round, as a set.
*
* A drag-out released back inside the app arrives as an ordinary host drop
* carrying the staged copy's path, and uploading that would write the app's
* own temp copy over the container file it came from — which is worse than a
* no-op, because the key above is built from the *last listing*, so a file an
* agent rewrote since then would be replaced by a minutes-old snapshot. This
* set is what makes the "is this ours?" test exact instead of a guess at the
* temp directory's name.
*/
const stagedHostPathsRef = useRef(new Set<string>());
/** True when `path` is a copy this pane staged for a drag-out. */
const isStagedHostPath = useCallback(
(path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)),
[],
);
/**
* Copy an entry onto the host so the OS can drag it, and return the absolute
* host path — or `null`, having reported why, if it could not be staged.
*
* `cached` is what the caller needs to tell a gesture that will feel
* instantaneous from one that has a whole-file copy in front of it: the copy
* is the slow half of a drag-out, and the OS only picks a drag up while the
* button is still down.
*/
const stageForDrag = useCallback(
async (entry: FileEntry): Promise<{ hostPath: string; cached: boolean } | null> => {
const key = `${entry.path}|${entry.size}|${entry.modified}`;
const cached = stagedRef.current.get(key);
if (cached) return { hostPath: cached, cached: true };
startWork(`Preparing "${entry.name}"…`);
try {
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
stagedRef.current.set(key, hostPath);
stagedHostPathsRef.current.add(normaliseHostPath(hostPath));
setCompleted(`"${entry.name}" is ready to drag.`);
return { hostPath, cached: false };
} catch (e) {
report(`Could not prepare "${entry.name}" for dragging`, e);
return null;
} finally {
setBusy(null);
}
},
[projectId, startWork, report],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
@@ -447,8 +381,6 @@ export function useFileManager(projectId: string) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
};