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
+6 -6
View File
@@ -3,9 +3,9 @@ import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes
describe("formatBytes", () => {
it("defaults to base 1000, because that is what Docker prints", () => {
// The Disk panel exists to explain `docker system df`, which formats with
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
// 28.0 GB for the same build cache reads as a bug in the panel.
// Anything explaining `docker system df` has to match it, and Docker
// formats with `units.HumanSize` — base 1000. Showing 26.1 GB against a
// terminal saying 28.0 GB for the same object reads as a bug in the app.
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
expect(formatBytes(1_000)).toBe("1.0 KB");
expect(formatBytes(1_500_000)).toBe("1.5 MB");
@@ -108,9 +108,9 @@ describe("formatBytesDelta", () => {
describe("formatBytesCeiling", () => {
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
// Every other figure in the Disk panel is measured. This one cannot be
// known until the rewrite runs, and rendering it through a separate
// function is what stops it being read as a guarantee.
// A projected yield cannot be known until the work runs, unlike every
// measured figure beside it — rendering it through a separate function is
// what stops it being read as a guarantee.
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
});
+16 -9
View File
@@ -17,11 +17,11 @@
*
* ## Why the default is base 1000
*
* The Disk panel exists to explain what `docker system df` reports, and Docker
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
* same build cache would read as a bug in the panel. So decimal is the default
* and binary is opt-in, rather than the other way round.
* Anything explaining what Docker reports has to match it, and Docker formats
* every size it prints with `units.HumanSize`, which is **base 1000**. Showing
* 26.1 GB where the user's terminal said 28.0 GB for the same object would read
* as a bug in the app. So decimal is the default and binary is opt-in, rather
* than the other way round.
*
* Both existing conventions are preserved for every size either call site can
* realistically produce — a file size or a payload size, i.e. a non-negative
@@ -90,6 +90,10 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
* `12.3 GB` → `+12.3 GB`, for a figure that is being *added* rather than
* measured. Used for "next commit adds …", which is the number that explains
* why a snapshot grows.
*
* **No caller on this branch**, for the same reason as [`formatBytesCeiling`]:
* the Disk panel's per-project table was the last one, and it went to
* `hold/disk-and-dragout`.
*/
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
const formatted = formatBytes(bytes, options);
@@ -99,10 +103,13 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s
/**
* `up to 12.3 GB` — for a bound rather than a measurement.
*
* The Disk panel is careful about this distinction: every figure it shows is
* measured except a compaction's yield, which cannot be known until it runs.
* Rendering that one through a different function is what stops it being read
* as a promise.
* A figure that cannot be known until an operation runs must not render like
* one that was measured; going through a different function is what stops it
* being read as a promise.
*
* **No caller on this branch.** Its last one was the Disk panel's projected
* compaction yield, which went to `hold/disk-and-dragout`. Kept with its tests
* because the distinction it encodes is the reusable part.
*/
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
+1 -39
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -97,13 +97,6 @@ export const renameContainerPath = (projectId: string, fromPath: string, toPath:
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
invoke<string>("create_container_directory", { projectId, parentPath, name });
/**
* Copy a container file into an app-owned host temp directory and return the
* absolute host path. The OS can only drag a file that exists on the host, so
* this is the first half of every drag-out.
*/
export const stageContainerFileForDrag = (projectId: string, path: string) =>
invoke<string>("stage_container_file_for_drag", { projectId, path });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
@@ -369,34 +362,3 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
// Disk
/** Measure where the daemon's bytes have gone.
*
* **Expensive — keep it behind an explicit Scan button.** This is
* `GET /system/df`, which walks every image, container and volume on the
* daemon to compute shared-layer sizes, plus an `image_history` per image.
* Seconds on a 100 GB store. Never call it on mount and never poll it. */
export const getDockerDiskUsage = () => invoke<DiskUsageReport>("get_docker_disk_usage");
/** Classify what could be reclaimed, with measured bytes. Takes the report
* from `getDockerDiskUsage` so re-planning costs no second scan. */
export const listReclaimable = (report: DiskUsageReport) =>
invoke<ReclaimPlan>("list_reclaimable", { report });
/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action,
* so no selection built here can delete a live project's data. */
export const reclaim = (targets: ReclaimTarget[]) =>
invoke<ReclaimOutcome>("reclaim", { targets });
/** Delete one object that has no other copy. `confirmation` must be the
* project's name, typed by the user. One target per call, never bulk. */
export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) =>
invoke<ReclaimResult>("destroy_project_disk_object", { target, confirmation });
/** Run the orphaned-snapshot sweep on demand and see its report — the same
* sweep that runs at startup and after every recreation, whose result every
* existing caller throws away. */
export const sweepOrphanedSnapshots = () =>
invoke<SnapshotSweepReport>("sweep_orphaned_snapshots");
-242
View File
@@ -853,245 +853,3 @@ export interface MigrationState {
options: MigrationOptions;
plan: MigrationPlan | null;
}
// ---------------------------------------------------------------------------
// Disk
// ---------------------------------------------------------------------------
//
// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every
// other IPC struct in this app.
/** One row of the per-project disk table. */
export interface ProjectDiskRow {
project_id: string;
project_name: string;
snapshot_image: string;
snapshot_exists: boolean;
/** Total size of the snapshot image, base image included. */
snapshot_bytes: number;
/** Bytes shared with another image — almost always the base. */
snapshot_shared_bytes: number;
/** Layers stacked above the base image: **one per container recreation**.
* This is the number that explains why a snapshot grows — but only when
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
snapshot_commit_layers: number;
/** Whether the base image this snapshot descends from could be identified.
* False is the normal case for a project created before the
* `triple-c.base-image-id` label existed; the layer count must not be
* presented as a recreation count then. */
base_lineage_known: boolean;
/** Bytes those layers account for. `null` when the base image is gone and
* the split cannot be measured — never a guess. */
snapshot_above_base_bytes: number | null;
container_exists: boolean;
container_running: boolean;
/** The writable layer, i.e. exactly what the next commit will add. */
container_writable_bytes: number;
home_volume_bytes: number;
home_volume_present: boolean;
config_volume_bytes: number;
config_volume_present: boolean;
/** **The one snapshot figure a row adds up from.** The Snapshot column shows
* this and `total_bytes` is computed from it, so the Total reconciles with
* its parts. It did not before: the total used `snapshot_bytes -
* snapshot_shared_bytes` unconditionally while the column fell back to
* `snapshot_above_base_bytes` or to `—`, and in that fallback branch the
* subtraction is the *whole base image* — 4.7 GB charged to every row.
*
* Rust computes it in one function (`snapshot_attribution`), in this order:
* a `df()` shared size gives `size - shared`; failing that a known base
* lineage gives the layer arithmetic; failing both it is the full size,
* which is the honest answer for an image nothing shares with.
*
* It is always a number — never null. "Unknown" applies to
* `snapshot_above_base_bytes` (the *split*, which really can be
* unmeasurable) and to the layer count, not to this. */
snapshot_attributed_bytes: number;
total_bytes: number;
migrating: boolean;
}
export interface BaseImageRow {
reference: string;
bytes: number;
shared_bytes: number;
containers: number;
is_labelled_base: boolean;
}
/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies.
* The vhdx copy comes from Rust so the wording cannot drift from the
* constants its tests pin. */
export interface HostStorage {
docker_root_dir: string;
operating_system: string;
is_docker_desktop: boolean;
is_windows_host: boolean;
vhdx_applies: boolean;
/** Empty unless `vhdx_applies`. */
vhdx_note: string;
vhdx_fix: string[];
vhdx_fix_gui: string;
}
export interface BuildCacheUsage {
total_bytes: number;
reclaimable_bytes: number;
/** What a `--filter until=168h` prune would reach. */
stale_bytes: number;
/** `"buildx du"` or `"system df"` — `docker system df` under-reports build
* cache, so which one produced the number is worth showing. */
source: string;
cli_error: string | null;
}
/** A per-project volume whose project id is not in Triple-C's project store.
*
* **Not "a volume with no container".** From the daemon's side an idle live
* project and a deleted one look identical — volumes present, no container,
* nothing running — so only the project store can tell them apart. */
export interface OrphanVolume {
name: string;
project_id: string;
bytes: number;
/** `"home"` or `"config"`. */
role: string;
/** When Docker created it. Evidence a user can recognise a project by; a
* size and a UUID identify nothing. From `df()` metadata — volumes are
* never mounted to inspect them, because `docker run -v` *creates* a
* volume that does not exist. */
created_at: string | null;
}
/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */
export interface DiskUsageReport {
scanned_at: string;
projects: ProjectDiskRow[];
base_images: BaseImageRow[];
base_images_bytes: number;
orphan_image_bytes: number;
orphan_image_count: number;
orphan_volumes: OrphanVolume[];
orphan_volume_bytes: number;
/** Why orphan detection was suppressed, when it was. */
orphan_volumes_unavailable: string | null;
build_cache: BuildCacheUsage;
images_total_bytes: number;
containers_total_bytes: number;
volumes_total_bytes: number;
triple_c_total_bytes: number;
host: HostStorage;
}
/** Mirrors Rust `Safety` (serde snake_case). */
export type ReclaimSafety = "safe" | "semi_safe";
/** Mirrors Rust `ReclaimTarget`, an internally tagged enum.
*
* This type **cannot express a destructive action** — that is
* `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one.
* The separation is structural on both sides on purpose. */
export type ReclaimTarget =
| { kind: "dangling_snapshots" }
| { kind: "superseded_base_images" }
| { kind: "build_cache"; all: boolean }
| { kind: "migration_pins" }
| { kind: "migration_staging" }
| { kind: "probe_containers" }
| { kind: "scrub_containers" }
| { kind: "compact_snapshot"; project_id: string }
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
/** Mirrors Rust `DestructiveTarget`, an internally tagged enum (serde
* `tag = "kind"`, snake_case). Every one of these deletes something with no
* other copy, and needs a name typed to confirm — the *project's* name for
* every variant except `orphan_volume`, which has no project and takes the
* volume's own name. `DestructiveItem.project_name` carries whichever string
* is the one to type. */
export type DestructiveTarget =
| { kind: "home_volume"; project_id: string }
| { kind: "config_volume"; project_id: string }
| { kind: "snapshot_image"; project_id: string }
| { kind: "rollback_pin"; project_id: string; tag: string }
/** A `triple-c-home-*` / `triple-c-claude-config-*` volume whose project id
* is in no `projects.json` this app can find.
*
* **This was a `ReclaimTarget` at `Safety::Safe`** — a tick and a group
* Reclaim button, no confirmation at all. The object behind that tick is a
* `triple-c-claude-config-*` volume holding a Claude OAuth credential,
* every installed plugin and skill, and every conversation transcript that
* project ever had; the *same volume* for a project still in the store
* required typing the project's name. The only difference between the two
* is a lookup against a file this app has been wrong about before — a
* second instance's project is absent from an in-memory list, a corrupt
* `projects.json` empties it, a restored data directory empties it too.
*
* `project_id` is parsed out of the volume name and is display only: it
* names no project in the store, which is the entire definition of this
* variant. Rust's `destroy` takes the orphan arm *before* looking a project
* up, and compares the typed string against `name`. */
| { kind: "orphan_volume"; name: string; project_id: string };
export interface ReclaimItem {
target: ReclaimTarget;
safety: ReclaimSafety;
/** Reaches beyond Triple-C's own objects — true only for the build cache,
* and the UI must say so. */
daemon_wide: boolean;
label: string;
detail: string;
bytes: number;
/** `false` means `bytes` is a bound, not a measurement. Render it as
* "up to …" — only snapshot compaction sets this. */
bytes_are_exact: boolean;
bytes_floor: number | null;
/** Why this cannot run right now. */
blocked: string | null;
}
export interface DestructiveItem {
target: DestructiveTarget;
project_id: string;
project_name: string;
label: string;
/** Spelled out in full — this is the confirmation copy. */
loses: string;
bytes: number;
blocked: string | null;
}
export interface ReclaimPlan {
items: ReclaimItem[];
/** Display only. `reclaim` cannot act on these. */
destructive: DestructiveItem[];
store_error: string | null;
}
export interface ReclaimResult {
/** The reclaim target this reports on, or `null` when it reports a destroy.
* Exactly one of `target` / `destroyed` is ever set — a destroy used to come
* back wearing a `ReclaimTarget` that named work it had not done. */
target: ReclaimTarget | null;
destroyed: DestructiveTarget | null;
ok: boolean;
freed_bytes: number;
/** What was projected beforehand, for the one action that projects. */
projected_bytes: number | null;
message: string;
}
export interface ReclaimOutcome {
results: ReclaimResult[];
total_freed_bytes: number;
}
/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of
* `[reference, error]` pairs — a Rust tuple serialises as an array. */
export interface SnapshotSweepReport {
removed: string[];
reclaimed_bytes: number;
/** Refused because a container is still built from them. Normal. */
in_use: number;
failed: [string, string][];
unavailable: string | null;
}