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
+20 -16
View File
@@ -79,23 +79,27 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **Files moves in both directions and neither direction uses HTML5 drag.** Dropping *into*
the pane is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by
a hit-test of the physical-pixel payload position against the pane's rect ÷
- **Files takes drops *in*, and that path does not use HTML5 drag.** Dropping into the pane
is Tauri's native `onDragDropEvent`, which is window-wide and therefore routed by a
hit-test of the physical-pixel payload position against the pane's rect ÷
`devicePixelRatio` — a hidden pane has a zero-size rect, which is what stops it and
`TerminalView`'s listener both firing. Dragging *out* is pointer events into
`tauri-plugin-drag`, for the same `dragDropEnabled` reason the tab strip is pointer-driven.
- **A drag-out is a copy first and a drag second.** The OS can only drag a path that exists
on the host, and these files are inside a container, so `stage_container_file_for_drag`
materialises one into `<os-temp>/triple-c-drag-out/<session>/` (via the shared
`fetch_container_file`, keeping the original filename, capped at the same 256 MiB as an
upload) and `startDrag` is handed *that*. Two consequences worth keeping: the copy is an
async gap inside a gesture that feels instantaneous, so the staged path is cached and the
UI says "drag it again" when the pointer came up first; and the staging directory is
cleared on exit **and** reaped at startup, because a drag-out quietly filling the host temp
dir with whole files would be the disk problem this project just fixed, in a new place.
"Save to host…" stays — `startDrag` is an enhancement and can fail per platform.
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
`TerminalView`'s listener both firing. Keep `lib/dropTarget.ts` and both listeners.
- **Getting a file *out* is "Save to host…", and there is no other route.** OS drag-out —
`tauri-plugin-drag`, `stage_container_file_for_drag` and its host staging directory — was
removed from the ship branch and held back for separate hardening; it lives on
`hold/disk-and-dragout`. Do not re-add `drag:allow-start-drag` or a staging command
without taking that work back whole: the plugin has no scope mechanism, so the grant lets
a compromised webview start a drag on *any* host path the user can read, and the staging
directory is a host-temp disk leak with a gesture attached unless its exit-clear and
startup-reap come back with it.
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth.
There is deliberately **no Disk panel** here. The disk survey and its reclaim / destroy /
compaction surface were held back for separate hardening and live on `hold/disk-and-dragout`;
one of their IPC commands was a verified arbitrary-DELETE primitive, so if that work returns it
returns whole, `generate_handler!` entries and typed confirmations included. The *prevention*
half stayed and is not disk-panel code: the pre-commit scrub in `docker/container.rs`, capped
container logs, the `triple-c.base` / `triple-c.managed` labels, `sweep_orphaned_snapshots` and
the startup housekeeping in `lib.rs`, the migration reapers, and `project_lock.rs`.
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
+4 -2
View File
@@ -1164,8 +1164,10 @@ When you scroll up in the terminal to review previous output, a **Jump to Curren
The **Files** tab of Project Home browses inside a running container. You can:
- **Browse** the container filesystem, starting at `/workspace`, with breadcrumb navigation
- **Download** any file to your host machine via the **Download** button on each file entry
- **Upload file** from your host into the current container directory
- **Save to host…** — copy any file out to a location you pick. This is the way to get a file out
of a container; there is one button per file entry, and the file viewer offers it too
- **Upload file** from your host into the current container directory — or **drop files straight
onto the pane** from your desktop, which uploads them into the directory on screen
- **Refresh** the directory listing at any time
The listing shows file names, sizes, and modification dates.
-9
View File
@@ -8,7 +8,6 @@
"name": "triple-c",
"version": "0.4.0",
"dependencies": {
"@crabnebula/tauri-plugin-drag": "^2.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
@@ -414,14 +413,6 @@
"specificity": "bin/cli.js"
}
},
"node_modules/@crabnebula/tauri-plugin-drag": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/@crabnebula/tauri-plugin-drag/-/tauri-plugin-drag-2.1.0.tgz",
"integrity": "sha512-LnUXAZwQt1cdMoGDLJ6ogW9wFCYServCZXlGadS7CA+CZ9eXS7L+Q7QyQW6g/zGw9YI2MwKFqf1aSNBGyWw+OA==",
"dependencies": {
"@tauri-apps/api": "^2.0.0"
}
},
"node_modules/@csstools/color-helpers": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz",
-1
View File
@@ -12,7 +12,6 @@
"test:watch": "vitest"
},
"dependencies": {
"@crabnebula/tauri-plugin-drag": "^2.1.0",
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
+15 -185
View File
@@ -630,19 +630,6 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-graphics"
version = "0.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.11.0",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"libc",
]
[[package]]
name = "core-graphics"
version = "0.25.0"
@@ -1029,28 +1016,6 @@ dependencies = [
"serde",
]
[[package]]
name = "drag"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7e90b4a25ace5ce0561534b073943594cbcd21af936e64d09aec444568411f8c"
dependencies = [
"core-graphics 0.24.0",
"dunce",
"gdk",
"gdkx11",
"gtk",
"log",
"objc2",
"objc2-app-kit",
"objc2-foundation",
"raw-window-handle",
"serde",
"thiserror 2.0.18",
"windows 0.52.0",
"windows-core 0.58.0",
]
[[package]]
name = "dtoa"
version = "1.0.11"
@@ -2682,17 +2647,9 @@ checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.11.0",
"block2",
"libc",
"objc2",
"objc2-cloud-kit",
"objc2-core-data",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-core-image",
"objc2-core-text",
"objc2-core-video",
"objc2-foundation",
"objc2-quartz-core",
]
[[package]]
@@ -2712,7 +2669,6 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b402a653efbb5e82ce4df10683b6b28027616a2715e90009947d50b8dd298fa"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-foundation",
]
@@ -2773,19 +2729,6 @@ dependencies = [
"objc2-core-graphics",
]
[[package]]
name = "objc2-core-video"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d425caf1df73233f29fd8a5c3e5edbc30d2d4307870f802d18f00d83dc5141a6"
dependencies = [
"bitflags 2.11.0",
"objc2",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-io-surface",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
@@ -4457,7 +4400,7 @@ dependencies = [
"bitflags 2.11.0",
"block2",
"core-foundation 0.10.1",
"core-graphics 0.25.0",
"core-graphics",
"crossbeam-channel",
"dbus",
"dispatch2",
@@ -4482,7 +4425,7 @@ dependencies = [
"tao-macros",
"unicode-segmentation",
"url",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
@@ -4565,7 +4508,7 @@ dependencies = [
"webkit2gtk",
"webview2-com",
"window-vibrancy",
"windows 0.61.3",
"windows",
]
[[package]]
@@ -4665,21 +4608,6 @@ dependencies = [
"url",
]
[[package]]
name = "tauri-plugin-drag"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "729ca0ce4b1169869d3405216d3c09a524f41ea5e2eec89f917cd6623f8a70ca"
dependencies = [
"base64 0.22.1",
"drag",
"serde",
"serde_json",
"tauri",
"tauri-plugin",
"thiserror 2.0.18",
]
[[package]]
name = "tauri-plugin-fs"
version = "2.5.0"
@@ -4722,7 +4650,7 @@ dependencies = [
"tauri-plugin",
"thiserror 2.0.18",
"url",
"windows 0.61.3",
"windows",
"zbus",
]
@@ -4748,7 +4676,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows",
]
[[package]]
@@ -4773,7 +4701,7 @@ dependencies = [
"url",
"webkit2gtk",
"webview2-com",
"windows 0.61.3",
"windows",
"wry",
]
@@ -5242,7 +5170,6 @@ dependencies = [
"tauri",
"tauri-build",
"tauri-plugin-dialog",
"tauri-plugin-drag",
"tauri-plugin-opener",
"tokio",
"tower-http",
@@ -5695,10 +5622,10 @@ checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a"
dependencies = [
"webview2-com-macros",
"webview2-com-sys",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
]
[[package]]
@@ -5719,7 +5646,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c"
dependencies = [
"thiserror 2.0.18",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
]
@@ -5769,18 +5696,6 @@ dependencies = [
"windows-version",
]
[[package]]
name = "windows"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e48a53791691ab099e5e2ad123536d0fff50652600abaf43bbf952894110d0be"
dependencies = [
"windows-core 0.52.0",
"windows-implement 0.52.0",
"windows-interface 0.52.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows"
version = "0.61.3"
@@ -5803,36 +5718,14 @@ dependencies = [
"windows-core 0.61.2",
]
[[package]]
name = "windows-core"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "33ab640c8d7e35bf8ba19b884ba838ceb4fba93a4e8c65a9059d08afcfc683d9"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-core"
version = "0.61.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
"windows-link 0.1.3",
"windows-result 0.3.4",
"windows-strings 0.4.2",
@@ -5844,8 +5737,8 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement 0.60.2",
"windows-interface 0.59.3",
"windows-implement",
"windows-interface",
"windows-link 0.2.1",
"windows-result 0.4.1",
"windows-strings 0.5.1",
@@ -5862,28 +5755,6 @@ dependencies = [
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12168c33176773b86799be25e2a2ba07c7aab9968b37541f1094dbd7a60c8946"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
@@ -5895,28 +5766,6 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d8dc32e0095a7eeccebd0e3f09e9509365ecb3fc6ac4d6f5f14a3f6392942d1"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
@@ -5950,15 +5799,6 @@ dependencies = [
"windows-link 0.1.3",
]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]]
name = "windows-result"
version = "0.3.4"
@@ -5977,16 +5817,6 @@ dependencies = [
"windows-link 0.2.1",
]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]]
name = "windows-strings"
version = "0.4.2"
@@ -6414,7 +6244,7 @@ dependencies = [
"webkit2gtk",
"webkit2gtk-sys",
"webview2-com",
"windows 0.61.3",
"windows",
"windows-core 0.61.2",
"windows-version",
"x11-dl",
-1
View File
@@ -36,7 +36,6 @@ tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
tauri-plugin-drag = "2.1"
[dev-dependencies]
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
+2 -3
View File
@@ -1,6 +1,6 @@
{
"identifier": "default",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource` (`startDrag`'s `Channel` is not one); and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"description": "Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.",
"windows": ["main"],
"permissions": [
"core:event:allow-listen",
@@ -11,7 +11,6 @@
{
"identifier": "opener:allow-open-url",
"allow": [{ "url": "http://*" }, { "url": "https://*" }]
},
"drag:allow-start-drag"
}
]
}
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -1 +1 @@
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource` (`startDrag`'s `Channel` is not one); and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` stays, and cannot be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read. It is not a silent exfiltration primitive: the drag only delivers anything if the user completes a real drop onto a real target, and the OS shows the drag under the cursor while it is in flight. Removing it would remove drag-out from the Files pane (`stage_container_file_for_drag`), whose fallback is the explicit \"Save to host…\" action. Accepted residual risk. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]},"drag:allow-start-drag"]}}
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — verified against tauri 2.11.0's `PLUGINS` table in `build.rs`, not assumed from a plugin's `default` set. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` cannot be narrowed by host. `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist here would delete the feature rather than bound it. What *is* bounded: `opener:default` is not used, so `open_path` and `reveal_item_in_dir` are absent; the scope's two entries restrict the scheme to http/https (`file:`, `mailto:`, `tel:`, `smb:` are all refused by `Scope::is_url_allowed`); and because each entry leaves `app` at its serde default of `Application::Default`, which matches only `with == None`, `openUrl(url, \"/bin/sh\")` is refused — the `with` argument is not a usable exec primitive. The call sites re-validate through `sanitizeRelayUrl` (scheme allowlist, no embedded credentials, length cap) before anything reaches the opener. Accepted residual risk: a compromised webview can make the OS open an attacker-chosen http(s) URL, which is an outbound channel. Recorded here rather than fixed. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`), the plugin is no longer a dependency, and getting a file out of a container is now the explicit \"Save to host…\" action, which never touches this permission. Note that dragging files *into* the app is unaffected: `dragDropEnabled` and `onDragDropEvent` are core webview behaviour and need no grant. Historical note kept because it is easy to re-introduce: the `store:*` grants were removed — nothing in `app/src` uses `@tauri-apps/plugin-store`, and the plugin's `resolve_store_path` is a `PathBuf::push` against AppData, which `push` discards outright when handed an absolute path, so the grant was an arbitrary host-file read/write primitive (`plugin:store|load` + `set` + `save` on `~/.claude/settings.json` is host code execution). On the CSP side: `app.security.csp` in `tauri.conf.json` covers the shipped bundle, and there is deliberately no `devCsp`. `npm run tauri dev` loads the main document straight from Vite at `build.devUrl` (`http://localhost:1420`), and Tauri only attaches a CSP to documents it serves itself — `protocol/tauri.rs:217` sets the header on `tauri://` assets, and the dev server is proxied through that protocol only when `PROXY_DEV_SERVER`, which is `cfg!(all(dev, mobile))` and therefore false for every desktop build. A `devCsp` here would be inert config that reads as protection, which is worse than its absence. If a CSP in dev is wanted, the only place that can set one is the Vite dev server's own `server.headers` in `app/vite.config.ts`; it is not set today, and dev is not the shipped configuration.","local":true,"windows":["main"],"permissions":["core:event:allow-listen","core:event:allow-unlisten","core:webview:allow-internal-toggle-devtools","dialog:allow-open","dialog:allow-save",{"identifier":"opener:allow-open-url","allow":[{"url":"http://*"},{"url":"https://*"}]}]}}
@@ -2426,24 +2426,6 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
"type": "string",
"const": "drag:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
},
{
"description": "Enables the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:allow-start-drag",
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
},
{
"description": "Denies the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:deny-start-drag",
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -2426,24 +2426,6 @@
"const": "dialog:deny-save",
"markdownDescription": "Denies the save command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`",
"type": "string",
"const": "drag:default",
"markdownDescription": "Default permissions for the plugin\n#### This default permission set includes:\n\n- `allow-start-drag`"
},
{
"description": "Enables the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:allow-start-drag",
"markdownDescription": "Enables the start_drag command without any pre-configured scope."
},
{
"description": "Denies the start_drag command without any pre-configured scope.",
"type": "string",
"const": "drag:deny-start-drag",
"markdownDescription": "Denies the start_drag command without any pre-configured scope."
},
{
"description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`",
"type": "string",
@@ -54,80 +54,3 @@ pub async fn list_sibling_containers() -> Result<Vec<serde_json::Value>, String>
.collect();
Ok(result)
}
// ---------------------------------------------------------------------------
// Disk
// ---------------------------------------------------------------------------
//
// The disk view's IPC surface. It lives here rather than in a module of its own
// for the same reason `check_image_exists` does: these are thin shims over
// `crate::docker`, and the logic they call is in `docker/disk.rs` where it can
// be unit-tested without a daemon.
/// Measure where the daemon's bytes have gone.
///
/// **Expensive on purpose.** This is `GET /system/df` plus an `image_history`
/// per distinct image, and `df()` walks every image, container and volume on
/// the daemon to compute shared-layer sizes. On a 100 GB store that is seconds.
/// The frontend must keep it behind an explicit Scan button — never on panel
/// open, never on a timer.
#[tauri::command]
pub async fn get_docker_disk_usage(
state: State<'_, AppState>,
) -> Result<docker::disk::DiskUsageReport, String> {
let projects = state.projects_store.list();
docker::disk::scan(&projects).await
}
/// Everything that could be reclaimed, each with its measured cost.
///
/// Takes the report from [`get_docker_disk_usage`] rather than re-measuring, so
/// a user who re-plans after ticking a box does not pay for a second `df()`.
#[tauri::command]
pub async fn list_reclaimable(
report: docker::disk::DiskUsageReport,
state: State<'_, AppState>,
) -> Result<docker::disk::ReclaimPlan, String> {
let projects = state.projects_store.list();
docker::disk::list_reclaimable(&projects, &report).await
}
/// Run the ticked targets and report what each one actually freed.
///
/// `ReclaimTarget` cannot express a destructive action — that is a different
/// type, reached only through [`destroy_project_disk_object`] with a typed
/// confirmation — so there is no selection a user can build here that deletes a
/// live project's data.
#[tauri::command]
pub async fn reclaim(
targets: Vec<docker::disk::ReclaimTarget>,
state: State<'_, AppState>,
) -> Result<docker::disk::ReclaimOutcome, String> {
let projects = state.projects_store.list();
Ok(docker::disk::reclaim(&targets, &projects).await)
}
/// Delete one object that has no other copy, against a typed confirmation of
/// the project's name.
///
/// Deliberately one target per call: this is never part of a bulk action.
#[tauri::command]
pub async fn destroy_project_disk_object(
target: docker::disk::DestructiveTarget,
confirmation: String,
state: State<'_, AppState>,
) -> Result<docker::disk::ReclaimResult, String> {
let projects = state.projects_store.list();
docker::disk::destroy(&target, &confirmation, &projects).await
}
/// Run the orphaned-snapshot sweep on demand and return its report.
///
/// The sweep already runs at startup, after every recreation and after a
/// migration settles, but every one of those callers throws the report away —
/// so a user has never been able to see that 11.9 GB of superseded images were
/// found and left because a stopped container still pinned them.
#[tauri::command]
pub async fn sweep_orphaned_snapshots() -> Result<docker::SnapshotSweepReport, String> {
Ok(docker::sweep_orphaned_snapshots().await)
}
+3 -350
View File
@@ -1,7 +1,6 @@
use std::path::{Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::{Duration, SystemTime};
use std::sync::Arc;
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine as _;
@@ -9,7 +8,7 @@ use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContai
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt;
use serde::Serialize;
use tauri::{AppHandle, Manager, State};
use tauri::State;
use crate::docker::client::get_docker;
use crate::docker::exec::{
@@ -1271,241 +1270,6 @@ pub async fn read_container_file(
})
}
// ─────────────────────────────────────────────────────────────────────────────
// Drag-out staging
// ─────────────────────────────────────────────────────────────────────────────
//
// Dragging a file onto the host desktop hands the OS a *host* path, and the
// files in this panel live inside a container, where nothing on the desktop can
// reach them. So a drag-out is really a copy-then-drag: materialise the file
// into a host temp directory first, then start the native drag on that copy.
//
// The copy is the reason this section carries a lifecycle. A staging directory
// nobody empties is a disk leak with a gesture attached to it, so there are two
// halves and both matter: `clear_drag_staging` on exit, and
// `reap_drag_staging` at startup for whatever a crash left behind.
/// Ceiling on one staged copy. Deliberately the same 256 MiB as
/// [`MAX_UPLOAD_BYTES`] — it is the same whole-file-through-host-RAM round trip,
/// only in the other direction.
const MAX_DRAG_STAGE_BYTES: u64 = 256 * 1024 * 1024;
/// Name of the app-owned directory inside the OS temp dir. Everything staged by
/// any Triple-C process lives under it, so housekeeping has exactly one place to
/// look and never walks the rest of the user's temp dir.
const DRAG_STAGE_DIR_NAME: &str = "triple-c-drag-out";
/// How long *another* process's leftover staging directory may sit before
/// startup housekeeping deletes it.
///
/// Only ever applied to directories this process does not own (see
/// [`drag_stage_session_dir`]), so it is not a limit on how long a staged file
/// survives in a live session — it is the crash-recovery threshold, and it is
/// generous because a second Triple-C running right now would also look like a
/// leftover.
const DRAG_STAGE_MAX_AGE: Duration = Duration::from_secs(24 * 60 * 60);
/// This process's own sub-directory name, stable for the life of the process.
///
/// Per-process rather than shared so exit cleanup can delete *ours* outright
/// without reaching into a directory another instance may be dragging out of.
fn drag_stage_session() -> &'static str {
static SESSION: OnceLock<String> = OnceLock::new();
SESSION.get_or_init(|| uuid::Uuid::new_v4().to_string())
}
/// The app-owned staging root inside `temp_dir`.
///
/// Takes the temp dir rather than reading it, because on Windows it is neither
/// `/tmp` nor a constant — Tauri's path API is the only thing that knows it —
/// and because a pure function is what the tests can drive.
pub fn drag_stage_root(temp_dir: &Path) -> PathBuf {
temp_dir.join(DRAG_STAGE_DIR_NAME)
}
/// This process's staging directory: `<temp>/triple-c-drag-out/<session>`.
pub fn drag_stage_session_dir(temp_dir: &Path) -> PathBuf {
drag_stage_root(temp_dir).join(drag_stage_session())
}
/// The per-file sub-directory a staged copy lives in, derived from the
/// container path.
///
/// Filenames are only unique within a directory, so `a/notes.txt` and
/// `b/notes.txt` would otherwise be the same host path — and the second drag
/// would silently rewrite the first one's contents under the first one's cached
/// path. A digest of the full container path separates them while staying
/// *deterministic*, so re-staging the same file reuses its slot instead of
/// growing a new one every drag.
fn drag_stage_slot(container_path: &str) -> String {
use sha2::{Digest, Sha256};
let digest = Sha256::digest(container_path.as_bytes());
digest[..8].iter().map(|b| format!("{:02x}", b)).collect()
}
/// The name the staged copy is given on the host.
///
/// The whole point is that what lands on the desktop is called `notes.txt` and
/// not `tmp1234`, so the container's basename is kept verbatim wherever it can
/// be. Only the characters Windows refuses outright are substituted — a Linux
/// file really can be called `a:b`, and the staged copy has to exist on NTFS.
/// A name that is not a filename at all (empty, `.`, `..`) is rejected rather
/// than invented: that means the caller passed something that never named a
/// file, and quietly inventing a name would stage the wrong thing.
fn stage_file_name(container_path: &str) -> Result<String, String> {
let base = container_path
.trim_end_matches('/')
.rsplit('/')
.next()
.unwrap_or("");
let cleaned: String = base
.chars()
.map(|c| match c {
'<' | '>' | ':' | '"' | '/' | '\\' | '|' | '?' | '*' => '_',
c if (c as u32) < 0x20 => '_',
c => c,
})
.collect();
// Windows also silently drops a trailing dot or space, which would make the
// path we hand back not the path that exists.
let cleaned = cleaned.trim_end_matches([' ', '.']);
if cleaned.is_empty() || cleaned == "." || cleaned == ".." {
return Err(format!("{} does not name a file", container_path));
}
Ok(cleaned.to_string())
}
/// Reject an oversize file *by its real size*, before anything is written.
///
/// Split out so the ceiling and its wording are testable without a container.
/// The message names the fallback, because "too large" with no way forward is
/// the one thing a size cap must not be.
fn check_stage_size(size: u64) -> Result<(), String> {
if size > MAX_DRAG_STAGE_BYTES {
return Err(format!(
"{:.0} MB is too large to drag out (limit {} MB) — use \"Save to host…\" instead.",
size as f64 / (1024.0 * 1024.0),
MAX_DRAG_STAGE_BYTES / (1024 * 1024)
));
}
Ok(())
}
/// Whether a leftover staging directory is old enough to delete.
///
/// A modification time in the *future* (a clock step, a copied temp dir) makes
/// `duration_since` fail, and that answers "not stale" — housekeeping deleting
/// something it cannot date is worse than leaving it for the next startup.
fn drag_stage_is_stale(modified: SystemTime, now: SystemTime, max_age: Duration) -> bool {
now.duration_since(modified)
.map(|age| age >= max_age)
.unwrap_or(false)
}
/// Delete every staging directory except this process's own, once it is older
/// than [`DRAG_STAGE_MAX_AGE`]. Called from startup housekeeping.
pub async fn reap_drag_staging(temp_dir: PathBuf) {
let root = drag_stage_root(&temp_dir);
let keep = drag_stage_session_dir(&temp_dir);
let now = SystemTime::now();
let mut dir = match tokio::fs::read_dir(&root).await {
Ok(dir) => dir,
// Nothing staged yet is the normal case, not a problem.
Err(_) => return,
};
let mut reaped = 0usize;
while let Ok(Some(entry)) = dir.next_entry().await {
let path = entry.path();
if path == keep {
continue;
}
let stale = match entry.metadata().await.and_then(|m| m.modified()) {
Ok(modified) => drag_stage_is_stale(modified, now, DRAG_STAGE_MAX_AGE),
Err(_) => false,
};
if !stale {
continue;
}
if tokio::fs::remove_dir_all(&path).await.is_ok() {
reaped += 1;
}
}
if reaped > 0 {
log::info!("Startup housekeeping removed {} stale drag-out staging directory(ies)", reaped);
}
}
/// Delete this process's staging directory. Called from the shutdown teardown.
pub async fn clear_drag_staging(temp_dir: PathBuf) {
let dir = drag_stage_session_dir(&temp_dir);
if let Err(e) = tokio::fs::remove_dir_all(&dir).await {
if e.kind() != std::io::ErrorKind::NotFound {
log::warn!("Failed to clear drag-out staging at {}: {}", dir.display(), e);
}
}
// Best effort: leave no empty root behind either. Fails harmlessly while
// another instance still has a directory in there.
let _ = tokio::fs::remove_dir(drag_stage_root(&temp_dir)).await;
}
/// Copy a container file onto the host so it can be dragged to the desktop, and
/// return the absolute host path.
///
/// Reuses [`fetch_container_file`] rather than extracting a second way, so a
/// dragged file, a downloaded file and a previewed file are byte-identical and
/// refuse folders and links with the same words. The fetch is capped at
/// [`MAX_DRAG_STAGE_BYTES`], so an oversize file is recognised from the tar
/// header without being pulled across the socket in full.
#[tauri::command]
pub async fn stage_container_file_for_drag(
app: AppHandle,
project_id: String,
path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
validate_container_path("File", &path)?;
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
// Before the transfer: a path that cannot become a host filename is not
// worth a round trip.
let file_name = stage_file_name(&path)?;
let fetched = fetch_container_file(container_id, &path, MAX_DRAG_STAGE_BYTES).await?;
// `size` is the tar entry's, i.e. the file's real size, which is exactly
// what a truncated fetch does not tell you from `bytes.len()`.
check_stage_size(fetched.size)?;
let temp_dir = app
.path()
.temp_dir()
.map_err(|e| format!("No host temporary directory available: {}", e))?;
let dir = drag_stage_session_dir(&temp_dir).join(drag_stage_slot(&path));
tokio::fs::create_dir_all(&dir)
.await
.map_err(|e| format!("Failed to create the drag staging directory: {}", e))?;
let dest = dir.join(&file_name);
tokio::fs::write(&dest, &fetched.bytes)
.await
.map_err(|e| format!("Failed to stage {} on the host: {}", file_name, e))?;
Ok(dest.to_string_lossy().to_string())
}
/// Rename an entry in place. `to_path` is the **new name**, not a destination
/// path — moving between directories is deliberately not offered here, so the
/// name is validated to carry no `/`.
@@ -2425,8 +2189,7 @@ mod tests {
// `fetch_container_file` takes a plain `u64` now, so the `None` that
// made the cap inert cannot be written again. These are the two callers
// left, and both buffer.
assert!(MAX_READ_BYTES <= MAX_DRAG_STAGE_BYTES);
assert!(MAX_DRAG_STAGE_BYTES < MAX_DOWNLOAD_BYTES);
assert!(MAX_READ_BYTES < MAX_DOWNLOAD_BYTES);
}
#[test]
@@ -2445,116 +2208,6 @@ mod tests {
);
}
// ── Drag-out staging ────────────────────────────────────────────────────
#[test]
fn the_staging_path_is_built_under_the_supplied_temp_dir() {
// Never `/tmp`: on Windows the temp dir is per-user and nowhere near it,
// so the whole path has to be derived from what Tauri hands us.
let temp = Path::new("/somewhere/else");
let root = drag_stage_root(temp);
assert_eq!(root, Path::new("/somewhere/else/triple-c-drag-out"));
let session = drag_stage_session_dir(temp);
assert_eq!(session.parent(), Some(root.as_path()));
assert!(session.starts_with(root));
}
#[test]
fn every_call_in_a_process_stages_into_the_same_session_directory() {
// Exit cleanup deletes this directory by name rather than tracking what
// it wrote, which only works if the name does not move.
let temp = Path::new("/tmp-ish");
assert_eq!(drag_stage_session_dir(temp), drag_stage_session_dir(temp));
assert_ne!(drag_stage_session_dir(temp), drag_stage_root(temp));
}
#[test]
fn the_staged_copy_keeps_the_original_file_name() {
// The reason the feature stages into a per-session directory at all: a
// plain temp file would be dropped onto the desktop called `tmp1234`.
assert_eq!(stage_file_name("/workspace/notes.txt").unwrap(), "notes.txt");
assert_eq!(stage_file_name("/workspace/a b/.env").unwrap(), ".env");
assert_eq!(stage_file_name("report.pdf").unwrap(), "report.pdf");
assert_eq!(stage_file_name("/workspace/über.md").unwrap(), "über.md");
}
#[test]
fn a_name_windows_cannot_hold_is_substituted_rather_than_dropped() {
// These are all legal on Linux and all refused by NTFS, and the staged
// copy has to exist on the host we are dragging onto.
assert_eq!(stage_file_name("/workspace/a:b.txt").unwrap(), "a_b.txt");
assert_eq!(stage_file_name("/workspace/q?.log").unwrap(), "q_.log");
assert_eq!(stage_file_name("/workspace/a\\b").unwrap(), "a_b");
// A trailing dot or space is not refused, it is silently dropped — so
// the path we return would not be the path that exists.
assert_eq!(stage_file_name("/workspace/trailing. ").unwrap(), "trailing");
}
#[test]
fn a_path_that_does_not_name_a_file_is_refused_not_invented() {
assert!(stage_file_name("/").is_err());
assert!(stage_file_name("").is_err());
assert!(stage_file_name("/workspace/..").is_err());
assert!(stage_file_name("/workspace/.").is_err());
// Trims down to nothing, which is the same problem one step later.
assert!(stage_file_name("/workspace/...").is_err());
}
#[test]
fn two_files_with_the_same_name_stage_to_different_places() {
// Names are unique per directory, not per container — and the second
// drag would otherwise rewrite the first one's bytes under the path the
// first one is still cached at.
assert_ne!(
drag_stage_slot("/workspace/a/notes.txt"),
drag_stage_slot("/workspace/b/notes.txt")
);
}
#[test]
fn re_staging_the_same_file_reuses_its_slot() {
// Deterministic, so a file dragged repeatedly does not grow a new
// directory in the host temp dir every time.
assert_eq!(
drag_stage_slot("/workspace/notes.txt"),
drag_stage_slot("/workspace/notes.txt")
);
// Short enough to keep the path sane, long enough not to collide.
assert_eq!(drag_stage_slot("/workspace/notes.txt").len(), 16);
}
#[test]
fn the_drag_size_cap_matches_the_established_ceiling_and_names_the_fallback() {
assert_eq!(MAX_DRAG_STAGE_BYTES, MAX_UPLOAD_BYTES);
assert!(check_stage_size(MAX_DRAG_STAGE_BYTES).is_ok());
let err = check_stage_size(MAX_DRAG_STAGE_BYTES + 1).unwrap_err();
assert!(err.contains("256 MB"), "{}", err);
// A size cap with no way forward is the one thing this must not be.
assert!(err.contains("Save to host"), "{}", err);
}
#[test]
fn the_reaper_only_takes_entries_past_the_age_threshold() {
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let age = Duration::from_secs(3_600);
assert!(drag_stage_is_stale(now - Duration::from_secs(3_601), now, age));
assert!(drag_stage_is_stale(now - age, now, age));
assert!(!drag_stage_is_stale(now - Duration::from_secs(3_599), now, age));
assert!(!drag_stage_is_stale(now, now, age));
}
#[test]
fn a_future_timestamp_is_left_alone_rather_than_reaped() {
// A clock step must not turn housekeeping into deletion of something it
// cannot date.
let now = SystemTime::UNIX_EPOCH + Duration::from_secs(1_000_000);
let age = Duration::from_secs(3_600);
assert!(!drag_stage_is_stale(now + Duration::from_secs(60), now, age));
}
// ── Host path normalisation, on every platform ──────────────────────────
#[test]
@@ -243,6 +243,13 @@ async fn container_label(container_id: &str, label: &str) -> Option<String> {
/// function is the specialisation of it that reconcile still needs: a *live*
/// migration is indistinguishable from a crashed one from the outside, and only
/// this process knows which it is looking at.
///
/// No production caller on this branch: the Disk panel's survey was the last
/// one, and it went to `hold/disk-and-dragout`. Kept — and still exercised by
/// `a_live_migration_is_distinguishable_from_a_crashed_one` — because it is the
/// one named answer to that question and re-inventing it is how the two
/// disagreeing answers happened the first time.
#[allow(dead_code)]
pub(crate) fn is_migrating(project_id: &str) -> bool {
crate::project_lock::is_held_by(project_id, crate::project_lock::ProjectOp::Migration)
}
+13 -59
View File
@@ -2184,8 +2184,8 @@ pub(crate) fn snapshot_scrub_script() -> String {
/// expands to itself and is skipped by the existence guard.
///
/// Passing the whole pattern rather than the two halves also keeps each entry
/// readable verbatim in the compaction `RUN` line — `disk.rs` asserts exactly
/// that, to catch a second forked copy of the list.
/// readable verbatim in the generated script, so a test can assert the script
/// names this list rather than a second forked copy of it.
///
/// ## The containment guarantee (C1)
///
@@ -2258,15 +2258,15 @@ pub(crate) fn snapshot_scrub_script() -> String {
/// which the agent's passwordless sudo can. It closes the part of the gap that
/// survives a container restart and needs no privileges at all.
///
/// ## Why every line ends in `;`
/// ## Why every line ends in `;`, and why there are no `#` comments
///
/// `disk.rs` folds this script onto a single `RUN` line for the compaction
/// build, joining non-blank lines with a space. That is only a join and not a
/// rewrite if each line already terminates its own statement the previous
/// version did not, and its folded form was a `"do" unexpected` syntax error,
/// so compaction had been running no scrub at all. It also means the script
/// carries **no `#` comments**: folded, one would swallow the rest of the
/// program. A test pins both the multi-line and the folded form.
/// A self-terminating statement per line, and no comments, is what makes the
/// script safe to join onto one line: any embedder that folds it with spaces
/// gets a join rather than a rewrite. That property was learnt the hard way —
/// an earlier version's folded form was a `"do" unexpected` syntax error, so
/// the scrub ran not at all — and it is kept even though the folding caller is
/// gone, because a script that survives being flattened is the cheap invariant
/// and re-learning it is not.
///
/// ## Why `root` exists
///
@@ -2903,8 +2903,8 @@ pub async fn scrub_secrets_from_snapshots() -> SnapshotScrubReport {
// Claim the project before touching its snapshot.
//
// This is the third writer of `triple-c-snapshot-{id}:latest`, after a
// recreate's commit and a compaction, and it has the same
// This is the second writer of `triple-c-snapshot-{id}:latest`, after a
// recreate's commit, and it has the same
// read-modify-write shape: create a scratch container *from* the
// snapshot, then commit back over the same tag. A `:latest` move
// landing in between is silently overwritten by an image derived from
@@ -4233,8 +4233,7 @@ mod tests {
assert!(script.contains("scrub_in '/var/log/apt/*' '-';"));
assert!(script.contains("scrub_in '/tmp/triple-c-drops/*' '14';"));
// The parent/glob split happens in the shell, so every entry stays
// readable verbatim — `disk.rs` folds this onto one `RUN` line and
// asserts each pattern appears there rather than a forked copy.
// readable verbatim in the script rather than as a forked copy.
for pattern in SNAPSHOT_SCRUB_PATHS {
assert!(script.contains(pattern), "{} is not named in the script", pattern);
}
@@ -4572,51 +4571,6 @@ mod tests {
assert!(SCRUB_TIMEOUT.as_secs() <= 300, "long enough that a user would force-quit first");
}
/// `disk.rs` folds this script onto one `RUN` line for the compaction
/// build by joining its non-blank lines with a space, so the script has to
/// be a sequence of self-terminating statements and carry no `#` comments.
/// The previous version was neither: its folded form was a `"do"
/// unexpected` syntax error, which means compaction had been scrubbing
/// nothing at all. The fold is reproduced here rather than imported
/// because it is private to the other module — a divergence would show up
/// as this test passing while the real Dockerfile broke, so it is pinned
/// against the same wording in `fold_shell_script`.
#[cfg(unix)]
#[test]
fn a_compaction_runs_this_module_s_scrub_script_byte_for_byte() {
// The compaction build used to fold the script onto one `RUN` line by
// joining its lines with a space, which turned `for p in …; do` into
// `do` in statement position and made every compaction fail with
// `syntax error: unexpected "do"`. That fold is gone — `disk.rs` now
// emits the JSON exec form, whose string escapes carry newlines — so
// the assertion worth pinning from this side is no longer "the folded
// one-liner still parses" but the stronger one: whatever encoding
// `disk.rs` chooses, the bytes that reach `sh` are *this* script.
//
// This is what stops the two files drifting. `container.rs` owns the
// containment rules in `snapshot_scrub_script`; a compaction that ran a
// mangled copy would be running a scrub with those rules altered, and
// the mangling would be silent.
let expected = snapshot_scrub_script();
// Build the real Dockerfile the compaction would, then pull the script
// back out of it — going through `compaction_dockerfile` rather than a
// helper means a change to how the RUN line is emitted is caught here.
let dockerfile = crate::docker::disk::compaction_dockerfile(
"triple-c-snapshot-00000000-0000-0000-0000-000000000000:latest",
&expected,
);
let run_line = dockerfile
.lines()
.find(|l| l.starts_with("RUN "))
.expect("the compaction Dockerfile should carry a RUN line");
let actual = crate::docker::disk::script_from_run_line(run_line)
.expect("the compaction RUN line should be the JSON exec form");
assert_eq!(
actual, expected,
"the compaction runs a different script than snapshot_scrub_script() produces"
);
}
#[test]
fn every_container_is_created_with_a_bounded_log() {
let cfg = capped_log_config();
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-5
View File
@@ -1,7 +1,6 @@
pub mod ca_certs;
pub mod client;
pub mod container;
pub mod disk;
pub mod image;
pub mod exec;
pub mod gateway;
@@ -25,10 +24,6 @@ pub use exec::*;
pub use legacy_cleanup::*;
#[allow(unused_imports)]
pub use migration::*;
// `disk` is also deliberately kept namespaced. Its `scan`, `reclaim` and
// `destroy` are meaningless as bare names, and `disk::destroy` reading as what
// it is at every call site is worth more than the brevity.
// Deliberately *not* re-exported flat: `ca_certs::resolve` and
// `ca_certs::CA_MOUNT_DIR` are far clearer than bare `resolve` in a module that
// already re-exports five other namespaces.
+2 -51
View File
@@ -215,11 +215,6 @@ pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.plugin(tauri_plugin_opener::init())
// Drag a file from the Files tab onto the host desktop. The gesture is
// pointer-driven for the same reason the tab drag is (see MainTabs):
// `dragDropEnabled` is on for the terminal's sake and blocks HTML5 drag
// inside the webview, so this plugin's native drag is the only route out.
.plugin(tauri_plugin_drag::init())
.manage(AppState {
projects_store,
settings_store,
@@ -245,8 +240,8 @@ pub fn run() {
// simply stopped launching a project kept its orphaned snapshot
// layers forever, and anything a crash left behind (a probe
// container pinning a base image, a rollback pin whose migration
// record is gone) had no path back at all. All three are
// read-mostly and finish in well under a second on an idle daemon,
// record is gone) had no path back at all. All of it is
// read-mostly and finishes in well under a second on an idle daemon,
// but they are detached anyway: housekeeping must never delay the
// window appearing, and a daemon that is not running yet is a
// logged warning rather than a failed start.
@@ -255,35 +250,13 @@ pub fn run() {
// an image open and the sweep will not force; pins are untagged
// second so the images they were holding are dangling by the time
// the sweep lists them; the sweep runs last and collects both.
//
// Drag-out staging is swept here too, and it is the *other* half of
// a lifecycle whose first half is the exit cleanup below: a run that
// crashed never got to clear its staged copies, and those are whole
// files, not metadata.
let drag_temp_dir = app.path().temp_dir().ok();
tauri::async_runtime::spawn(async move {
crate::docker::reap_probe_containers().await;
// Before the sweep, and for the same reason the pins are:
// `triple-c-snapshot-*:compacting` is a *tagged* image, so the
// sweep's `dangling=true` filter cannot see it, and the
// `triple-c-compact-*` container a crashed compaction leaves
// behind pins that image open. Untagging first is what turns
// both into something the sweep can collect on the same pass.
let stranded = crate::docker::disk::reap_stale_compaction_artifacts().await;
if stranded > 0 {
log::info!(
"Startup housekeeping dropped {} stranded compaction staging tag(s)",
stranded
);
}
let reaped = crate::docker::reap_stale_migration_pins().await;
if reaped > 0 {
log::info!("Startup housekeeping dropped {} stale rollback pin(s)", reaped);
}
crate::docker::sweep_orphaned_snapshots_logged("startup").await;
if let Some(temp_dir) = drag_temp_dir {
commands::file_commands::reap_drag_staging(temp_dir).await;
}
});
// Auto-start web terminal server if enabled in settings
@@ -410,10 +383,6 @@ pub fn run() {
let _ = window.emit("app-shutting-down", ());
let app_handle = window.app_handle().clone();
// Resolved here rather than inside the teardown, which is
// already under a wall-clock budget and should not spend any of
// it asking where the temp dir is.
let drag_temp_dir = app_handle.path().temp_dir().ok();
tauri::async_runtime::spawn(async move {
let teardown = async {
// First: let the auto-starts unwind. Anything they are
@@ -440,20 +409,10 @@ pub fn run() {
log::warn!("Failed to stop the model gateway on exit: {}", e);
}
};
// Whole files copied out of containers for drag-out.
// Left behind they are a disk leak with a gesture
// attached; startup housekeeping is the backstop for a
// run that never reaches this point.
let clear_drag_staging = async {
if let Some(temp_dir) = drag_temp_dir {
commands::file_commands::clear_drag_staging(temp_dir).await;
}
};
tokio::join!(
web_terminal,
stop_stt,
stop_gateway,
clear_drag_staging,
exec_manager.close_all_sessions(),
auth_bridge.stop_all(),
browser_view::manager().stop_all(),
@@ -477,12 +436,6 @@ pub fn run() {
commands::docker_commands::build_image,
commands::docker_commands::get_container_info,
commands::docker_commands::list_sibling_containers,
// Disk
commands::docker_commands::get_docker_disk_usage,
commands::docker_commands::list_reclaimable,
commands::docker_commands::reclaim,
commands::docker_commands::destroy_project_disk_object,
commands::docker_commands::sweep_orphaned_snapshots,
// Projects
commands::project_commands::list_projects,
commands::project_commands::add_project,
@@ -549,7 +502,6 @@ pub fn run() {
commands::file_commands::read_container_file,
commands::file_commands::rename_container_path,
commands::file_commands::create_container_directory,
commands::file_commands::stage_container_file_for_drag,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
@@ -764,7 +716,6 @@ mod tests {
"dialog:allow-open",
"dialog:allow-save",
"opener:allow-open-url",
"drag:allow-start-drag",
];
expected.sort();
assert_eq!(
+18 -2
View File
@@ -61,8 +61,7 @@
//! does do about it is bound the damage: [`any_held_excluding`] lets the daemon-wide
//! reapers skip work while this process is mid-operation, and the reapers
//! themselves gained age gates so a young container belonging to somebody else
//! is left alone (see `docker::disk::reap_stale_compaction_artifacts` and
//! `docker::migration::reap_probe_containers`).
//! is left alone (see `docker::migration::reap_probe_containers`).
//!
//! ## Refuse, do not queue
//!
@@ -85,6 +84,13 @@ pub enum ProjectOp {
/// `confirm_migration`.
Migration,
/// `disk::compact_snapshot` — the long one, and the reason this exists.
///
/// Not constructed on this branch: the Disk panel and its compaction were
/// held back for separate hardening and live on `hold/disk-and-dragout`.
/// The variant stays because this registry is the thing that made those
/// operations safe to re-land, and a re-land that had to re-derive the
/// claim classes would be re-deriving the bug.
#[allow(dead_code)]
Compaction,
/// Start / stop / recreate. Anything in `start_project_container`'s path.
Recreate,
@@ -95,6 +101,10 @@ pub enum ProjectOp {
/// `disk::clear_caches` — an exec into the live container. It does not
/// write `:latest`, but it must not run while the container is being
/// removed out from under it.
///
/// Not constructed on this branch, for the same reason as
/// [`ProjectOp::Compaction`].
#[allow(dead_code)]
CacheClear,
/// `container::scrub_secrets_from_snapshots` — the third writer of
/// `triple-c-snapshot-{id}:latest`, reached from `clear_claude_token`. It
@@ -220,6 +230,12 @@ pub fn is_held_by(project_id: &str, op: ProjectOp) -> bool {
/// only in-process question they can ask before force-removing one. The
/// exclusion is for the reaper that runs *inside* a compaction, which is
/// already holding a claim of its own and would otherwise see it and skip.
///
/// No production caller on this branch: the compaction reaper it was written
/// for went to `hold/disk-and-dragout` with the rest of the Disk panel. Kept
/// (and still tested) because it is the only bound this module offers on the
/// cross-process case documented above.
#[allow(dead_code)]
pub fn any_held_excluding(op: ProjectOp, exclude_project_id: &str) -> bool {
holders()
.lock()
@@ -267,22 +267,6 @@ fn ownerless_marker_path(project_id: &str, tag: &str) -> Result<PathBuf, String>
)))
}
/// When this pin was first observed ownerless, **without recording anything**.
///
/// For the survey paths, which describe the world and must not change it.
/// `None` means "no reaper has seen it yet", which is not the same as "seen
/// just now" and must not be treated as a start date.
pub fn peek_ownerless_since(
project_id: &str,
tag: &str,
) -> Option<chrono::DateTime<chrono::Utc>> {
let path = ownerless_marker_path(project_id, tag).ok()?;
let raw = fs::read_to_string(path).ok()?;
chrono::DateTime::parse_from_rfc3339(raw.trim())
.ok()
.map(|t| t.with_timezone(&chrono::Utc))
}
/// Read the first-observed instant for a pin, creating the marker if this is
/// the first sighting. Returns `None` when the clock has not started yet.
///
+9 -40
View File
@@ -13,43 +13,6 @@ fn corrupt_marker_for(file_path: &Path) -> PathBuf {
file_path.with_extension("json.corrupt")
}
/// `<data_dir>/triple-c/projects.json.corrupt`, whether or not it exists.
pub fn corrupt_marker_path() -> Option<PathBuf> {
dirs::data_dir().map(|d| corrupt_marker_for(&d.join("triple-c").join("projects.json")))
}
/// When this data directory last loaded a `projects.json` it could not parse,
/// as the RFC3339 instant recorded in the marker.
///
/// ## Why this outlives the load that wrote it
///
/// A corrupt load is *recoverable for the app* — the list starts empty and
/// everything keeps working — and that recovery is precisely what makes it
/// dangerous for anything that reasons about which projects exist. The
/// in-memory symptom does not survive: the first [`ProjectsStore::save`] after
/// the failure, which is as little as starting one project (`update_status`),
/// writes `[{that one project}]` over the file. From then on `projects.json`
/// parses, holds one id, and looks exactly like a user with one project — while
/// every *other* project's home and config volume is on the daemon claimed by
/// nobody.
///
/// The guard in `project_store_trust` keyed on "the list is empty and the file
/// exists", which that write silently ends. So the fact is recorded on disk
/// instead of inferred from the list's shape, and it is **sticky**: nothing in
/// this app clears it, because nothing in this app can reconstruct what the
/// unreadable file held. The refusal names the marker so a user who has
/// restored their list — or accepted the loss — can delete it deliberately.
pub fn corrupt_since() -> Option<String> {
let raw = fs::read_to_string(corrupt_marker_path()?).ok()?;
let trimmed = raw.trim();
if trimmed.is_empty() {
// The marker's presence is the signal; an empty one still means a
// corrupt load happened, it just cannot say when.
return Some("an unknown time".to_string());
}
Some(trimmed.lines().next().unwrap_or(trimmed).to_string())
}
/// Keep the bytes of an unparseable `projects.json`, and record that it
/// happened.
///
@@ -76,15 +39,21 @@ fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
}
}
// Sticky, and written even though nothing in the app reads it back on this
// branch: the Disk panel's `project_store_trust` was the reader and went to
// `hold/disk-and-dragout`. The marker stays because it is the only durable
// record that a project list was lost — the in-memory symptom does not
// survive the next save — and because re-deriving *when* it happened is
// impossible after the fact.
let marker = corrupt_marker_for(file_path);
if marker.exists() {
// Sticky: the *first* corruption is the one that dates the loss.
// The *first* corruption is the one that dates the loss.
return;
}
if let Err(e) = fs::write(&marker, now.to_rfc3339()) {
log::error!(
"Could not record the corrupt projects.json load at {}: {} — orphan detection will \
not know the project list is incomplete",
"Could not record the corrupt projects.json load at {}: {} — nothing will be able to \
tell later that the project list was incomplete",
marker.display(),
e
);
@@ -9,7 +9,6 @@ const uploadFileToContainer = vi.fn(async () => {});
const renameContainerPath = vi.fn(async () => "");
const createContainerDirectory = vi.fn(async () => "");
const readContainerFile = vi.fn();
const stageContainerFileForDrag = vi.fn(async () => "/tmp/triple-c-drag-out/s1/notes.txt");
vi.mock("../../../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -19,19 +18,6 @@ vi.mock("../../../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: (p: string, path: string, max?: number) => readContainerFile(p, path, max),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/**
* The OS-level drag. Nothing in jsdom can start one, so it is only observed
* including its `onEvent` channel, which is how the plugin reports that the
* gesture ended and therefore how the pane knows to start accepting drops
* again. `endDragOut` below drives it.
*/
type DragCallback = (payload: { result: "Dropped" | "Cancelled" }) => void;
const startDrag = vi.fn(async (_opts: unknown, _onEvent?: DragCallback) => {});
vi.mock("@crabnebula/tauri-plugin-drag", () => ({
startDrag: (opts: unknown, onEvent?: DragCallback) => startDrag(opts, onEvent),
}));
/** Transient failures land in `ToastHost`, not in an inline string. */
@@ -103,39 +89,6 @@ async function drop(paths: string[], position = { x: 100, y: 100 }) {
});
}
/**
* A pointer event carrying real coordinates.
*
* jsdom implements no `PointerEvent` and Testing Library's synthesized one has
* no coordinates which is the whole gesture here, since the drag only starts
* once the pointer has travelled past the threshold. `MouseEvent` has them, and
* React dispatches on the type name either way.
*/
function pointer(el: Element, type: string, clientX: number, clientY: number) {
fireEvent(
el,
new MouseEvent(type, { bubbles: true, cancelable: true, clientX, clientY, button: 0 }),
);
}
/** Press on a row and move far enough to become a drag, leaving the button down. */
function dragRow(el: Element) {
pointer(el, "pointerdown", 10, 10);
pointer(el, "pointermove", 60, 10);
}
/**
* Tell the pane the OS finished with the drag it started what the plugin's
* `onEvent` channel does for real. Until this arrives the pane deliberately
* ignores drops, because a drag-out released back over the app arrives as one.
*/
function endDragOut(result: "Dropped" | "Cancelled" = "Dropped") {
const onEvent = startDrag.mock.calls.at(-1)?.[1];
act(() => {
onEvent?.({ result });
});
}
/** Every row that is part of the grid's roving tabindex, in order. */
const gridRows = () => Array.from(document.querySelectorAll("tr[data-file-row]"));
/** The rows that are actually tab stops. There must never be more than one. */
@@ -153,8 +106,6 @@ function dropWithoutWaiting(paths: string[], position = { x: 100, y: 100 }) {
beforeEach(() => {
vi.clearAllMocks();
dragHandler = null;
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/notes.txt");
startDrag.mockResolvedValue(undefined);
listContainerFiles.mockResolvedValue([
entry("src", { is_directory: true, path: "/workspace/src" }),
entry("notes.txt"),
@@ -167,10 +118,6 @@ beforeEach(() => {
// Not implemented in jsdom; the image preview needs both halves.
URL.createObjectURL = vi.fn(() => "blob:mock-url");
URL.revokeObjectURL = vi.fn();
// Nor is canvas, which the drag preview draws on. Stubbed to the null jsdom
// would return anyway, minus the "not implemented" noise on every drag —
// `dragPreview.test.ts` covers what the fallback then produces.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
});
describe("FilesTab listing", () => {
@@ -503,191 +450,6 @@ describe("FilesTab save to host", () => {
});
});
describe("FilesTab drag-out", () => {
it("stages the file on the host and starts the native drag on that copy", async () => {
// The container path is not draggable — only the host copy is — so the
// thing handed to the OS must be what staging returned.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/notes.txt");
expect(startDrag).toHaveBeenCalledWith(
expect.objectContaining({ item: ["/tmp/triple-c-drag-out/s1/notes.txt"] }),
// The `onEvent` channel: without it, "the drag finished" is unobservable
// and a drag released back over the pane reads as a host drop.
expect.any(Function),
);
});
it("never drags a directory, which cannot be staged as one file", async () => {
await renderTab();
dragRow(screen.getByText("src").closest("tr")!);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
expect(startDrag).not.toHaveBeenCalled();
});
it("stays a click until the pointer has actually travelled", async () => {
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
pointer(row, "pointerdown", 10, 10);
pointer(row, "pointermove", 12, 11);
await act(async () => {
await Promise.resolve();
});
expect(stageContainerFileForDrag).not.toHaveBeenCalled();
});
it("says what went wrong instead of leaving a gesture that did nothing", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(toastText()).toContain("too large"));
expect(startDrag).not.toHaveBeenCalled();
});
it("points at the fallback when the platform refuses the drag itself", async () => {
startDrag.mockRejectedValue("drag image not found");
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(toastText()).toContain("Save to host"));
});
it("tells the user the copy is ready when the drag outlived the gesture", async () => {
// Staging is a whole-file copy, and the OS only adopts a drag while the
// button is down. Releasing mid-copy used to be — and must not be — a
// gesture that did nothing and explained nothing.
let release: (path: string) => void = () => {};
stageContainerFileForDrag.mockReturnValue(
new Promise<string>((resolve) => {
release = resolve;
}),
);
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
pointer(row, "pointerup", 60, 10);
await act(async () => {
release("/tmp/triple-c-drag-out/s1/notes.txt");
await Promise.resolve();
});
await waitFor(() => expect(screen.getByText(/is ready/).textContent).toContain("notes.txt"));
expect(startDrag).not.toHaveBeenCalled();
});
it("drags immediately on the retry, reusing the copy it already made", async () => {
// The instruction "drag it again" is only honest if the second attempt does
// not repeat the copy that made the first one too slow.
await renderTab();
const row = screen.getByText("notes.txt").closest("tr")!;
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(1));
pointer(row, "pointerup", 60, 10);
dragRow(row);
await waitFor(() => expect(startDrag).toHaveBeenCalledTimes(2));
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
});
it("leaves Save to host… working — drag-out is the enhancement, not the replacement", async () => {
await renderTab();
await act(async () => {
fireEvent.click(screen.getByLabelText("Save to host… — notes.txt"));
});
expect(downloadContainerFile).toHaveBeenCalledWith("p1", "/workspace/notes.txt", "/host/out");
expect(startDrag).not.toHaveBeenCalled();
});
it("still accepts a drop into the pane — the two directions coexist", async () => {
// The drag-out gesture is pointer-driven precisely so it does not need the
// HTML5 machinery that Tauri's native drop listener rules out.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
// The OS is done with it — anything arriving now is a genuine host drop.
endDragOut();
await drop(["/host/a.txt"]);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/a.txt", "/workspace");
});
});
describe("FilesTab drag-out released back over the app", () => {
it("does not re-import its own staged copy while the drag is in flight", async () => {
// The damaging case, and the reason this is HIGH: the staged copy is keyed
// off the *last listing*, so uploading it back is not even idempotent — a
// file an agent rewrote since then would be replaced by a stale snapshot.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("still refuses the staged copy after the drag has ended", async () => {
// Second line of defence, and the one that survives a platform whose
// `onEvent` never arrives: the path is known to be ours, exactly.
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
endDragOut("Cancelled");
await drop(["/tmp/triple-c-drag-out/s1/notes.txt"]);
expect(uploadFileToContainer).not.toHaveBeenCalled();
});
it("uploads the rest of a mixed drop, minus our own copy", async () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
endDragOut();
await drop(["/tmp/triple-c-drag-out/s1/notes.txt", "/host/real.png"]);
expect(uploadFileToContainer).toHaveBeenCalledTimes(1);
expect(uploadFileToContainer).toHaveBeenCalledWith("p1", "/host/real.png", "/workspace");
});
it("does not offer to accept files during its own export", async () => {
await renderTab();
dragRow(screen.getByText("notes.txt").closest("tr")!);
await waitFor(() => expect(startDrag).toHaveBeenCalled());
await act(async () => {
await dragHandler?.({ payload: { type: "enter", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.queryByText(/Drop files into/)).toBeNull();
// …and it comes back once the gesture is over.
endDragOut();
await act(async () => {
await dragHandler?.({ payload: { type: "over", position: { x: 100, y: 100 }, paths: [] } });
});
expect(screen.getByText(/Drop files into \/workspace/)).toBeTruthy();
});
});
describe("FilesTab drop hit test", () => {
it("uploads nothing when a dialog is covering the pane", async () => {
// The pane still has its rect underneath the viewer's `fixed inset-0`
+5 -193
View File
@@ -1,6 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { startDrag } from "@crabnebula/tauri-plugin-drag";
import type { FileEntry, Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import { classifyDrop, isDropTarget } from "../../../lib/dropTarget";
@@ -8,32 +7,12 @@ import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import FileViewerModal from "./FileViewerModal";
import OverwriteConfirmModal from "./OverwriteConfirmModal";
import { dragPreviewIcon } from "./dragPreview";
import { formatBytes } from "./format";
interface Props {
project: Project;
}
/**
* How far the pointer must travel before a press becomes a drag. Same few
* pixels of slop as the tab strip, so a click that trembles stays a click.
*/
const DRAG_THRESHOLD = 4;
/**
* Belt and braces for the in-flight drag-out flag.
*
* The flag is cleared by the drag plugin's own `onEvent` channel, which fires
* `Dropped` or `Cancelled` for every gesture the OS finishes. A platform that
* never fires it would leave the flag stuck and this pane deaf to drops, so it
* also times out. Long enough that a deliberate, slow drag across two monitors
* is not cut short; short enough that a wedged flag heals within one coffee
* sip. The staged-path filter below is the real protection either way this
* only decides how long the *hint* stays suppressed.
*/
const DRAG_OUT_WATCHDOG_MS = 30_000;
/** Key of the synthetic "go up one level" row. No listing ever contains `..`. */
const PARENT_ROW = "..";
@@ -73,8 +52,6 @@ export default function FilesTab({ project }: Props) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
} = useFileManager(project.id);
@@ -90,8 +67,6 @@ export default function FilesTab({ project }: Props) {
const [viewing, setViewing] = useState<FileEntry | null>(null);
/** A host drag is currently over this pane. */
const [dragOver, setDragOver] = useState(false);
/** Name of a file staged for drag-out whose gesture did not reach the OS. */
const [dragNotice, setDragNotice] = useState<string | null>(null);
/** The row that owns the grid's single tab stop. */
const [activeRow, setActiveRow] = useState<string | null>(null);
@@ -108,7 +83,6 @@ export default function FilesTab({ project }: Props) {
useEffect(() => {
setSelected(null);
setRenaming(null);
setDragNotice(null);
}, [currentPath]);
useEffect(() => {
@@ -260,153 +234,6 @@ export default function FilesTab({ project }: Props) {
goUp();
}, [currentPath, goUp]);
// Container → host drag-out.
//
// The mirror image of the drop path below, and it has the same constraint
// pushing it: `dragDropEnabled` blocks HTML5 drag inside the webview, so
// `draggable` + `DataTransfer` is not available and the gesture is driven
// from pointer events into the native drag plugin — exactly the shape the tab
// strip uses, and for the same reason.
//
// What makes it more than a pointer gesture is that the file being dragged
// does not exist on the host at all: it lives in the container, and the OS
// can only drag a real host path. So every drag-out is a copy first (see
// `stageForDrag`) and a drag second, which is why the gesture has an async
// gap in the middle of something that feels instantaneous.
const dragOut = useRef<{
path: string;
x: number;
y: number;
down: boolean;
started: boolean;
} | null>(null);
/**
* A drag-out the OS has taken and not yet finished.
*
* Without this, releasing a drag-out back over the Files pane fed the app its
* own export as if it were a host drop: the staged copy was uploaded straight
* back over the container file it came from. Not even idempotent the staged
* copy is cached against the *last listing*, so a file rewritten in the
* container since then was replaced by a minutes-old snapshot. The `enter`
* and `over` branches consult it too, so the pane does not offer to accept
* files during its own export.
*
* Cleared from the drag plugin's `onEvent` channel, which reports `Dropped`
* or `Cancelled` when the gesture ends the installed
* `@crabnebula/tauri-plugin-drag` (2.1.0) takes it as `startDrag`'s second
* argument. `startDrag`'s own promise is *not* the signal: on some platforms
* it resolves as soon as the OS adopts the drag, i.e. while it is still in
* flight. See `DRAG_OUT_WATCHDOG_MS` for what happens if `onEvent` never
* arrives.
*/
const dragOutInFlight = useRef(false);
const dragOutWatchdog = useRef<ReturnType<typeof setTimeout> | null>(null);
const endDragOut = useCallback(() => {
dragOutInFlight.current = false;
if (dragOutWatchdog.current !== null) {
clearTimeout(dragOutWatchdog.current);
dragOutWatchdog.current = null;
}
}, []);
useEffect(() => endDragOut, [endDragOut]);
// Pointer-up almost never lands on the row it started on — the pointer has
// moved off it by definition, and once the OS takes the drag the webview stops
// seeing the pointer at all, which is what makes a lost focus the only
// "the button came up" signal left.
useEffect(() => {
const release = () => {
if (dragOut.current) dragOut.current.down = false;
};
window.addEventListener("pointerup", release);
window.addEventListener("pointercancel", release);
window.addEventListener("blur", release);
return () => {
window.removeEventListener("pointerup", release);
window.removeEventListener("pointercancel", release);
window.removeEventListener("blur", release);
};
}, []);
const beginDragOut = useCallback(
async (entry: FileEntry) => {
setDragNotice(null);
const staged = await stageForDrag(entry);
// `stageForDrag` has already reported the reason through the toast host.
if (!staged) return;
// The OS only adopts a drag while the button is still down, and the copy
// that just ran can easily outlast a flick of the wrist. Say so rather
// than leaving a gesture that did nothing and explained nothing — and it
// is a real instruction, not an apology: the copy is kept, so the second
// attempt starts immediately.
if (dragOut.current?.path !== entry.path || !dragOut.current.down) {
setDragNotice(entry.name);
return;
}
dragOutInFlight.current = true;
dragOutWatchdog.current = setTimeout(endDragOut, DRAG_OUT_WATCHDOG_MS);
try {
await startDrag({ item: [staged.hostPath], icon: dragPreviewIcon(entry.name) }, () =>
endDragOut(),
);
} catch (e) {
endDragOut();
// Drag-out is the enhancement; "Save to host…" is the path that always
// works, so a platform that refuses the drag says where to go instead.
useAppState.getState().pushToast({
kind: "error",
message: 'Could not start the drag — use "Save to host…" instead.',
detail: String(e),
});
}
},
[stageForDrag, endDragOut],
);
/**
* Pointer wiring for one row. Directories get none of it: staging copies a
* single regular file, and a folder would only ever produce an error.
*/
const dragOutProps = (entry: FileEntry) => {
if (entry.is_directory) return {};
return {
onPointerDown: (e: React.PointerEvent<HTMLTableRowElement>) => {
if (e.button !== 0 || renaming === entry.name) return;
// The row's own controls, and the rename input, where a drag is a text
// selection.
if ((e.target as HTMLElement).closest("button, input")) return;
dragOut.current = {
path: entry.path,
x: e.clientX,
y: e.clientY,
down: true,
started: false,
};
// Deliberately no `setPointerCapture` — unlike the tab strip, which
// draws its own ghost. Here the OS has to take the pointer over, and a
// capture held in the webview is exactly what stops it.
},
onPointerMove: (e: React.PointerEvent<HTMLTableRowElement>) => {
const gesture = dragOut.current;
if (!gesture || gesture.started || !gesture.down) return;
if (gesture.path !== entry.path) return;
if (
Math.abs(e.clientX - gesture.x) < DRAG_THRESHOLD &&
Math.abs(e.clientY - gesture.y) < DRAG_THRESHOLD
) {
return;
}
gesture.started = true;
void beginDragOut(entry);
},
};
};
// Host → container drag and drop.
//
// This is Tauri's *native* drag-drop event, not HTML5 `ondrop`, for the same
@@ -418,10 +245,6 @@ export default function FilesTab({ project }: Props) {
// point? (Not "is that element mine": chrome painted over a pane — a toast,
// a button — is not something that swallows a drop, and treating it as such
// made permanent dead zones.)
//
// Two further filters sit in front of it, both about our own drag-out:
// `dragOutInFlight`, and the staged-path check, which is exact because
// `useFileManager` remembers every host path it staged.
useEffect(() => {
if (!running) return;
let unlisten: (() => void) | undefined;
@@ -435,14 +258,11 @@ export default function FilesTab({ project }: Props) {
return;
}
if (payload.type === "enter" || payload.type === "over") {
setDragOver(
!dragOutInFlight.current && isDropTarget(paneRef.current, payload.position),
);
setDragOver(isDropTarget(paneRef.current, payload.position));
return;
}
if (payload.type !== "drop") return;
setDragOver(false);
if (dragOutInFlight.current) return;
const verdict = classifyDrop(paneRef.current, payload.position);
// Aimed at this pane and refused anyway: say so. Nothing else would —
// the file just never appears in the listing.
@@ -460,10 +280,7 @@ export default function FilesTab({ project }: Props) {
return;
}
if (verdict !== "accept") return;
// Anything we staged for a drag-out is our own copy of a file that is
// already in the container; re-importing it would overwrite the
// original with a snapshot.
const paths = (payload.paths ?? []).filter((path) => !isStagedHostPath(path));
const paths = payload.paths ?? [];
if (paths.length === 0) return;
await uploadPaths(paths);
});
@@ -475,7 +292,7 @@ export default function FilesTab({ project }: Props) {
cancelled = true;
unlisten?.();
};
}, [running, uploadPaths, isStagedHostPath]);
}, [running, uploadPaths]);
const breadcrumbs =
currentPath === "/"
@@ -518,11 +335,7 @@ export default function FilesTab({ project }: Props) {
* frequently not announced at all, which is how "uploading 3 items…" and
* every completion notice used to go by in silence.
*/
const liveText = busy
? busy
: dragNotice
? `"${dragNotice}" is ready — drag it again to drop it on the desktop.`
: (completed ?? "");
const liveText = busy ? busy : (completed ?? "");
return (
<div ref={paneRef} className="relative flex flex-col h-full min-h-0">
@@ -568,7 +381,7 @@ export default function FilesTab({ project }: Props) {
{/* The one failure that stays inline: it explains why the grid below is
empty, it is in context, and there are no rows for it to scroll
behind. Every *transient* failure upload, rename, mkdir,
save-to-host, staging goes to `ToastHost` instead, which is above
save-to-host goes to `ToastHost` instead, which is above
the file viewer's overlay and does not scroll away. */}
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
@@ -665,7 +478,6 @@ export default function FilesTab({ project }: Props) {
setActiveRow(entry.name);
}}
onDoubleClick={() => openEntry(entry)}
{...dragOutProps(entry)}
onKeyDown={(e) => {
if (isRenaming) return;
if (e.key === "Enter") {
@@ -79,7 +79,7 @@ export default function AuthBridgeRow({ project }: { project: Project }) {
/**
* Which write to `status` is the newest the same "is this still mine?"
* guard `useDiskUsage` and `useContainerMigration` use around their async
* guard `useContainerMigration` uses around its async
* writes, and needed here for a reason that is easy to miss.
*
* There are two sources of truth for this row and only one of them is
@@ -1,79 +0,0 @@
import { describe, it, expect, vi, afterEach } from "vitest";
import { dragPreviewIcon } from "./dragPreview";
afterEach(() => {
vi.restoreAllMocks();
});
describe("dragPreviewIcon", () => {
it("falls back to a PNG data URL when there is no 2D context", () => {
// jsdom has no canvas, and a webview can refuse one. `startDrag` requires
// an image and the Rust side accepts nothing but a PNG data URL, so a
// fallback that is not one takes the whole drag down with it.
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(null);
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("falls back rather than throwing when the canvas throws", () => {
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockImplementation(() => {
throw new Error("no canvas here");
});
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,/);
});
it("refuses a canvas that encoded nothing", () => {
// jsdom's `toDataURL` answers `data:,` — which the Rust side rejects
// outright, so returning it would be worse than not drawing at all.
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue("data:,");
expect(dragPreviewIcon("notes.txt")).toMatch(/^data:image\/png;base64,[A-Za-z0-9+/=]+$/);
});
it("uses what the canvas drew when there is one", () => {
const ctx = {
scale: vi.fn(),
measureText: () => ({ width: 60 }),
beginPath: vi.fn(),
roundRect: vi.fn(),
fill: vi.fn(),
stroke: vi.fn(),
fillRect: vi.fn(),
strokeRect: vi.fn(),
fillText: vi.fn(),
font: "",
fillStyle: "",
strokeStyle: "",
lineWidth: 0,
textBaseline: "",
} as unknown as CanvasRenderingContext2D;
const drawn = "data:image/png;base64,AAAA";
vi.spyOn(HTMLCanvasElement.prototype, "getContext").mockReturnValue(ctx);
vi.spyOn(HTMLCanvasElement.prototype, "toDataURL").mockReturnValue(drawn);
expect(dragPreviewIcon("notes.txt")).toBe(drawn);
// A long name is elided rather than drawn off the edge of the preview.
expect(dragPreviewIcon("a-really-quite-long-file-name-indeed.txt")).toBe(drawn);
expect(ctx.fillText).toHaveBeenLastCalledWith(
expect.stringContaining("…"),
expect.any(Number),
expect.any(Number),
);
});
});
@@ -1,85 +0,0 @@
/**
* The image the OS shows under the cursor during a drag-out.
*
* `startDrag` requires one the plugin's `image` argument is not optional, and
* it only accepts a `data:image/png;base64,` URL so this is drawn rather than
* shipped as an asset. Drawing it is also what keeps the colours honest: the
* palette lives in CSS custom properties, and reading them off the document is
* the only way a raw-pixel preview can still come from the design tokens rather
* than from hard-coded hexes.
*/
/**
* A 1x1 transparent PNG, used when no 2D canvas is available jsdom has none,
* and a webview can refuse a context under memory pressure. `startDrag` needs
* *an* image, and a drag with an invisible preview is much better than no drag.
*/
const TRANSPARENT_PNG =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVR4nGNgAAIAAAUAAXpeqz8AAAAASUVORK5CYII=";
/** Longest filename drawn in full; past this the middle is elided. */
const MAX_LABEL = 28;
function cssVar(name: string, fallback: string): string {
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim();
return value || fallback;
}
/** Keep both ends of a long name — the extension is the informative half. */
function elide(label: string): string {
if (label.length <= MAX_LABEL) return label;
const head = label.slice(0, MAX_LABEL - 12);
const tail = label.slice(-9);
return `${head}${tail}`;
}
export function dragPreviewIcon(label: string): string {
try {
const text = elide(label);
// Cap the scale: the OS draws this at logical size, so a 3x buffer is only
// bytes over IPC.
const scale = Math.min(window.devicePixelRatio || 1, 2);
const height = 24;
const padding = 8;
const canvas = document.createElement("canvas");
// Measuring needs a context, and sizing the canvas resets it — so measure
// on a throwaway pass, then size, then draw.
const probe = canvas.getContext("2d");
if (!probe) return TRANSPARENT_PNG;
const font = "12px ui-monospace, SFMono-Regular, Menlo, monospace";
probe.font = font;
const width = Math.ceil(probe.measureText(text).width) + padding * 2;
canvas.width = Math.round(width * scale);
canvas.height = Math.round(height * scale);
const ctx = canvas.getContext("2d");
if (!ctx) return TRANSPARENT_PNG;
ctx.scale(scale, scale);
ctx.fillStyle = cssVar("--bg-tertiary", "#2a2a2a");
ctx.strokeStyle = cssVar("--accent", "#6aa8ff");
ctx.lineWidth = 1;
if (typeof ctx.roundRect === "function") {
ctx.beginPath();
ctx.roundRect(0.5, 0.5, width - 1, height - 1, 4);
ctx.fill();
ctx.stroke();
} else {
ctx.fillRect(0.5, 0.5, width - 1, height - 1);
ctx.strokeRect(0.5, 0.5, width - 1, height - 1);
}
ctx.font = font;
ctx.fillStyle = cssVar("--text-primary", "#e6e6e6");
ctx.textBaseline = "middle";
ctx.fillText(text, padding, height / 2);
const url = canvas.toDataURL("image/png");
// jsdom (and a canvas that failed to encode) answers `data:,` — which the
// Rust side rejects outright, taking the whole drag with it.
return url.startsWith("data:image/png;base64,") ? url : TRANSPARENT_PNG;
} catch {
return TRANSPARENT_PNG;
}
}
@@ -1,250 +0,0 @@
import OverflowMenu from "../ui/OverflowMenu";
import Tooltip from "../ui/Tooltip";
import StatusIndicator from "../ui/StatusIndicator";
import { formatBytes, formatBytesDelta } from "../../lib/formatBytes";
import type { DestructiveItem, ProjectDiskRow } from "../../lib/types";
interface Props {
rows: ProjectDiskRow[];
/** Per-project destructive objects, keyed off the same rows. */
destructive: DestructiveItem[];
onDestroy: (item: DestructiveItem) => void;
}
const LAYERS_HELP =
"Commit layers stacked above the base image — one for every time this project's container was recreated. Nothing merges them, so each one is paid for permanently until the snapshot is compacted.";
const NEXT_COMMIT_HELP =
"The container's writable layer. This is exactly what the next recreation will stack onto the snapshot, and it never comes back after that.";
/** Why a layer count reads "unknown" rather than as a number. */
const layersUnknownHelp = (layers: number) =>
`${layers} layers in total, but this project predates the base-image label, so there is no way to tell which of them are commits. Migrating it to the current base restores the count.`;
/** `—` for a column with nothing in it, so an empty cell never reads as zero. */
function cell(bytes: number, present: boolean) {
return present ? formatBytes(bytes) : "—";
}
const SNAPSHOT_HELP =
"This project's share of its snapshot image — the bytes no other image carries. The base image is shared by every project, so charging it to each row would show the same 4.7 GB eight times over. It is the figure the Total is built from.";
/** Why a snapshot figure is the whole image rather than a share of one. */
const SPLIT_UNKNOWN_HELP =
"Nothing measurably shares layers with this snapshot, and the base image it descends from is no longer on the daemon, so there is no split to show and none is guessed. This is the whole image, which is what it actually costs — a compacted snapshot is exactly this shape.";
/**
* How `snapshot_attributed_bytes` was arrived at, in the row's own terms.
*
* Rust computes the number in one function so the column and the Total cannot
* be derived from two different rules again but the branches do not mean the
* same thing to a reader, so the sub-line has to say which one this row is.
* `snapshot_above_base_bytes` is `null` in exactly the branch where the figure
* *is* the whole image, which is what makes it the test.
*/
function attributionNote(row: ProjectDiskRow): { note: string; help: string | null } {
if (row.snapshot_above_base_bytes !== null) {
return { note: `${formatBytes(row.snapshot_bytes)} with base`, help: null };
}
return { note: "whole image — base unknown", help: SPLIT_UNKNOWN_HELP };
}
/**
* The per-project table the mental model users actually have of this app.
*
* ## Why "Layers" is a column and not a detail
*
* A total tells a user their disk is full. The layer count tells them *why*:
* every container recreation runs `docker commit`, a commit stacks a layer and
* never rewrites one, and 24 different settings changes trigger a recreation.
* A project sitting at 14 layers has paid for fourteen full copies of whatever
* changed, and no total on its own ever says that.
*
* "Next commit adds" is the same fact from the other end: it is the container's
* writable layer, i.e. exactly what the *next* recreation will bake in
* permanently. Seeing 868 MB there is what makes Compact worth doing before the
* next settings change rather than after it.
*/
export default function DiskProjectTable({ rows, destructive, onDestroy }: Props) {
if (rows.length === 0) {
return (
<p className="text-xs text-[var(--text-secondary)]">
No projects to account for.
</p>
);
}
return (
// Wide content scrolls inside its own container; the panel itself must
// never scroll sideways.
<div className="overflow-x-auto">
<table className="w-full text-[13px] border-collapse">
<caption className="sr-only">
Disk used by each project, largest first
</caption>
<thead>
<tr className="text-left text-xs text-[var(--text-secondary)]">
<th scope="col" className="font-medium py-1.5 pr-3">
Project
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Snapshot
<Tooltip text={SNAPSHOT_HELP} />
<span className="sr-only"> {SNAPSHOT_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Layers
{/* `Tooltip` renders a portalled div with no `role` and no
`aria-describedby`, so its text reaches no assistive tech and
the trigger announces as "Help". These two headers are
meaningless without their explanation, so it is also emitted
as screen-reader-only text. */}
<Tooltip text={LAYERS_HELP} />
<span className="sr-only"> {LAYERS_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right whitespace-nowrap">
Next commit adds
<Tooltip text={NEXT_COMMIT_HELP} />
<span className="sr-only"> {NEXT_COMMIT_HELP}</span>
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Home vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Config vol
</th>
<th scope="col" className="font-medium py-1.5 px-3 text-right">
Total
</th>
<th scope="col" className="font-medium py-1.5 pl-3">
<span className="sr-only">Actions</span>
</th>
</tr>
</thead>
<tbody>
{rows.map((row) => {
const mine = destructive.filter((d) => d.project_id === row.project_id);
return (
<tr
key={row.project_id}
className="border-t border-[var(--border-color)] align-top"
data-testid={`disk-row-${row.project_id}`}
>
<th scope="row" className="font-normal py-1.5 pr-3 text-[var(--text-primary)]">
<div className="flex items-center gap-1.5">
<span className="truncate max-w-[10rem]">{row.project_name}</span>
{row.migrating && (
<StatusIndicator
tone="busy"
label="Migrating"
className="text-[11px]"
/>
)}
</div>
<span className="block text-[11px] text-[var(--text-secondary)] font-mono truncate max-w-[12rem]">
{row.project_id}
</span>
</th>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{/* `snapshot_attributed_bytes`, and nothing else. This column
used to render `snapshot_above_base_bytes` and fall back
to `` while the Total was computed from
`snapshot_bytes - snapshot_shared_bytes` regardless so a
row could show `` here and still carry a whole 4.7 GB
base image in its Total, once per project. One field, one
rule, computed once in Rust: the parts add up. */}
{row.snapshot_exists ? formatBytes(row.snapshot_attributed_bytes) : "—"}
{row.snapshot_exists && (() => {
const { note, help } = attributionNote(row);
return help === null ? (
<span className="block text-[11px] text-[var(--text-secondary)]">
{note}
</span>
) : (
// Same treatment as the Layers column: `Tooltip` portals
// a plain div with no `role` and no `aria-describedby`,
// so the explanation is also emitted as screen-reader
// text rather than living in the tooltip alone.
<span className="block text-[11px] text-[var(--text-secondary)]">
<Tooltip text={help}>
<span>{note}</span>
</Tooltip>
<span className="sr-only"> &mdash; {help}</span>
</span>
);
})()}
</td>
<td className="py-1.5 px-3 text-right tabular-nums">
{!row.snapshot_exists ? (
"—"
) : !row.base_lineage_known ? (
// The base this descends from is unknown, so the count
// includes the base's own layers and does not mean
// "recreations". Saying so beats printing a wrong number.
//
// The explanation is the only thing standing between
// "unknown" and reading as a bug, so it cannot live in the
// tooltip alone: `Tooltip` portals a plain div with no
// `role` and no `aria-describedby`, and wrapped around
// children it has no focus handlers either — so on hover-
// less input it is unreachable and to a screen reader it
// does not exist. Same treatment as the column headers
// above: tooltip for the mouse, `sr-only` text for
// everything else.
<>
<Tooltip text={layersUnknownHelp(row.snapshot_commit_layers)}>
<span className="text-[var(--text-secondary)]">unknown</span>
</Tooltip>
<span className="sr-only">
{" "}
&mdash; {layersUnknownHelp(row.snapshot_commit_layers)}
</span>
</>
) : (
<span className="text-[var(--text-primary)]">
{row.snapshot_commit_layers}
{/* Never colour alone: a count worth acting on says so in
a word, which is also what a screen reader gets. */}
{row.snapshot_commit_layers > 5 && (
<span className="ml-1 text-[11px] text-[var(--warning)]">
stacked
</span>
)}
</span>
)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{row.container_exists
? formatBytesDelta(row.container_writable_bytes)
: "—"}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.home_volume_bytes, row.home_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap">
{cell(row.config_volume_bytes, row.config_volume_present)}
</td>
<td className="py-1.5 px-3 text-right tabular-nums whitespace-nowrap text-[var(--text-primary)] font-medium">
{formatBytes(row.total_bytes)}
</td>
<td className="py-1.5 pl-3">
{mine.length > 0 && (
<OverflowMenu
label={`Delete ${row.project_name} data`}
items={mine.map((item) => ({
label: `Delete ${item.label.toLowerCase()} (${formatBytes(item.bytes)})…`,
onSelect: () => onDestroy(item),
danger: true,
disabled: item.blocked !== null,
}))}
/>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -1,855 +0,0 @@
import { useEffect, useState } from "react";
import Button from "../ui/Button";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
import Modal from "../ui/Modal";
import TypedConfirmModal from "../ui/TypedConfirmModal";
import DiskProjectTable from "./DiskProjectTable";
import { useDiskUsage } from "../../hooks/useDiskUsage";
import { formatBytes, formatBytesCeiling } from "../../lib/formatBytes";
import type { DestructiveItem, ReclaimItem, ReclaimTarget } from "../../lib/types";
/** A stable key for a target, so ticks survive a re-plan. */
function targetKey(target: ReclaimTarget): string {
return JSON.stringify(target);
}
/** The same, for a destructive object — never ticked, but still listed. */
function destructiveKey(item: DestructiveItem): string {
return JSON.stringify(item.target);
}
/**
* An orphaned volume is confirmed against **its own name**, not a project's.
*
* There is no project to name: the whole definition of the variant is that its
* id matches nothing in the store, and `disk.rs`'s `destroy` takes the orphan
* arm before it ever looks a project up. `DestructiveItem.project_name` carries
* the volume name for exactly these items, which is what the gate compares.
*/
function isOrphanVolume(item: DestructiveItem): boolean {
return item.target.kind === "orphan_volume";
}
/**
* Where the disk went, and how to get it back.
*
* ## Why the scan is a button
*
* `getDockerDiskUsage` is `GET /system/df`, which walks every image, container
* and volume on the daemon computing shared-layer sizes seconds on a 100 GB
* store, and the only call that produces those numbers at all. So nothing here
* runs on open, on a timer, or on a re-render.
*
* ## Why the buckets are separated the way they are
*
* Safe work (dangling images, ownerless pins, build cache) gets one list of
* ticks and one button, because none of it can lose anything a user has.
* Semi-safe work (compaction, cache clearing) is a rewrite or a re-download and
* is confirmed one at a time. Destructive work a live project's volumes, its
* snapshot, a live rollback pin, **and an orphaned volume** is not in either
* list: it is reached one object at a time, behind a typed confirmation, and
* the backend refuses it in bulk by taking a different type entirely.
*
* ## Why orphaned volumes are down there and not in the tick list
*
* They used to be a `ReclaimTarget` at `Safety::Safe`: a tick and the group
* Reclaim button, no confirmation. The object behind that tick is a
* `triple-c-claude-config-*` volume holding a Claude OAuth credential, every
* plugin and skill installed into that project, and every conversation
* transcript it ever had and the *same volume* for a project still in the
* store required typing the project's name. The only difference between the two
* is a lookup against `projects.json`, which this app has been wrong about
* before: a second instance's project is absent from an in-memory list, a
* corrupt store empties it, a restored data directory empties it too. It once
* flagged two live projects as orphaned.
*
* So "no matching project" means one thing only the id is not in the project
* list. It is never inferred from a project being stopped, having no container
* or having no image; an idle live project looks identical from the daemon's
* side. Each volume is deleted on its own, against its own name typed out.
*/
export default function DiskSettings() {
const {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
} = useDiskUsage();
const [ticked, setTicked] = useState<Set<string>>(new Set());
const [confirming, setConfirming] = useState<ReclaimItem | null>(null);
const [destroying, setDestroying] = useState<DestructiveItem | null>(null);
// A dialog whose action failed stays open and says so *inside itself*. The
// hook's `error` is rendered at the top of a panel that is metres of scroll
// long, so a user who reached a project row through the table would have
// watched the dialog vanish and seen nothing take its place. This flag is
// what distinguishes "this dialog's action just failed" from a stale scan
// error that happened to still be sitting in `error` when it opened.
const [actionFailed, setActionFailed] = useState(false);
// The plan is dropped after any reclaim, so a tick can never outlive the row
// it was made against and be re-fired at an object that is already gone.
useEffect(() => {
if (!plan) setTicked(new Set());
}, [plan]);
// Split before anything renders. The per-project table keys off
// `project_id`, and an orphan's id matches no row by definition — so without
// this split those items are simply invisible, which is how a variant that
// moved from the tick list to the destructive list can vanish from the UI
// entirely rather than reappear behind a confirmation.
const orphanVolumes = plan?.destructive.filter(isOrphanVolume) ?? [];
// A destructive item is rendered inside its project's row, so one whose
// project id matches no row would be measured and shown nowhere. That is not
// hypothetical: `survey_rollback_pins` walks *images*, not projects, and
// deliberately tolerates an absent project by falling back to the raw id as
// the display name — so a pin left behind by a deleted project is exactly
// this case, and it is the multi-GB kind. Anything unmatched gets its own
// section rather than being silently dropped.
const rowIds = new Set((report?.projects ?? []).map((r) => r.project_id));
const projectDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && rowIds.has(d.project_id)) ?? [];
const unmatchedDestructive =
plan?.destructive.filter((d) => !isOrphanVolume(d) && !rowIds.has(d.project_id)) ?? [];
const safeItems = plan?.items.filter((i) => i.safety === "safe") ?? [];
const semiItems = plan?.items.filter((i) => i.safety === "semi_safe") ?? [];
const selected = safeItems.filter(
(i) => i.blocked === null && ticked.has(targetKey(i.target)),
);
const selectedBytes = selected.reduce((sum, i) => sum + i.bytes, 0);
// Opening or closing either dialog clears the in-dialog failure with it, so
// one never starts out showing the previous attempt's error.
const openConfirming = (item: ReclaimItem) => {
setConfirming(item);
setActionFailed(false);
};
const openDestroying = (item: DestructiveItem) => {
setDestroying(item);
setActionFailed(false);
};
const closeConfirming = () => {
setConfirming(null);
setActionFailed(false);
};
const closeDestroying = () => {
setDestroying(null);
setActionFailed(false);
};
const toggle = (item: ReclaimItem) => {
setTicked((prev) => {
const next = new Set(prev);
const key = targetKey(item.target);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
};
// Counted from the per-result list rather than from a flag: a reclaim of
// five targets can come back with two failures and a real byte total.
const failedCount = outcome?.results.filter((r) => !r.ok).length ?? 0;
// What the backend said about the parts it refused, rendered **verbatim**.
// A refusal arrives inside `Ok` — the command succeeded at declining — so it
// never reaches `error`, and the dialog that asked for the work has nothing
// else to show. Not a sentence of our own: the backend is the only side that
// knows which blocker is actually holding the project, and one written here
// would go stale the day that answer improves.
const refusalText =
outcome?.results
.filter((r) => !r.ok)
.map((r) => r.message)
.join(" ") ?? "";
const tone: StatusTone = scanning ? "unknown" : report ? "ok" : "off";
const statusLabel = scanning
? "Scanning"
: report
? `Scanned ${new Date(report.scanned_at).toLocaleTimeString()}`
: "Not scanned";
return (
<div className="space-y-4 text-[13px]">
{/* --- Why this section exists ------------------------------------- */}
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Every time a container is recreated, Triple-C commits it and a commit{" "}
<strong className="text-[var(--text-primary)]">stacks a new layer</strong> rather
than rewriting the old one. Deleting a file afterwards writes a whiteout; the
bytes underneath stay forever. Twenty-four different settings changes trigger a
recreation, so a project can quietly accumulate a dozen multi-gigabyte layers it
no longer uses any of.
</p>
{/* --- Scan --------------------------------------------------------- */}
<div className="flex items-center gap-3 flex-wrap">
{/* Disabled while a mutation runs, not only while scanning: a scan
started on top of a reclaim measures a daemon that is being changed
underneath it, and the hook can only discard such a result better
not to spend the seconds. */}
<Button variant="primary" size="md" onClick={scan} disabled={scanning || working}>
{scanning ? "Scanning…" : report ? "Scan again" : "Scan"}
</Button>
{/* The status flips between "Scanning", "Scanned HH:MM:SS" and "Not
scanned" with no other signal. The live region is mounted here
unconditionally wrapping it around the indicator only once there
is something to say would make the region *appear* already
populated, which is the one shape assistive tech does not announce. */}
<span role="status" aria-live="polite">
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
</span>
<span className="text-xs text-[var(--text-secondary)]">
Reads the whole Docker store; takes a few seconds on a large one.
</span>
</div>
{error && (
<p className="text-xs text-[var(--error)]" role="alert">
{error}
</p>
)}
{!report && !scanning && (
<p className="text-xs text-[var(--text-secondary)]">
Nothing has been measured yet. Scanning is the only thing here that costs
anything, so it is never done for you.
</p>
)}
{report && (
<>
{/* --- Windows / WSL2, mandatory when it applies ----------------- */}
{report.host.vhdx_applies && (
<section
className="border border-[var(--warning)]/40 bg-[var(--warning-muted)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-2"
data-testid="disk-vhdx-note"
>
{/* `StatusIndicator` has no warning tone `error` would put a
red glyph in a warning-toned panel. This is advisory, so it
carries its own glyph beside the words rather than relying on
the panel's colour. */}
<p className="text-xs font-medium text-[var(--text-primary)]">
<span aria-hidden="true">&#9650;</span> Warning: reclaiming here will not
shrink your C: drive
</p>
<p className="text-xs text-[var(--text-primary)] leading-relaxed">
{report.host.vhdx_note}
</p>
<p className="text-xs text-[var(--text-secondary)]">
To actually give the space back to C:, run these in PowerShell as
administrator after reclaiming:
</p>
<pre className="text-[11px] font-mono bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] px-2.5 py-2 overflow-x-auto select-text">
{report.host.vhdx_fix.join("\n")}
</pre>
<p className="text-xs text-[var(--text-secondary)]">
Or, without Hyper-V: {report.host.vhdx_fix_gui}.
</p>
</section>
)}
{/* --- Per-project table ---------------------------------------- */}
<section className="space-y-2">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
By project
</h3>
<DiskProjectTable
rows={report.projects}
destructive={projectDestructive}
onDestroy={openDestroying}
/>
</section>
{/* --- Globals --------------------------------------------------- */}
<section className="space-y-2" data-testid="disk-globals">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Shared and left over
</h3>
<dl className="grid grid-cols-[1fr_auto] gap-x-4 gap-y-1 text-xs">
<dt className="text-[var(--text-secondary)]">
Base images ({report.base_images.length}) shared by every project
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.base_images_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Superseded images from past recreations ({report.orphan_image_count})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_image_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Volumes with no matching project in Triple-C (
{report.orphan_volumes.length})
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.orphan_volume_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Build cache <strong className="text-[var(--warning)]">whole daemon</strong>,
not just Triple-C{" "}
{/* Live information about where the figure came from, not a
disabled control `--text-disabled` is ~4.1:1 and fails AA
at this size. */}
<span className="text-[var(--text-secondary)]">
(via {report.build_cache.source})
</span>
</dt>
<dd className="text-right tabular-nums">
{formatBytes(report.build_cache.reclaimable_bytes)} of{" "}
{formatBytes(report.build_cache.total_bytes)}
</dd>
<dt className="text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
Attributable to Triple-C
</dt>
<dd className="text-right tabular-nums text-[var(--text-primary)] font-medium pt-1 border-t border-[var(--border-color)]">
{formatBytes(report.triple_c_total_bytes)}
</dd>
<dt className="text-[var(--text-secondary)]">
Everything on this daemon, yours included
</dt>
<dd className="text-right tabular-nums">
{formatBytes(
report.images_total_bytes +
report.containers_total_bytes +
report.volumes_total_bytes,
)}
</dd>
</dl>
{report.build_cache.cli_error && (
<p className="text-[11px] text-[var(--warning)]">
{/* Without this the panel silently shows `docker system df`'s
under-reported build-cache figure and the user has no way
to know why it disagrees with their terminal. */}
Build-cache figures fell back to <code>docker system df</code>, which
under-reports what a prune would free: {report.build_cache.cli_error}
</p>
)}
{report.orphan_volumes.length > 0 && (
<p className="text-[11px] text-[var(--text-secondary)] leading-relaxed">
&ldquo;Volumes with no matching project&rdquo; above means only that the
volume&rsquo;s project id is not in your project list &mdash; it is{" "}
<em>not</em> inferred from a project being stopped or having no image. A project you have not opened in a
while has no container and no snapshot either, and that is normal, so
nothing here is deleted in a group: each one is listed below on its own,
with the date Docker created it, and removing it takes typing that
volume&rsquo;s name.
</p>
)}
<p className="text-[11px] text-[var(--text-secondary)]">
Docker stores this at{" "}
<span className="font-mono">{report.host.docker_root_dir || "an unknown path"}</span>
{report.host.is_docker_desktop && " — a path inside the Docker Desktop VM, not on your filesystem"}.
</p>
</section>
{/* --- Store failure, if any ------------------------------------ */}
{report.orphan_volumes_unavailable && (
<section
className="border border-[var(--error)]/40 bg-[var(--error-muted)] rounded-[var(--radius-panel)] px-3.5 py-3"
data-testid="disk-store-error"
>
<StatusIndicator
tone="error"
label="Could not read the project list"
className="text-xs"
/>
<p className="mt-1.5 text-xs text-[var(--text-primary)] leading-relaxed">
{report.orphan_volumes_unavailable}
</p>
</section>
)}
{/* --- The plan was dropped by a reclaim -------------------------- */}
{!plan && (
<p className="text-xs text-[var(--text-secondary)]" data-testid="disk-plan-stale">
The totals above were measured before that last action. Scan again to see
what is left to reclaim.
</p>
)}
{/* --- Safe reclaim ---------------------------------------------- */}
{plan && (
<section className="space-y-2" data-testid="disk-safe-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Safe to reclaim
</h3>
{safeItems.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">
Nothing here no leftovers were found.
</p>
) : (
<>
<p className="text-xs text-[var(--text-secondary)]">
None of this is reachable any more, or all of it regenerates on demand.
Nothing you have made is in this list.
</p>
<ul className="space-y-1.5">
{safeItems.map((item) => {
const key = targetKey(item.target);
return (
<li key={key}>
<label className="flex items-start gap-2.5 cursor-pointer">
<input
type="checkbox"
// A tick that survived onto a now-blocked row is
// excluded from `selected`, so showing it checked
// would make the count disagree with the screen.
checked={item.blocked === null && ticked.has(key)}
disabled={item.blocked !== null}
onChange={() => toggle(item)}
className="mt-0.5 accent-[var(--accent-emphasis)]"
/>
<span className="flex-1 min-w-0">
<span className="flex items-baseline justify-between gap-3">
<span
className={
item.blocked
? "text-[var(--text-disabled)]"
: "text-[var(--text-primary)]"
}
>
{item.label}
{item.daemon_wide && (
<span className="ml-1.5 text-[11px] text-[var(--warning)] border border-[var(--warning)]/40 rounded-[var(--radius-control)] px-1 py-px">
whole daemon
</span>
)}
</span>
<span className="tabular-nums whitespace-nowrap text-[var(--text-secondary)]">
{formatBytes(item.bytes)}
</span>
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
</label>
</li>
);
})}
</ul>
<div className="flex items-center gap-3">
<Button
variant="primary"
size="md"
disabled={selected.length === 0 || working}
onClick={() => runReclaim(selected.map((i) => i.target))}
>
{working ? "Reclaiming…" : "Reclaim"}
</Button>
<span className="text-xs text-[var(--text-secondary)]">
{selected.length === 0
? "Nothing ticked."
: `${selected.length} selected, ${formatBytes(selectedBytes)}.`}
</span>
</div>
</>
)}
</section>
)}
{/* --- Semi-safe -------------------------------------------------- */}
{semiItems.length > 0 && (
<section className="space-y-2" data-testid="disk-semi-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Worth doing, one at a time
</h3>
<p className="text-xs text-[var(--text-secondary)]">
Nothing here loses anything you have installed. Compacting rewrites a
project&rsquo;s stacked layers into one; clearing caches deletes files
that refill themselves. Both take a moment and both are confirmed
separately.
</p>
<ul className="space-y-1.5">
{semiItems.map((item) => (
<li
key={targetKey(item.target)}
className="flex items-start justify-between gap-3"
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)]">{item.label}</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.detail}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{/* A bound, not a measurement rendered through a
different helper so it cannot read as a promise. */}
{item.bytes_are_exact
? formatBytes(item.bytes)
: formatBytesCeiling(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openConfirming(item)}
>
Run
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Destructive leftovers with no project row ------------------- */}
{unmatchedDestructive.length > 0 && (
<section className="space-y-2" data-testid="disk-unmatched-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Leftovers from projects no longer in Triple-C
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
These belong to a project id that is not in your project list, so there
is no row above to show them under. The same caveat as the volumes below
applies: &ldquo;not in your project list&rdquo; is the only thing this
means, and an idle live project is indistinguishable from a deleted one
from Docker&rsquo;s side. Because there is no project name to type, each
one is confirmed against its project <em>id</em>.
</p>
<ul className="space-y-1.5">
{unmatchedDestructive.map((item) => (
<li
key={destructiveKey(item)}
className="flex items-start justify-between gap-3"
data-testid={`disk-unmatched-${destructiveKey(item)}`}
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)] font-mono break-all">
{item.label}
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.loses}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-secondary)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{formatBytes(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openDestroying(item)}
>
Delete&hellip;
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Orphaned volumes: destructive, one at a time ---------------- */}
{orphanVolumes.length > 0 && (
<section className="space-y-2" data-testid="disk-orphan-bucket">
<h3 className="text-[13px] font-medium text-[var(--text-primary)]">
Volumes with no matching project
</h3>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
A volume here is one whose project id is not in your project list. That
is <em>all</em> it means &mdash; it is <em>not</em> inferred from a
project being stopped, having no container or having no image. An idle
live project looks exactly the same from Docker&rsquo;s side, and that
inference has already flagged two live projects here once.
</p>
<p className="text-xs text-[var(--text-secondary)] leading-relaxed">
Deleting a{" "}
<span className="font-mono">triple-c-claude-config-*</span> volume
deletes{" "}
<strong className="text-[var(--text-primary)]">
the Claude login credential that project signed in with, every plugin
and skill installed into it, and every conversation transcript it ever
had
</strong>
. A <span className="font-mono">triple-c-home-*</span> volume holds its
dotfiles, shell history and installed toolchains. There is no other copy
of either and nothing regenerates, so each one is deleted on its own,
against that volume&rsquo;s name typed out &mdash; never as part of a
group.
</p>
<ul className="space-y-1.5">
{orphanVolumes.map((item) => (
<li
key={destructiveKey(item)}
className="flex items-start justify-between gap-3"
data-testid={`disk-orphan-${item.project_name}`}
>
<span className="flex-1 min-w-0">
<span className="block text-[var(--text-primary)] font-mono break-all">
{item.label}
</span>
<span className="block text-xs text-[var(--text-secondary)] leading-snug">
{item.loses}
</span>
{item.blocked && (
<span className="block text-xs text-[var(--text-disabled)]">
{item.blocked}
</span>
)}
</span>
<span className="flex items-center gap-2 whitespace-nowrap">
<span className="text-xs text-[var(--text-secondary)] tabular-nums">
{formatBytes(item.bytes)}
</span>
<Button
size="sm"
disabled={item.blocked !== null || working}
onClick={() => openDestroying(item)}
>
Delete&hellip;
</Button>
</span>
</li>
))}
</ul>
</section>
)}
{/* --- Sweep ------------------------------------------------------ */}
<section className="flex items-center gap-3 flex-wrap">
<Button size="sm" disabled={working} onClick={runSweep}>
Sweep superseded images now
</Button>
<span className="text-xs text-[var(--text-secondary)]">
The same sweep that runs at startup and after every recreation. Unlike the
tick above it also reports what it <em>refused</em> to remove, which is how
a superseded image pinned by a stopped project shows itself.
</span>
</section>
</>
)}
{/* --- Outcome ------------------------------------------------------- */}
{outcome && (
<section
className="border border-[var(--border-color)] bg-[var(--bg-primary)] rounded-[var(--radius-panel)] px-3.5 py-3 space-y-1.5"
role="status"
aria-live="polite"
data-testid="disk-outcome"
>
<div className="flex items-center justify-between gap-3">
{/* The headline has to carry the failure in words. A partial
reclaim that freed something still has a byte figure worth
printing, so the count is appended to it rather than replacing
it and the per-result lines below say *which* ones and why,
so this stops at how many. */}
<StatusIndicator
tone={failedCount === 0 ? "ok" : "error"}
label={
failedCount === 0
? `Reclaimed ${formatBytes(outcome.total_freed_bytes)}`
: `Reclaimed ${formatBytes(outcome.total_freed_bytes)}${failedCount} of ${outcome.results.length} failed`
}
className="text-xs"
/>
<Button size="sm" variant="ghost" onClick={clearOutcome}>
Dismiss
</Button>
</div>
<ul className="space-y-1 text-xs text-[var(--text-secondary)]">
{outcome.results.map((result, index) => (
<li key={index}>
{result.message}
{result.projected_bytes !== null && (
<>
{" "}
{/* The comparison that makes a compaction's yield
readable live information, so not the disabled ink. */}
<span className="text-[var(--text-secondary)]">
(projected {formatBytesCeiling(result.projected_bytes)}, actually{" "}
{formatBytes(result.freed_bytes)})
</span>
</>
)}
</li>
))}
</ul>
</section>
)}
{/* --- Semi-safe confirmation ---------------------------------------- */}
{confirming && (
<Modal
title={confirming.label}
onClose={closeConfirming}
widthClassName="w-[30rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={closeConfirming}>
Cancel
</Button>
<Button
size="md"
variant="primary"
disabled={working}
onClick={async () => {
// Same reasoning as the destructive modal: a compaction takes
// minutes, and the dialog reporting it beats it vanishing —
// and if it fails, the dialog is the only place the user is
// still looking, so it stays open and reports it here.
// `false` covers both a throw and a refusal that came back
// inside `Ok`; either way the work did not happen, so the
// dialog stays put and reports it where the user is looking.
const ok = await runReclaim([confirming.target]);
setActionFailed(!ok);
if (ok) setConfirming(null);
}}
>
{working ? "Working…" : "Run it"}
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
{/* The failure lands here rather than only in the panel's error
line, which this dialog is covering. */}
{actionFailed && (
<p role="alert" className="text-[var(--error)]">
{error ?? (refusalText || "That did not run. Nothing was changed.")}
</p>
)}
<p>{confirming.detail}</p>
{confirming.target.kind === "compact_snapshot" && (
<>
<p>
The snapshot is rebuilt into a single layer while the old one is left
in place, so a failure at any point leaves this project exactly as it
is now.
</p>
<p>
How much comes back depends on how much of those layers a later one
already replaced &mdash; it could be{" "}
{formatBytesCeiling(confirming.bytes)}, and it could be nothing at all.
You will be told the real figure when it finishes.
</p>
<p>
One thing worth knowing: the rewritten image no longer shares the base
image with your other projects, so it carries its own copy of it. That
cost is already subtracted from the figure above, and if the rewrite
turns out not to come out ahead it is thrown away and the snapshot is
left exactly as it is.
</p>
</>
)}
{confirming.target.kind === "clear_caches" &&
confirming.target.include_rustup && (
<p>
Rust toolchains are included in this one. They are regenerable, but
getting them back is a download rather than a rebuild.
</p>
)}
</div>
</Modal>
)}
{/* --- Destructive confirmation --------------------------------------- */}
{destroying && (() => {
// An orphaned volume has no project, so nothing about this dialog can
// be phrased in terms of one: the gate takes the volume's own name (as
// `disk.rs`'s `destroy` does), and the name is never lower-cased on its
// way to the title, because the comparison the backend makes is
// case-sensitive and a mangled name in the heading is a name the user
// cannot type.
const orphan = isOrphanVolume(destroying);
// A leftover whose project is gone has no name either. `project_name`
// is the raw id in that case — which is deliberate on the Rust side and
// is exactly what `destroy` compares against — so the gate works, but
// the label has to say "id" or it asks for something that does not
// exist.
const ownerless = !orphan && !rowIds.has(destroying.project_id);
return (
<TypedConfirmModal
title={
orphan
? `Delete volume ${destroying.project_name}`
: `Delete ${destroying.label.toLowerCase()}`
}
expected={destroying.project_name}
subject={orphan ? "volume name" : ownerless ? "project id" : "project name"}
confirmLabel={orphan ? "Delete volume" : `Delete ${destroying.label.toLowerCase()}`}
busy={working}
// A failure here has to land inside the dialog. The panel's own
// error line is at the top of several screens of scroll, and this
// dialog was reached from a project row far below it.
error={
actionFailed
? (error ?? (refusalText || "That did not run. Nothing was deleted."))
: null
}
onCancel={closeDestroying}
onConfirm={async (typed) => {
// The modal stays mounted until the call settles, so its `busy`
// state is what the user sees while a multi-second volume removal
// runs. Clearing it first made the whole busy path dead code.
const ok = await destroy(destroying.target, typed);
setActionFailed(!ok);
if (ok) setDestroying(null);
}}
>
{orphan ? (
<p>
This removes the volume{" "}
<strong className="text-[var(--text-primary)] font-mono break-all">
{destroying.project_name}
</strong>
, freeing {formatBytes(destroying.bytes)}. It is offered here for one
reason only: no project in your list has its id. That is a lookup against
a file, not a judgement about whether anything is using the volume.
</p>
) : (
<p>
This removes{" "}
<strong className="text-[var(--text-primary)]">
{destroying.project_name}
</strong>
&rsquo;s {destroying.label.toLowerCase()}, freeing{" "}
{formatBytes(destroying.bytes)}.
</p>
)}
<p className="text-[var(--error)]">{destroying.loses}</p>
{orphan && (
<p>
Nothing here can undo this. If you recognise that project id, close this
and leave the volume alone until you are certain.
</p>
)}
<p>
Your mounted project folders live on the host and are not affected by this.
</p>
</TypedConfirmModal>
);
})()}
</div>
);
}
@@ -19,7 +19,6 @@ import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
import CertificateSettings from "./CertificateSettings";
import DiskSettings from "./DiskSettings";
export default function SettingsPanel() {
const { appSettings, saveSettings } = useSettings();
@@ -174,10 +173,6 @@ export default function SettingsPanel() {
<DockerSettings />
</AccordionSection>
<AccordionSection id="disk" title="Disk" defaultOpen={false}>
<DiskSettings />
</AccordionSection>
<AccordionSection id="certificates" title="Certificates" defaultOpen={false}>
<CertificateSettings />
</AccordionSection>
@@ -1,133 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import TypedConfirmModal from "./TypedConfirmModal";
const onConfirm = vi.fn();
const onCancel = vi.fn();
function renderModal(props: Partial<React.ComponentProps<typeof TypedConfirmModal>> = {}) {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
{...props}
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
return {
input: screen.getByLabelText(/Type/),
confirm: screen.getByRole("button", { name: "Delete config volume" }),
};
}
beforeEach(() => vi.clearAllMocks());
describe("TypedConfirmModal", () => {
it("is a real dialog, from the Modal primitive", () => {
renderModal();
const dialog = screen.getByRole("dialog");
expect(dialog).toHaveAttribute("aria-modal", "true");
});
it("keeps the confirm button shut until the name is typed exactly", () => {
const { input, confirm } = renderModal();
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "wh" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "whp" } });
expect(confirm).toBeEnabled();
fireEvent.click(confirm);
expect(onConfirm).toHaveBeenCalledWith("whp");
});
it("is case-sensitive, because Api and api are different projects", () => {
// This gate is the only thing between a misclick on a sorted table of
// numbers and a project's transcripts, so a near-miss is a miss.
const { input, confirm } = renderModal({ expected: "Api" });
fireEvent.change(input, { target: { value: "api" } });
expect(confirm).toBeDisabled();
fireEvent.change(input, { target: { value: "Api" } });
expect(confirm).toBeEnabled();
});
it("forgives surrounding whitespace from a paste", () => {
const { input, confirm } = renderModal();
fireEvent.change(input, { target: { value: " whp " } });
expect(confirm).toBeEnabled();
});
it("announces the gate's state in words rather than only by the button fill", () => {
const { input } = renderModal();
expect(screen.getByRole("status")).toHaveTextContent(
"Waiting for the exact project name.",
);
fireEvent.change(input, { target: { value: "whp" } });
expect(screen.getByRole("status")).toHaveTextContent("Name matches.");
});
it("names what it is waiting for, when that is not a project", () => {
// An orphaned volume has no project — its id matches nothing in the store,
// which is the definition of the variant — so the gate takes the volume's
// own name and must not ask for a string that does not exist.
renderModal({ expected: "triple-c-claude-config-gone", subject: "volume name" });
expect(screen.getByRole("status")).toHaveTextContent(
"Waiting for the exact volume name.",
);
});
it("spells out what is lost, from the caller's copy", () => {
renderModal();
expect(screen.getByText("Everything goes.")).toBeInTheDocument();
});
it("locks itself while the deletion is running", () => {
render(
<TypedConfirmModal
title="Delete claude config volume"
expected="whp"
confirmLabel="Delete config volume"
onConfirm={onConfirm}
onCancel={onCancel}
busy
>
<p>Everything goes.</p>
</TypedConfirmModal>,
);
// The confirm button reports the work in a word rather than only going
// grey, so it is found by its busy label, not its idle one.
expect(screen.getByLabelText(/Type/)).toBeDisabled();
expect(screen.getByRole("button", { name: "Working…" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Cancel" })).toBeDisabled();
});
it("cancels without confirming", () => {
renderModal();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalled();
expect(onConfirm).not.toHaveBeenCalled();
});
it("carries a failed attempt inside the dialog, as an alert", () => {
// The caller keeps this dialog open when the deletion fails, because the
// panel behind it is several screens long and its error line sits at the
// top — nowhere near the row this was opened from.
renderModal({ error: "volume triple-c-home-p-whp is in use by a running container" });
expect(screen.getByRole("alert")).toHaveTextContent(/in use by a running container/);
});
it("says nothing about failure when there has been none", () => {
renderModal();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("cannot be satisfied by an empty box when there is no name to type", () => {
const { confirm } = renderModal({ expected: "" });
expect(confirm).toBeDisabled();
});
});
-145
View File
@@ -1,145 +0,0 @@
import { useId, useRef, useState, type ReactNode } from "react";
import Modal from "./Modal";
import Button from "./Button";
import { inputClass } from "./Field";
interface Props {
title: string;
/** What must be typed, verbatim, before the confirm button enables. */
expected: string;
/**
* What `expected` *is*, for the waiting message "project name" unless the
* caller says otherwise.
*
* An orphaned volume has no project by definition, so its gate takes the
* volume's own name (that is what `disk.rs`'s `destroy` compares against),
* and telling that user we are "waiting for the exact project name" would be
* asking for a string that does not exist.
*/
subject?: string;
/** The verb on the confirm button. Repeat the action — never "OK". */
confirmLabel: string;
/** What is about to be lost, in full. */
children: ReactNode;
onConfirm: (typed: string) => void;
onCancel: () => void;
busy?: boolean;
/**
* Why the last attempt did not happen. The caller keeps the dialog open when
* its action fails, so the failure has to be readable *here* the panel
* behind this one is several screens long and its error line is at the top
* of it, which is not where the user is looking.
*/
error?: string | null;
}
/**
* The confirmation gate for something that has no other copy.
*
* ## Why this exists when `ConfirmResetModal` already did
*
* Reset and Remove are reached from a project's own overflow menu, one project
* at a time, by a user who went looking for them. The Disk panel lists every
* project's volumes side by side in a table of numbers, sorted by size which
* is exactly the layout that invites a misclick on the wrong row. A two-button
* dialog does not survive that, because the thing being confirmed (*which*
* project) is the thing the user got wrong.
*
* Typing the name fixes the failure mode rather than adding friction to it: the
* gate is not "are you sure", it is "name the project you mean".
*
* The comparison is `expected.trim() === typed.trim()` and **case-sensitive**
* mirroring `confirmation_matches` in `docker/disk.rs`, which is the check that
* actually holds, since this one is only a UI affordance. The backend refuses a
* mismatch on its own.
*/
export default function TypedConfirmModal({
title,
expected,
subject = "project name",
confirmLabel,
children,
onConfirm,
onCancel,
busy = false,
error = null,
}: Props) {
const [typed, setTyped] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
// Every other `ui/` component uses `useId`; a hardcoded id breaks the
// label association as soon as two of these are mounted at once.
const inputId = useId();
const matches = expected.trim().length > 0 && typed.trim() === expected.trim();
return (
<Modal
title={title}
onClose={onCancel}
widthClassName="w-[30rem]"
initialFocusRef={inputRef}
dismissible={!busy}
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel} disabled={busy}>
Cancel
</Button>
<Button
size="md"
onClick={() => onConfirm(typed)}
disabled={!matches || busy}
className={
matches && !busy
? "bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
: "bg-[var(--bg-tertiary)] text-[var(--text-disabled)] border border-[var(--border-color)]"
}
>
{busy ? "Working…" : confirmLabel}
</Button>
</>
}
>
<div className="space-y-3 text-[13px] text-[var(--text-secondary)]">
{children}
<div>
<label
htmlFor={inputId}
className="block text-[13px] text-[var(--text-primary)] mb-1.5"
>
Type <strong className="font-mono">{expected}</strong> to confirm
</label>
<input
id={inputId}
ref={inputRef}
value={typed}
onChange={(e) => setTyped(e.target.value)}
disabled={busy}
autoComplete="off"
spellCheck={false}
className={`${inputClass} font-mono`}
/>
{/* Announced rather than only coloured the gate's state has to be
readable without relying on the button's fill. */}
<p role="status" aria-live="polite" className="mt-1.5 text-xs">
{matches ? (
<span className="text-[var(--text-secondary)]">Name matches.</span>
) : (
// Not disabled content — the gate is live and waiting on the
// user. `--text-disabled` is ~4.1:1 and fails AA at 12px.
<span className="text-[var(--text-secondary)]">
Waiting for the exact {subject}.
</span>
)}
</p>
</div>
{error && (
// Rendered last, next to the button that was just pressed, and as an
// `alert` so it is announced on arrival rather than waiting to be
// found.
<p role="alert" className="text-[var(--error)]">
{error}
</p>
)}
</div>
</Modal>
);
}
-473
View File
@@ -1,473 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useDiskUsage, type DiskUsageState } from "./useDiskUsage";
import type { DiskUsageReport } from "../lib/types";
const getDockerDiskUsage = vi.fn();
const listReclaimable = vi.fn();
const reclaim = vi.fn();
const destroyProjectDiskObject = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
getDockerDiskUsage: () => getDockerDiskUsage(),
listReclaimable: (report: DiskUsageReport) => listReclaimable(report),
reclaim: (targets: unknown) => reclaim(targets),
destroyProjectDiskObject: (target: unknown, confirmation: string) =>
destroyProjectDiskObject(target, confirmation),
sweepOrphanedSnapshots: () => sweepOrphanedSnapshots(),
}));
const sweepOrphanedSnapshots = vi.fn();
const report = (scanned_at: string): DiskUsageReport =>
({ scanned_at, projects: [] }) as unknown as DiskUsageReport;
const plan = { items: [], destructive: [], store_error: null };
beforeEach(() => {
vi.clearAllMocks();
listReclaimable.mockResolvedValue(plan);
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 0 });
});
describe("useDiskUsage", () => {
it("holds no report until a scan is asked for", () => {
const { result } = renderHook(() => useDiskUsage());
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(getDockerDiskUsage).not.toHaveBeenCalled();
});
it("scans, then plans off the same report rather than scanning again", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
expect(listReclaimable).toHaveBeenCalledWith(report("first"));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.plan).toEqual(plan);
});
it("lets the newest scan win when two are in flight", async () => {
// A user pressing Scan twice can have two `df()` calls outstanding, and
// the second is not necessarily the slower one. A stale response must not
// overwrite a fresher one.
let resolveFirst: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage
.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveFirst = r;
}),
)
.mockResolvedValueOnce(report("second"));
const { result } = renderHook(() => useDiskUsage());
let firstScan: Promise<void> = Promise.resolve();
act(() => {
firstScan = result.current.scan();
});
await act(async () => {
await result.current.scan();
});
expect(result.current.report?.scanned_at).toBe("second");
// The slow first scan lands afterwards and is discarded.
await act(async () => {
resolveFirst(report("first"));
await firstScan;
});
expect(result.current.report?.scanned_at).toBe("second");
expect(result.current.scanning).toBe(false);
});
it("passes the ticked targets straight through", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
expect(reclaim).toHaveBeenCalledWith([
{ kind: "dangling_snapshots" },
{ kind: "build_cache", all: false },
]);
});
it("does not call the backend for an empty selection", async () => {
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([]);
});
expect(reclaim).not.toHaveBeenCalled();
});
it("does not re-scan after a reclaim", async () => {
// Another `df()` costs seconds, and the outcome already carries measured
// bytes for every target. A user who wants fresh totals asks for them.
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(getDockerDiskUsage).toHaveBeenCalledTimes(1);
});
it("clears the previous outcome when a new scan starts", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({ results: [], total_freed_bytes: 42 });
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.outcome?.total_freed_bytes).toBe(42);
await act(async () => {
await result.current.scan();
});
expect(result.current.outcome).toBeNull();
});
it("forwards the typed confirmation verbatim", async () => {
destroyProjectDiskObject.mockResolvedValue({
target: { kind: "dangling_snapshots" },
ok: true,
freed_bytes: 100,
projected_bytes: null,
message: "gone",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.destroy({ kind: "config_volume", project_id: "p1" }, "whp");
});
expect(destroyProjectDiskObject).toHaveBeenCalledWith(
{ kind: "config_volume", project_id: "p1" },
"whp",
);
expect(result.current.outcome?.total_freed_bytes).toBe(100);
});
it("reports a scan failure and keeps the last good measurement", async () => {
// The old report is still an accurate measurement of an earlier moment,
// and the error says the refresh failed. Blanking it would leave the panel
// with nothing while telling the user nothing more.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockRejectedValueOnce("daemon unreachable");
await act(async () => {
await result.current.scan();
});
await waitFor(() => expect(result.current.error).toMatch(/daemon unreachable/));
expect(result.current.report?.scanned_at).toBe("first");
expect(result.current.scanning).toBe(false);
});
it("never shows fresh totals beside a stale tick list", async () => {
// `setReport` used to land before the plan call was awaited, so a plan
// failure rendered this scan's numbers above the previous scan's rows.
getDockerDiskUsage.mockResolvedValueOnce(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
getDockerDiskUsage.mockResolvedValueOnce(report("second"));
listReclaimable.mockRejectedValueOnce("planner exploded");
await act(async () => {
await result.current.scan();
});
expect(result.current.error).toMatch(/planner exploded/);
expect(result.current.report?.scanned_at).toBe("first");
});
it("drops the plan after a reclaim so ticks cannot be re-fired at nothing", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
expect(result.current.plan).toEqual(plan);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The totals stay — they were measured before the reclaim and the outcome
// says what changed.
expect(result.current.report?.scanned_at).toBe("first");
});
it("runs the sweep through its own command and reports what it refused", async () => {
// The sweep's `in_use` count — orphans Docker refused to delete because a
// stopped project still needs them — is invisible everywhere else in the
// app, because every other caller throws the report away.
sweepOrphanedSnapshots.mockResolvedValue({
removed: ["sha256:a", "sha256:b"],
reclaimed_bytes: 11_900_000_000,
in_use: 3,
failed: [],
unavailable: null,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(sweepOrphanedSnapshots).toHaveBeenCalled();
expect(result.current.outcome?.total_freed_bytes).toBe(11_900_000_000);
expect(result.current.outcome?.results[0].message).toMatch(/Swept 2 superseded image/);
expect(result.current.outcome?.results[0].message).toMatch(/3 were left alone/);
});
// -------------------------------------------------------------------------
// The scan-versus-mutation race
// -------------------------------------------------------------------------
it("throws away a scan that a reclaim overtook", async () => {
// The live race the generation counter used to miss entirely. A scan takes
// seconds and does not set `working`, so nothing stopped the user
// reclaiming on top of one — and when the scan landed it repainted the
// pre-reclaim report *and* a fresh, clickable plan listing objects the
// reclaim had just deleted.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
expect(result.current.scanning).toBe(true);
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(result.current.plan).toBeNull();
// The overtaken scan finishes last, and must land nothing at all.
await act(async () => {
resolveScan(report("measured before the reclaim"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
// It does not even get as far as re-planning: a plan built from a report
// this stale is the clickable half of the bug.
expect(listReclaimable).not.toHaveBeenCalled();
});
it("does not strand `scanning` when a mutation retires the scan", async () => {
// `scanning` is cleared against the newest *scan*, not the newest
// generation — a mutation bumps the generation without starting a scan, so
// guarding on that would leave the button reading "Scanning…" forever.
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.scanning).toBe(false);
});
it("retires an in-flight scan for a destroy and a sweep too", async () => {
// Every mutation invalidates a measurement, not just the bulk one.
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: true,
freed_bytes: 1,
projected_bytes: null,
message: "gone",
});
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: null,
});
for (const mutate of [
(r: DiskUsageState) => r.destroy({ kind: "home_volume", project_id: "p1" }, "whp"),
(r: DiskUsageState) => r.runSweep(),
]) {
let resolveScan: (value: DiskUsageReport) => void = () => {};
getDockerDiskUsage.mockReturnValueOnce(
new Promise<DiskUsageReport>((r) => {
resolveScan = r;
}),
);
const { result } = renderHook(() => useDiskUsage());
let inFlight: Promise<void> = Promise.resolve();
act(() => {
inFlight = result.current.scan();
});
await act(async () => {
await mutate(result.current);
});
await act(async () => {
resolveScan(report("stale"));
await inFlight;
});
expect(result.current.report).toBeNull();
expect(result.current.plan).toBeNull();
expect(result.current.scanning).toBe(false);
}
});
// -------------------------------------------------------------------------
// Reporting failure back to the caller
// -------------------------------------------------------------------------
it("tells the caller a reclaim failed instead of only swallowing it into `error`", async () => {
// The confirmation dialogs close on completion. Without a return value
// they closed on failure too, leaving the error at the top of a panel the
// user had scrolled well past.
reclaim.mockRejectedValueOnce("compaction failed: no space left on device");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/no space left on device/);
});
it("tells the caller a destroy failed", async () => {
destroyProjectDiskObject.mockRejectedValueOnce("volume is in use by a running container");
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.error).toMatch(/in use by a running container/);
});
it("calls a refusal that came back inside `Ok` a failure, and keeps the plan", async () => {
// `reclaim` reports per-target results, and a compaction the backend
// declined is `ok: false` with a sentence saying why — not a thrown error.
// Treating that as success closed the dialog that asked for it and took
// the tick list away, even though every object it listed is still there.
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{
target: { kind: "compact_snapshot", project_id: "p1" },
destroyed: null,
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "Cannot compact p1: a terminal session is still attached.",
},
],
total_freed_bytes: 0,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "compact_snapshot", project_id: "p1" }]);
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
expect(result.current.outcome?.results[0].message).toMatch(/still attached/);
});
it("drops the plan when part of a batch did happen", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
reclaim.mockResolvedValue({
results: [
{ target: { kind: "dangling_snapshots" }, destroyed: null, ok: true, freed_bytes: 12, projected_bytes: null, message: "Removed 3 images" },
{ target: { kind: "compact_snapshot", project_id: "p1" }, destroyed: null, ok: false, freed_bytes: 0, projected_bytes: null, message: "Refused" },
],
total_freed_bytes: 12,
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([
{ kind: "dangling_snapshots" },
{ kind: "compact_snapshot", project_id: "p1" },
]);
});
expect(ok).toBe(false);
expect(result.current.plan).toBeNull();
});
it("calls a refused destroy a failure and leaves its row in the plan", async () => {
getDockerDiskUsage.mockResolvedValue(report("first"));
destroyProjectDiskObject.mockResolvedValue({
target: null,
destroyed: { kind: "home_volume", project_id: "p1" },
ok: false,
freed_bytes: 0,
projected_bytes: null,
message: "The volume is still attached to a running container.",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.scan();
});
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.destroy({ kind: "home_volume", project_id: "p1" }, "whp");
});
expect(ok).toBe(false);
expect(result.current.plan).toEqual(plan);
});
it("reports success when the call came back", async () => {
const { result } = renderHook(() => useDiskUsage());
let ok: boolean | undefined;
await act(async () => {
ok = await result.current.runReclaim([{ kind: "dangling_snapshots" }]);
});
expect(ok).toBe(true);
});
it("treats an unreachable daemon in the sweep report as an error", async () => {
sweepOrphanedSnapshots.mockResolvedValue({
removed: [],
reclaimed_bytes: 0,
in_use: 0,
failed: [],
unavailable: "Could not reach the Docker engine",
});
const { result } = renderHook(() => useDiskUsage());
await act(async () => {
await result.current.runSweep();
});
expect(result.current.error).toMatch(/Could not reach the Docker engine/);
expect(result.current.outcome).toBeNull();
});
});
-270
View File
@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from "react";
import * as commands from "../lib/tauri-commands";
import type {
DestructiveTarget,
DiskUsageReport,
ReclaimOutcome,
ReclaimPlan,
ReclaimTarget,
} from "../lib/types";
/**
* State for the Disk section.
*
* ## Why nothing here runs on mount
*
* A scan is `GET /system/df`, which walks every image, container and volume on
* the daemon and computes shared-layer sizes. On a 100 GB store that is
* seconds. `AccordionSection` unmounts its body when collapsed, so a
* `useEffect` scan would re-run every single time the user opened the section.
* The scan is therefore only ever what the Scan button calls.
*
* Note what that does *not* buy: this hook lives inside `DiskSettings`, which
* the accordion unmounts on collapse, so its state goes with it and reopening
* the section shows an unscanned panel again. That is the honest behaviour
* a stale total is worse than an absent one but it means collapsing and
* reopening discards a scan the user paid for. Lifting the report into
* `appState` would fix that and is deliberately not done here: it would put a
* multi-megabyte, rapidly-stale blob into the app-wide store for one panel.
*
* ## The generation guard
*
* A user who hits Scan twice can have two `df()` calls in flight, and they can
* land out of order the second one is not necessarily slower. Every async
* write in `scan` checks it is still the newest before it lands, the same
* pattern `useContainerMigration` uses.
*
* The race that actually bites, though, is not scan-versus-scan: it is
* scan-versus-**mutation**. A scan takes seconds and does not set `working`, so
* nothing stopped a reclaim starting on top of one. The reclaim correctly drops
* the plan and then the still-running scan landed, passed its own generation
* check, and repainted a pre-reclaim report *plus a fresh, clickable plan
* listing objects that had just been deleted*. So every mutation bumps the
* counter as well: whatever a scan is holding was measured before the mutation
* and is now a lie, and throwing it away is the only honest thing to do with
* it. (The Scan button is disabled while `working` for the mirror-image case,
* so a scan can never start *during* a mutation.)
*
* That is also why `scanning` is not cleared against the same counter: a
* mutation bumping it mid-scan would strand the flag at true and leave the
* button reading "Scanning…" forever. `latestScan` records the generation the
* newest *scan* owns only a newer scan may take the flag away and that is
* what the `finally` compares against.
*/
export interface DiskUsageState {
report: DiskUsageReport | null;
plan: ReclaimPlan | null;
/** A scan is in flight. */
scanning: boolean;
/** A reclaim or a destroy is in flight. */
working: boolean;
error: string | null;
/** The outcome of the last reclaim, kept on screen until the next scan. */
outcome: ReclaimOutcome | null;
scan: () => Promise<void>;
/**
* Resolves `true` only when the work actually happened.
*
* Two different failures reach here and both have to answer `false`. One is
* the call throwing, which lands in `error`. The other is the backend coming
* back inside `Ok` with a *refusal* `reclaim` reports per-target results,
* and a compaction declined because the project is busy is a `ReclaimResult`
* with `ok: false` and a sentence saying why. Reading only "did it throw"
* treated that refusal as a success: the confirmation dialog closed, the plan
* was dropped, and the explanation appeared in the outcome panel several
* screens above the row the user had clicked.
*
* Callers that dismiss UI on completion the confirmation dialogs must
* only dismiss on `true`, and take the wording from `outcome`'s per-result
* `message` rather than writing their own: the backend's sentence is the one
* that names the real blocker.
*/
runReclaim: (targets: ReclaimTarget[]) => Promise<boolean>;
/** Same contract as `runReclaim`: `false` means it did not happen, and either
* `error` or the outcome's `message` says why. */
destroy: (target: DestructiveTarget, confirmation: string) => Promise<boolean>;
/** Run the orphaned-snapshot sweep and report what it found *and refused*. */
runSweep: () => Promise<void>;
clearOutcome: () => void;
}
export function useDiskUsage(): DiskUsageState {
const [report, setReport] = useState<DiskUsageReport | null>(null);
const [plan, setPlan] = useState<ReclaimPlan | null>(null);
const [scanning, setScanning] = useState(false);
const [working, setWorking] = useState(false);
const [error, setError] = useState<string | null>(null);
const [outcome, setOutcome] = useState<ReclaimOutcome | null>(null);
const generation = useRef(0);
/** The generation belonging to the most recently *started* scan. */
const latestScan = useRef(0);
/**
* Retire every in-flight scan. Called at the top of each mutation, because
* the moment we start deleting things, a measurement taken before that is no
* longer describing the daemon the user is looking at.
*/
const invalidateScans = useCallback(() => {
generation.current += 1;
}, []);
const scan = useCallback(async () => {
const mine = ++generation.current;
latestScan.current = mine;
setScanning(true);
setError(null);
// The previous outcome describes a state that no longer holds once a new
// scan starts, so it goes rather than sitting beside fresh numbers.
setOutcome(null);
try {
const next = await commands.getDockerDiskUsage();
if (generation.current !== mine) return;
// Planning is cheap and always wanted: the classification is what makes
// the numbers actionable, and it reuses the report rather than scanning
// again.
const nextPlan = await commands.listReclaimable(next);
if (generation.current !== mine) return;
// Both land together, or neither does. Setting the report before
// awaiting the plan would render this scan's totals above the *previous*
// scan's still-clickable tick list if the plan call failed.
setReport(next);
setPlan(nextPlan);
} catch (e) {
if (generation.current !== mine) return;
setError(String(e));
// The old report is left on screen deliberately — it is still an
// accurate measurement of an earlier moment, and the error says the
// refresh failed. What must not survive is a plan describing a scan the
// user can no longer see the totals for, but that cannot happen: the two
// only ever move together.
} finally {
// Deliberately `latestScan`, not `generation`: a mutation that retired
// this scan did not start another one, so this scan is still the last
// word on whether a scan is running.
if (latestScan.current === mine) setScanning(false);
}
}, []);
const runReclaim = useCallback(
async (targets: ReclaimTarget[]): Promise<boolean> => {
// Nothing was asked for, so nothing failed — a caller gating a dialog on
// this must not be left staring at an error that has no cause.
if (targets.length === 0) return true;
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.reclaim(targets);
setOutcome(result);
// **The plan is now stale and must not stay clickable.** Its rows
// describe objects this call just removed, so leaving them ticked lets
// the user fire the same reclaim again against nothing. Dropping the plan
// (not the report) leaves the totals on screen, marked as measured before
// the reclaim, with the tick list gone.
//
// Deliberately no automatic re-scan: it costs another `df()`, and the
// outcome already reports measured bytes for every target — a user who
// wants the new totals asks for them.
//
// The exception is a call that removed *nothing at all* because every
// target was refused: those objects are all still there, so the plan
// still describes the daemon accurately and taking it away would leave
// the user re-scanning to get back a list that never went stale.
const everythingRefused =
result.results.length > 0 && result.results.every((r) => !r.ok);
if (!everythingRefused) setPlan(null);
return result.results.every((r) => r.ok);
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
const destroy = useCallback(
async (target: DestructiveTarget, confirmation: string): Promise<boolean> => {
invalidateScans();
setWorking(true);
setError(null);
try {
const result = await commands.destroyProjectDiskObject(target, confirmation);
setOutcome({ results: [result], total_freed_bytes: result.freed_bytes });
// Same reasoning as `runReclaim`, refusal included: the destructive
// list named an object that is now gone — unless the backend declined,
// in which case it is still there and so is the row for it.
if (result.ok) setPlan(null);
return result.ok;
} catch (e) {
setError(String(e));
return false;
} finally {
setWorking(false);
}
},
[invalidateScans],
);
/**
* The startup sweep, on demand.
*
* Not the same as ticking "superseded snapshot layers", even though both end
* up removing the same images: this reports `in_use` the orphans Docker
* *refused* to delete because a stopped project's container still needs
* them. That refusal is the sweep's third safety net and it is invisible
* everywhere else in the app, because every existing caller throws the
* report away.
*/
const runSweep = useCallback(async () => {
invalidateScans();
setWorking(true);
setError(null);
try {
const sweep = await commands.sweepOrphanedSnapshots();
if (sweep.unavailable) {
setError(sweep.unavailable);
return;
}
const refused =
sweep.in_use > 0
? ` ${sweep.in_use} were left alone because a container is still built from them — start and stop, or recreate, that project and a later sweep gets them.`
: "";
setOutcome({
results: [
{
target: { kind: "dangling_snapshots" },
destroyed: null,
ok: sweep.failed.length === 0,
freed_bytes: sweep.reclaimed_bytes,
projected_bytes: null,
message: `Swept ${sweep.removed.length} superseded image(s).${refused}`,
},
],
total_freed_bytes: sweep.reclaimed_bytes,
});
setPlan(null);
} catch (e) {
setError(String(e));
} finally {
setWorking(false);
}
}, [invalidateScans]);
const clearOutcome = useCallback(() => setOutcome(null), []);
return {
report,
plan,
scanning,
working,
error,
outcome,
scan,
runReclaim,
destroy,
runSweep,
clearOutcome,
};
}
-96
View File
@@ -8,7 +8,6 @@ const downloadContainerFile = vi.fn();
const uploadFileToContainer = vi.fn();
const renameContainerPath = vi.fn();
const createContainerDirectory = vi.fn();
const stageContainerFileForDrag = vi.fn();
vi.mock("../lib/tauri-commands", () => ({
listContainerFiles: (p: string, path: string) => listContainerFiles(p, path),
@@ -18,7 +17,6 @@ vi.mock("../lib/tauri-commands", () => ({
createContainerDirectory: (p: string, parent: string, n: string) =>
createContainerDirectory(p, parent, n),
readContainerFile: vi.fn(),
stageContainerFileForDrag: (p: string, path: string) => stageContainerFileForDrag(p, path),
}));
/**
@@ -231,85 +229,6 @@ describe("useFileManager save to host", () => {
});
});
describe("useFileManager drag-out staging", () => {
it("copies the file onto the host and hands back the host path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledWith("p1", "/workspace/a.txt");
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
// The note is transient — it must not still be sitting there afterwards.
expect(result.current.busy).toBeNull();
});
it("reuses the copy on a second drag of the same entry", async () => {
// The whole point of the cache: the copy is the slow half of the gesture,
// and a retry after a drag the OS missed has to be immediate.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let second: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
second = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(1);
expect(second).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: true });
});
it("re-stages once the entry has changed underneath it", async () => {
// Keyed on size and mtime, so a file edited in the container is copied
// again rather than dragged out at its old contents.
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
await act(async () => {
await result.current.stageForDrag(file("a.txt", { size: 10 }));
await result.current.stageForDrag(file("a.txt", { size: 4096 }));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
});
it("surfaces a refused staging instead of returning a path that is not there", async () => {
stageContainerFileForDrag.mockRejectedValue(
'900 MB is too large to drag out (limit 256 MB) — use "Save to host…" instead.',
);
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
staged = await result.current.stageForDrag(file("huge.bin"));
});
expect(staged).toBeNull();
expect(toastText()).toContain("too large to drag out");
expect(toastText()).toContain("Save to host");
expect(result.current.busy).toBeNull();
});
it("does not cache a failure, so a retry actually retries", async () => {
stageContainerFileForDrag.mockRejectedValueOnce("Container not running");
stageContainerFileForDrag.mockResolvedValueOnce("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
let staged: { hostPath: string; cached: boolean } | null = null;
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
staged = await result.current.stageForDrag(file("a.txt"));
});
expect(stageContainerFileForDrag).toHaveBeenCalledTimes(2);
expect(staged).toEqual({ hostPath: "/tmp/triple-c-drag-out/s1/a.txt", cached: false });
});
});
describe("useFileManager stays where the user is", () => {
it("does not drag the pane back when the user navigates away mid-upload", async () => {
// The closure captured `/workspace`; the user is in `/workspace/src` by the
@@ -485,21 +404,6 @@ describe("useFileManager overwrite prompt", () => {
});
});
describe("useFileManager staged host paths", () => {
it("recognises a path it staged, and only that path", async () => {
stageContainerFileForDrag.mockResolvedValue("/tmp/triple-c-drag-out/s1/a.txt");
const { result } = renderHook(() => useFileManager("p1"));
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(false);
await act(async () => {
await result.current.stageForDrag(file("a.txt"));
});
expect(result.current.isStagedHostPath("/tmp/triple-c-drag-out/s1/a.txt")).toBe(true);
// Same basename, a real host file the user actually wants uploaded.
expect(result.current.isStagedHostPath("/home/me/a.txt")).toBe(false);
});
});
/**
* The loop, end to end. The prompt only earns its place if the *batch* survives
* it: one answer, given once, has to leave every other file in the drop exactly
+2 -70
View File
@@ -32,15 +32,6 @@ function baseName(path: string): string {
return parts[parts.length - 1] || path;
}
/**
* Host paths compare on separators, not on case: the OS hands a dropped path
* back in whatever form its file dialog produced, and on Windows that is not
* reliably the form `stage_container_file_for_drag` returned.
*/
function normaliseHostPath(path: string): string {
return path.replace(/\\/g, "/").replace(/\/+$/, "");
}
/**
* ## Where failures are reported
*
@@ -51,7 +42,7 @@ function normaliseHostPath(path: string): string {
* no rows, and it is not transient it stands until the directory lists.
*
* Every **transient operation** failure upload, rename, create folder,
* save-to-host, drag staging goes to `ToastHost` instead. Those used to land
* save-to-host goes to `ToastHost` instead. Those used to land
* in the same inline `error` div, which is the first child of the *scrolling*
* list: three hundred rows down, a refused rename produced no visible change
* at all, just a rename box that stayed open for no stated reason. Worse, the
@@ -91,7 +82,7 @@ export function useFileManager(projectId: string) {
/**
* A slow listing can land after a newer one and set both the rows and the
* breadcrumb back to a directory the user already left. Same generation
* guard `useDiskUsage` and `useContainerMigration` use: every async write
* guard `useContainerMigration` uses: every async write
* checks it is still the newest before it lands.
*/
const navGeneration = useRef(0);
@@ -320,63 +311,6 @@ export function useFileManager(projectId: string) {
[projectId, navigate, startWork, report, askOverwrite],
);
/**
* Host paths already copied out this session, keyed by the entry they came
* from. Size and mtime are in the key, so an entry that changed since the
* last listing re-stages rather than dragging a stale copy.
*/
const stagedRef = useRef(new Map<string, string>());
/**
* The same paths the other way round, as a set.
*
* A drag-out released back inside the app arrives as an ordinary host drop
* carrying the staged copy's path, and uploading that would write the app's
* own temp copy over the container file it came from which is worse than a
* no-op, because the key above is built from the *last listing*, so a file an
* agent rewrote since then would be replaced by a minutes-old snapshot. This
* set is what makes the "is this ours?" test exact instead of a guess at the
* temp directory's name.
*/
const stagedHostPathsRef = useRef(new Set<string>());
/** True when `path` is a copy this pane staged for a drag-out. */
const isStagedHostPath = useCallback(
(path: string) => stagedHostPathsRef.current.has(normaliseHostPath(path)),
[],
);
/**
* Copy an entry onto the host so the OS can drag it, and return the absolute
* host path or `null`, having reported why, if it could not be staged.
*
* `cached` is what the caller needs to tell a gesture that will feel
* instantaneous from one that has a whole-file copy in front of it: the copy
* is the slow half of a drag-out, and the OS only picks a drag up while the
* button is still down.
*/
const stageForDrag = useCallback(
async (entry: FileEntry): Promise<{ hostPath: string; cached: boolean } | null> => {
const key = `${entry.path}|${entry.size}|${entry.modified}`;
const cached = stagedRef.current.get(key);
if (cached) return { hostPath: cached, cached: true };
startWork(`Preparing "${entry.name}"…`);
try {
const hostPath = await commands.stageContainerFileForDrag(projectId, entry.path);
stagedRef.current.set(key, hostPath);
stagedHostPathsRef.current.add(normaliseHostPath(hostPath));
setCompleted(`"${entry.name}" is ready to drag.`);
return { hostPath, cached: false };
} catch (e) {
report(`Could not prepare "${entry.name}" for dragging`, e);
return null;
} finally {
setBusy(null);
}
},
[projectId, startWork, report],
);
const uploadFile = useCallback(async () => {
try {
const selected = await openDialog({ multiple: true, directory: false });
@@ -447,8 +381,6 @@ export function useFileManager(projectId: string) {
downloadFile,
uploadFile,
uploadPaths,
stageForDrag,
isStagedHostPath,
renameEntry,
createFolder,
};
+6 -6
View File
@@ -3,9 +3,9 @@ import { formatBytes, formatBytesCeiling, formatBytesDelta } from "./formatBytes
describe("formatBytes", () => {
it("defaults to base 1000, because that is what Docker prints", () => {
// The Disk panel exists to explain `docker system df`, which formats with
// `units.HumanSize` — base 1000. Showing 26.1 GB against a terminal saying
// 28.0 GB for the same build cache reads as a bug in the panel.
// Anything explaining `docker system df` has to match it, and Docker
// formats with `units.HumanSize` — base 1000. Showing 26.1 GB against a
// terminal saying 28.0 GB for the same object reads as a bug in the app.
expect(formatBytes(28_000_000_000)).toBe("28.0 GB");
expect(formatBytes(1_000)).toBe("1.0 KB");
expect(formatBytes(1_500_000)).toBe("1.5 MB");
@@ -108,9 +108,9 @@ describe("formatBytesDelta", () => {
describe("formatBytesCeiling", () => {
it("says 'up to', because a compaction's yield is a bound not a promise", () => {
// Every other figure in the Disk panel is measured. This one cannot be
// known until the rewrite runs, and rendering it through a separate
// function is what stops it being read as a guarantee.
// A projected yield cannot be known until the work runs, unlike every
// measured figure beside it — rendering it through a separate function is
// what stops it being read as a guarantee.
expect(formatBytesCeiling(5_100_000_000)).toBe("up to 5.1 GB");
});
+16 -9
View File
@@ -17,11 +17,11 @@
*
* ## Why the default is base 1000
*
* The Disk panel exists to explain what `docker system df` reports, and Docker
* formats every size it prints with `units.HumanSize`, which is **base 1000**.
* A panel that showed 26.1 GB where the user's terminal said 28.0 GB for the
* same build cache would read as a bug in the panel. So decimal is the default
* and binary is opt-in, rather than the other way round.
* Anything explaining what Docker reports has to match it, and Docker formats
* every size it prints with `units.HumanSize`, which is **base 1000**. Showing
* 26.1 GB where the user's terminal said 28.0 GB for the same object would read
* as a bug in the app. So decimal is the default and binary is opt-in, rather
* than the other way round.
*
* Both existing conventions are preserved for every size either call site can
* realistically produce a file size or a payload size, i.e. a non-negative
@@ -90,6 +90,10 @@ export function formatBytes(bytes: number, options: FormatBytesOptions = {}): st
* `12.3 GB` `+12.3 GB`, for a figure that is being *added* rather than
* measured. Used for "next commit adds …", which is the number that explains
* why a snapshot grows.
*
* **No caller on this branch**, for the same reason as [`formatBytesCeiling`]:
* the Disk panel's per-project table was the last one, and it went to
* `hold/disk-and-dragout`.
*/
export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): string {
const formatted = formatBytes(bytes, options);
@@ -99,10 +103,13 @@ export function formatBytesDelta(bytes: number, options?: FormatBytesOptions): s
/**
* `up to 12.3 GB` for a bound rather than a measurement.
*
* The Disk panel is careful about this distinction: every figure it shows is
* measured except a compaction's yield, which cannot be known until it runs.
* Rendering that one through a different function is what stops it being read
* as a promise.
* A figure that cannot be known until an operation runs must not render like
* one that was measured; going through a different function is what stops it
* being read as a promise.
*
* **No caller on this branch.** Its last one was the Disk panel's projected
* compaction yield, which went to `hold/disk-and-dragout`. Kept with its tests
* because the distinction it encodes is the reusable part.
*/
export function formatBytesCeiling(bytes: number, options?: FormatBytesOptions): string {
if (!Number.isFinite(bytes) || bytes <= 0) return "an unknown amount";
+1 -39
View File
@@ -1,5 +1,5 @@
import { invoke } from "@tauri-apps/api/core";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo, DiskUsageReport, ReclaimPlan, ReclaimTarget, ReclaimOutcome, ReclaimResult, DestructiveTarget, SnapshotSweepReport } from "./types";
import type { Project, ProjectPath, ContainerInfo, SiblingContainer, AppSettings, UpdateInfo, ImageUpdateInfo, FileEntry, FileContents, WebTerminalInfo, SttStatus, GatewayStatus, InstallOptions, ClaudeSession, ContainerCapabilities, ScheduledTask, ScheduledTaskInput, SchedulerNotification, AuthBridgeStatus, BrowserViewStatus, BrowserViewPopoutState, BrowserPageState, PlaywrightDetection, BrowserSetupOutcome, BrowserInstallTarget, ContainerStaleness, MigrationOptions, MigrationReport, MigrationState, ClearTokenOutcome, CaCertInfo } from "./types";
// Docker
export const checkDocker = () => invoke<boolean>("check_docker");
@@ -97,13 +97,6 @@ export const renameContainerPath = (projectId: string, fromPath: string, toPath:
invoke<string>("rename_container_path", { projectId, fromPath, toPath });
export const createContainerDirectory = (projectId: string, parentPath: string, name: string) =>
invoke<string>("create_container_directory", { projectId, parentPath, name });
/**
* Copy a container file into an app-owned host temp directory and return the
* absolute host path. The OS can only drag a file that exists on the host, so
* this is the first half of every drag-out.
*/
export const stageContainerFileForDrag = (projectId: string, path: string) =>
invoke<string>("stage_container_file_for_drag", { projectId, path });
// Updates
export const getAppVersion = () => invoke<string>("get_app_version");
@@ -369,34 +362,3 @@ export const rollbackMigration = (projectId: string) =>
* app crash shows up here as phase "interrupted". */
export const getMigrationState = (projectId: string) =>
invoke<MigrationState | null>("get_migration_state", { projectId });
// Disk
/** Measure where the daemon's bytes have gone.
*
* **Expensive keep it behind an explicit Scan button.** This is
* `GET /system/df`, which walks every image, container and volume on the
* daemon to compute shared-layer sizes, plus an `image_history` per image.
* Seconds on a 100 GB store. Never call it on mount and never poll it. */
export const getDockerDiskUsage = () => invoke<DiskUsageReport>("get_docker_disk_usage");
/** Classify what could be reclaimed, with measured bytes. Takes the report
* from `getDockerDiskUsage` so re-planning costs no second scan. */
export const listReclaimable = (report: DiskUsageReport) =>
invoke<ReclaimPlan>("list_reclaimable", { report });
/** Run the ticked targets. `ReclaimTarget` cannot name a destructive action,
* so no selection built here can delete a live project's data. */
export const reclaim = (targets: ReclaimTarget[]) =>
invoke<ReclaimOutcome>("reclaim", { targets });
/** Delete one object that has no other copy. `confirmation` must be the
* project's name, typed by the user. One target per call, never bulk. */
export const destroyProjectDiskObject = (target: DestructiveTarget, confirmation: string) =>
invoke<ReclaimResult>("destroy_project_disk_object", { target, confirmation });
/** Run the orphaned-snapshot sweep on demand and see its report the same
* sweep that runs at startup and after every recreation, whose result every
* existing caller throws away. */
export const sweepOrphanedSnapshots = () =>
invoke<SnapshotSweepReport>("sweep_orphaned_snapshots");
-242
View File
@@ -853,245 +853,3 @@ export interface MigrationState {
options: MigrationOptions;
plan: MigrationPlan | null;
}
// ---------------------------------------------------------------------------
// Disk
// ---------------------------------------------------------------------------
//
// Mirrors `app/src-tauri/src/docker/disk.rs`. Plain snake_case, like every
// other IPC struct in this app.
/** One row of the per-project disk table. */
export interface ProjectDiskRow {
project_id: string;
project_name: string;
snapshot_image: string;
snapshot_exists: boolean;
/** Total size of the snapshot image, base image included. */
snapshot_bytes: number;
/** Bytes shared with another image — almost always the base. */
snapshot_shared_bytes: number;
/** Layers stacked above the base image: **one per container recreation**.
* This is the number that explains why a snapshot grows but only when
* `base_lineage_known` is true. Otherwise it counts the base's layers too. */
snapshot_commit_layers: number;
/** Whether the base image this snapshot descends from could be identified.
* False is the normal case for a project created before the
* `triple-c.base-image-id` label existed; the layer count must not be
* presented as a recreation count then. */
base_lineage_known: boolean;
/** Bytes those layers account for. `null` when the base image is gone and
* the split cannot be measured never a guess. */
snapshot_above_base_bytes: number | null;
container_exists: boolean;
container_running: boolean;
/** The writable layer, i.e. exactly what the next commit will add. */
container_writable_bytes: number;
home_volume_bytes: number;
home_volume_present: boolean;
config_volume_bytes: number;
config_volume_present: boolean;
/** **The one snapshot figure a row adds up from.** The Snapshot column shows
* this and `total_bytes` is computed from it, so the Total reconciles with
* its parts. It did not before: the total used `snapshot_bytes -
* snapshot_shared_bytes` unconditionally while the column fell back to
* `snapshot_above_base_bytes` or to ``, and in that fallback branch the
* subtraction is the *whole base image* 4.7 GB charged to every row.
*
* Rust computes it in one function (`snapshot_attribution`), in this order:
* a `df()` shared size gives `size - shared`; failing that a known base
* lineage gives the layer arithmetic; failing both it is the full size,
* which is the honest answer for an image nothing shares with.
*
* It is always a number never null. "Unknown" applies to
* `snapshot_above_base_bytes` (the *split*, which really can be
* unmeasurable) and to the layer count, not to this. */
snapshot_attributed_bytes: number;
total_bytes: number;
migrating: boolean;
}
export interface BaseImageRow {
reference: string;
bytes: number;
shared_bytes: number;
containers: number;
is_labelled_base: boolean;
}
/** Where the daemon keeps its bytes, and the Windows/WSL2 caveat if it applies.
* The vhdx copy comes from Rust so the wording cannot drift from the
* constants its tests pin. */
export interface HostStorage {
docker_root_dir: string;
operating_system: string;
is_docker_desktop: boolean;
is_windows_host: boolean;
vhdx_applies: boolean;
/** Empty unless `vhdx_applies`. */
vhdx_note: string;
vhdx_fix: string[];
vhdx_fix_gui: string;
}
export interface BuildCacheUsage {
total_bytes: number;
reclaimable_bytes: number;
/** What a `--filter until=168h` prune would reach. */
stale_bytes: number;
/** `"buildx du"` or `"system df"` `docker system df` under-reports build
* cache, so which one produced the number is worth showing. */
source: string;
cli_error: string | null;
}
/** A per-project volume whose project id is not in Triple-C's project store.
*
* **Not "a volume with no container".** From the daemon's side an idle live
* project and a deleted one look identical volumes present, no container,
* nothing running so only the project store can tell them apart. */
export interface OrphanVolume {
name: string;
project_id: string;
bytes: number;
/** `"home"` or `"config"`. */
role: string;
/** When Docker created it. Evidence a user can recognise a project by; a
* size and a UUID identify nothing. From `df()` metadata volumes are
* never mounted to inspect them, because `docker run -v` *creates* a
* volume that does not exist. */
created_at: string | null;
}
/** The result of one Scan. Expensive to produce — see `getDockerDiskUsage`. */
export interface DiskUsageReport {
scanned_at: string;
projects: ProjectDiskRow[];
base_images: BaseImageRow[];
base_images_bytes: number;
orphan_image_bytes: number;
orphan_image_count: number;
orphan_volumes: OrphanVolume[];
orphan_volume_bytes: number;
/** Why orphan detection was suppressed, when it was. */
orphan_volumes_unavailable: string | null;
build_cache: BuildCacheUsage;
images_total_bytes: number;
containers_total_bytes: number;
volumes_total_bytes: number;
triple_c_total_bytes: number;
host: HostStorage;
}
/** Mirrors Rust `Safety` (serde snake_case). */
export type ReclaimSafety = "safe" | "semi_safe";
/** Mirrors Rust `ReclaimTarget`, an internally tagged enum.
*
* This type **cannot express a destructive action** that is
* `DestructiveTarget`, and the Rust `reclaim` command cannot be handed one.
* The separation is structural on both sides on purpose. */
export type ReclaimTarget =
| { kind: "dangling_snapshots" }
| { kind: "superseded_base_images" }
| { kind: "build_cache"; all: boolean }
| { kind: "migration_pins" }
| { kind: "migration_staging" }
| { kind: "probe_containers" }
| { kind: "scrub_containers" }
| { kind: "compact_snapshot"; project_id: string }
| { kind: "clear_caches"; project_id: string; include_rustup: boolean };
/** Mirrors Rust `DestructiveTarget`, an internally tagged enum (serde
* `tag = "kind"`, snake_case). Every one of these deletes something with no
* other copy, and needs a name typed to confirm the *project's* name for
* every variant except `orphan_volume`, which has no project and takes the
* volume's own name. `DestructiveItem.project_name` carries whichever string
* is the one to type. */
export type DestructiveTarget =
| { kind: "home_volume"; project_id: string }
| { kind: "config_volume"; project_id: string }
| { kind: "snapshot_image"; project_id: string }
| { kind: "rollback_pin"; project_id: string; tag: string }
/** A `triple-c-home-*` / `triple-c-claude-config-*` volume whose project id
* is in no `projects.json` this app can find.
*
* **This was a `ReclaimTarget` at `Safety::Safe`** a tick and a group
* Reclaim button, no confirmation at all. The object behind that tick is a
* `triple-c-claude-config-*` volume holding a Claude OAuth credential,
* every installed plugin and skill, and every conversation transcript that
* project ever had; the *same volume* for a project still in the store
* required typing the project's name. The only difference between the two
* is a lookup against a file this app has been wrong about before a
* second instance's project is absent from an in-memory list, a corrupt
* `projects.json` empties it, a restored data directory empties it too.
*
* `project_id` is parsed out of the volume name and is display only: it
* names no project in the store, which is the entire definition of this
* variant. Rust's `destroy` takes the orphan arm *before* looking a project
* up, and compares the typed string against `name`. */
| { kind: "orphan_volume"; name: string; project_id: string };
export interface ReclaimItem {
target: ReclaimTarget;
safety: ReclaimSafety;
/** Reaches beyond Triple-C's own objects true only for the build cache,
* and the UI must say so. */
daemon_wide: boolean;
label: string;
detail: string;
bytes: number;
/** `false` means `bytes` is a bound, not a measurement. Render it as
* "up to …" only snapshot compaction sets this. */
bytes_are_exact: boolean;
bytes_floor: number | null;
/** Why this cannot run right now. */
blocked: string | null;
}
export interface DestructiveItem {
target: DestructiveTarget;
project_id: string;
project_name: string;
label: string;
/** Spelled out in full — this is the confirmation copy. */
loses: string;
bytes: number;
blocked: string | null;
}
export interface ReclaimPlan {
items: ReclaimItem[];
/** Display only. `reclaim` cannot act on these. */
destructive: DestructiveItem[];
store_error: string | null;
}
export interface ReclaimResult {
/** The reclaim target this reports on, or `null` when it reports a destroy.
* Exactly one of `target` / `destroyed` is ever set a destroy used to come
* back wearing a `ReclaimTarget` that named work it had not done. */
target: ReclaimTarget | null;
destroyed: DestructiveTarget | null;
ok: boolean;
freed_bytes: number;
/** What was projected beforehand, for the one action that projects. */
projected_bytes: number | null;
message: string;
}
export interface ReclaimOutcome {
results: ReclaimResult[];
total_freed_bytes: number;
}
/** Mirrors Rust `SnapshotSweepReport`. Note `failed` is a list of
* `[reference, error]` pairs a Rust tuple serialises as an array. */
export interface SnapshotSweepReport {
removed: string[];
reclaimed_bytes: number;
/** Refused because a container is still built from them. Normal. */
in_use: number;
failed: [string, string][];
unavailable: string | null;
}