diff --git a/Cargo.lock b/Cargo.lock index 5eeb81c..93c26ff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -499,18 +499,6 @@ version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" -[[package]] -name = "base64" -version = "0.21.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bit-set" version = "0.6.0" @@ -883,12 +871,6 @@ dependencies = [ "unicode-width 0.1.14", ] -[[package]] -name = "color_quant" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" - [[package]] name = "colorchoice" version = "1.0.5" @@ -1159,12 +1141,6 @@ dependencies = [ "syn 3.0.4", ] -[[package]] -name = "data-url" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be1e0bca6c3637f992fc1cc7cbc52a78c1ef6db076dbf1059c4323d6a2048376" - [[package]] name = "digest" version = "0.10.7" @@ -1338,12 +1314,9 @@ checksum = "bf3c1f5cd8dfe2ade470a218696c66cf556fcfd701e7830fa2e9f4428292a2a1" dependencies = [ "ahash", "egui", - "ehttp", "enum-map", - "image", "log", "mime_guess2", - "resvg", ] [[package]] @@ -1363,20 +1336,6 @@ dependencies = [ "winit", ] -[[package]] -name = "ehttp" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59a81c221a1e4dad06cb9c9deb19aea1193a5eea084e8cd42d869068132bf876" -dependencies = [ - "document-features", - "js-sys", - "ureq", - "wasm-bindgen", - "wasm-bindgen-futures", - "web-sys", -] - [[package]] name = "either" version = "1.18.0" @@ -1536,12 +1495,6 @@ dependencies = [ "miniz_oxide", ] -[[package]] -name = "float-cmp" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "98de4bbd547a563b716d8dfa9aad1cb19bfab00f4fa09a6a4ed21dbcf44ce9c4" - [[package]] name = "fnv" version = "1.0.7" @@ -1717,16 +1670,6 @@ dependencies = [ "r-efi 6.0.0", ] -[[package]] -name = "gif" -version = "0.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" -dependencies = [ - "color_quant", - "weezl", -] - [[package]] name = "gl_generator" version = "0.14.0" @@ -2078,19 +2021,11 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", - "color_quant", - "gif", "moxcms", "num-traits", - "png 0.18.1", + "png", ] -[[package]] -name = "imagesize" -version = "0.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "029d73f573d8e8d63e6d5020011d3255b28c3ba85d6cf870a07184ed23de9284" - [[package]] name = "immutable-chunkmap" version = "2.1.3" @@ -2259,15 +2194,6 @@ version = "3.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" -[[package]] -name = "kurbo" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd85a5776cd9500c2e2059c8c76c3b01528566b7fcbaf8098b55a33fc298849b" -dependencies = [ - "arrayvec", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2973,16 +2899,10 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" dependencies = [ - "siphasher 1.0.3", + "siphasher", "unicase", ] -[[package]] -name = "pico-args" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5be167a7af36ee22fe3115051bc51f6e6c7054c9348e28deb4f49bd6f705a315" - [[package]] name = "pin-project" version = "1.1.13" @@ -3032,19 +2952,6 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" -[[package]] -name = "png" -version = "0.17.16" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" -dependencies = [ - "bitflags 1.3.2", - "crc32fast", - "fdeflate", - "flate2", - "miniz_oxide", -] - [[package]] name = "png" version = "0.18.1" @@ -3313,12 +3220,6 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "rctree" -version = "0.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b42e27ef78c35d3998403c1d26f3efd9e135d3e5121b0a4845cc5cc27547f4f" - [[package]] name = "redox_syscall" version = "0.4.1" @@ -3358,20 +3259,6 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" -[[package]] -name = "resvg" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cadccb3d99a9efb8e5e00c16fbb732cbe400db2ec7fc004697ee7d97d86cf1f4" -dependencies = [ - "log", - "pico-args", - "rgb", - "svgtypes", - "tiny-skia", - "usvg", -] - [[package]] name = "rfd" version = "0.15.4" @@ -3396,41 +3283,12 @@ dependencies = [ "windows-sys 0.59.0", ] -[[package]] -name = "rgb" -version = "0.8.53" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b34b781b31e5d73e9fbc8689c70551fd1ade9a19e3e28cfec8580a79290cc4" -dependencies = [ - "bytemuck", -] - -[[package]] -name = "ring" -version = "0.17.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" -dependencies = [ - "cc", - "cfg-if", - "getrandom 0.2.17", - "libc", - "untrusted", - "windows-sys 0.52.0", -] - [[package]] name = "roff" version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "323c417e1d9665a65b263ec744ba09030cfb277e9daa0b018a4ab62e57bc8189" -[[package]] -name = "roxmltree" -version = "0.19.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3cd14fd5e3b777a7422cca79358c57a8f6e3a703d9ac187448d0daf220c2407f" - [[package]] name = "rustc-hash" version = "1.1.0" @@ -3478,41 +3336,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "rustls" -version = "0.23.43" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" -dependencies = [ - "log", - "once_cell", - "ring", - "rustls-pki-types", - "rustls-webpki", - "subtle", - "zeroize", -] - -[[package]] -name = "rustls-pki-types" -version = "1.15.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" -dependencies = [ - "zeroize", -] - -[[package]] -name = "rustls-webpki" -version = "0.103.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" -dependencies = [ - "ring", - "rustls-pki-types", - "untrusted", -] - [[package]] name = "rustversion" version = "1.0.23" @@ -3710,21 +3533,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" -[[package]] -name = "simplecss" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a9c6883ca9c3c7c90e888de77b7a5c849c779d25d74a1269b0218b14e8b136c" -dependencies = [ - "log", -] - -[[package]] -name = "siphasher" -version = "0.3.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" - [[package]] name = "siphasher" version = "1.0.3" @@ -3850,9 +3658,6 @@ name = "strict-num" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731" -dependencies = [ - "float-cmp", -] [[package]] name = "strsim" @@ -3882,22 +3687,6 @@ dependencies = [ "syn 2.0.119", ] -[[package]] -name = "subtle" -version = "2.6.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" - -[[package]] -name = "svgtypes" -version = "0.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e44e288cd960318917cbd540340968b90becc8bc81f171345d706e7a89d9d70" -dependencies = [ - "kurbo", - "siphasher 0.3.11", -] - [[package]] name = "syn" version = "1.0.109" @@ -4015,7 +3804,6 @@ dependencies = [ "bytemuck", "cfg-if", "log", - "png 0.17.16", "tiny-skia-path", ] @@ -4246,28 +4034,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "untrusted" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" - -[[package]] -name = "ureq" -version = "2.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02d1a66277ed75f640d608235660df48c8e3c19f3b4edb6a263315626cc3c01d" -dependencies = [ - "base64 0.22.1", - "flate2", - "log", - "once_cell", - "rustls", - "rustls-pki-types", - "url", - "webpki-roots 0.26.11", -] - [[package]] name = "url" version = "2.5.8" @@ -4287,50 +4053,6 @@ version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "daf8dba3b7eb870caf1ddeed7bc9d2a049f3cfdfae7cb521b087cc33ae4c49da" -[[package]] -name = "usvg" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38b0a51b72ab80ca511d126b77feeeb4fb1e972764653e61feac30adc161a756" -dependencies = [ - "base64 0.21.7", - "log", - "pico-args", - "usvg-parser", - "usvg-tree", - "xmlwriter", -] - -[[package]] -name = "usvg-parser" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bd4e3c291f45d152929a31f0f6c819245e2921bfd01e7bd91201a9af39a2bdc" -dependencies = [ - "data-url", - "flate2", - "imagesize", - "kurbo", - "log", - "roxmltree", - "simplecss", - "siphasher 0.3.11", - "svgtypes", - "usvg-tree", -] - -[[package]] -name = "usvg-tree" -version = "0.37.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee3d202ebdb97a6215604b8f5b4d6ef9024efd623cf2e373a6416ba976ec7d3" -dependencies = [ - "rctree", - "strict-num", - "svgtypes", - "tiny-skia-path", -] - [[package]] name = "utf8_iter" version = "1.0.4" @@ -4620,30 +4342,6 @@ dependencies = [ "web-sys", ] -[[package]] -name = "webpki-roots" -version = "0.26.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" -dependencies = [ - "webpki-roots 1.0.9", -] - -[[package]] -name = "webpki-roots" -version = "1.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" -dependencies = [ - "rustls-pki-types", -] - -[[package]] -name = "weezl" -version = "0.1.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" - [[package]] name = "wgpu" version = "22.1.0" @@ -5297,12 +4995,6 @@ version = "0.8.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e450f9b2ed1dff33c94c12589a87338689467b9c4f5d8a5710bd09a847d2c8a7" -[[package]] -name = "xmlwriter" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec7a2a501ed189703dba8b08142f057e887dfc4b2cc4db2d343ac6376ba3e0b9" - [[package]] name = "yoke" version = "0.8.3" @@ -5536,12 +5228,6 @@ dependencies = [ "synstructure", ] -[[package]] -name = "zeroize" -version = "1.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" - [[package]] name = "zerotrie" version = "0.2.5" diff --git a/crates/chystik-cli/src/tui.rs b/crates/chystik-cli/src/tui.rs index 625ccc2..aa732f3 100644 --- a/crates/chystik-cli/src/tui.rs +++ b/crates/chystik-cli/src/tui.rs @@ -252,6 +252,7 @@ impl ScanView { note: "fixture".into(), advice: None, provenance: None, + version_group: None, }; Self { root: Some("/home/demo".into()), diff --git a/crates/chystik-cli/tests/cli.rs b/crates/chystik-cli/tests/cli.rs index 09c7820..1089168 100644 --- a/crates/chystik-cli/tests/cli.rs +++ b/crates/chystik-cli/tests/cli.rs @@ -64,7 +64,13 @@ fn scan_json_writes_a_versioned_document_and_no_diagnostics_on_success() { "generated_at must be an RFC 3339 UTC timestamp: {document}" ); assert_eq!(document["findings"], serde_json::json!([])); - assert!(document["roots"][0].as_str().unwrap().starts_with('/')); + // Roots are absolute; a leading `/` is Unix-only, so assert absoluteness + // portably rather than by prefix (Windows roots look like `C:\…`). + let root0 = document["roots"][0].as_str().unwrap(); + assert!( + std::path::Path::new(root0).is_absolute(), + "scan roots must be absolute: {root0}" + ); } #[test] @@ -234,7 +240,7 @@ fn catalog_finding_exposes_policy_and_evidence_in_json_and_verbose_output() { .unwrap() .contains("pip.pypa.io")); assert!(pip["provenance"]["recovery_cost"].as_str().is_some()); - assert_eq!(pip["provenance"]["reviewed_at"], "2026-08-26"); + assert_eq!(pip["provenance"]["reviewed_at"], "2026-08-30"); assert!(pip["provenance"]["preconditions"] .as_array() .is_some_and(|preconditions| !preconditions.is_empty())); @@ -258,7 +264,7 @@ fn catalog_finding_exposes_policy_and_evidence_in_json_and_verbose_output() { assert!(stdout.contains("rule: python.pip.cache")); assert!(stdout.contains("recovery:")); assert!(stdout.contains("source: https://pip.pypa.io/")); - assert!(stdout.contains("reviewed: 2026-08-26")); + assert!(stdout.contains("reviewed: 2026-08-30")); assert!(stdout.contains("requires:")); let mut preview = chystik(); diff --git a/crates/chystik-core/rules/catalog/amd.toml b/crates/chystik-core/rules/catalog/amd.toml index d03d54e..ea9008b 100644 --- a/crates/chystik-core/rules/catalog/amd.toml +++ b/crates/chystik-core/rules/catalog/amd.toml @@ -6,7 +6,7 @@ recovery_note = "re-download the AMD installer if it is needed for repair" cleanup_policy = "review-required" note = "AMD extracted installer — confirm no install or repair is in progress" source_url = "https://rocm.docs.amd.com/projects/install-on-windows/en/latest/how-to/install.html" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is under the exact vendor staging root", "the documented installer layout markers are present", @@ -28,7 +28,7 @@ recovery_note = "games rebuild shaders on their next start" cleanup_policy = "tool-managed" note = "AMD shader cache — reset it through AMD Software: Adrenalin" source_url = "https://www.amd.com/en/resources/support-articles/faqs/dh-012.html" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "Chystik must not move this path to Trash", "use the owning operating-system or vendor command instead", diff --git a/crates/chystik-core/rules/catalog/android-studio.toml b/crates/chystik-core/rules/catalog/android-studio.toml new file mode 100644 index 0000000..a4d273c --- /dev/null +++ b/crates/chystik-core/rules/catalog/android-studio.toml @@ -0,0 +1,30 @@ +# Android Studio is a JetBrains-platform IDE vendored by Google. Its +# regenerable "system" directory (caches, indexes, logs) sits under +# %LOCALAPPDATA%\Google\AndroidStudio, while settings live in the +# separate roaming config directory. See: +# https://developer.android.com/studio/intro/studio-config + +[[rule]] +id = "google.android-studio-system-dir" +category = "ide-toolchains" +recovery = "rebuild-redownload" +recovery_note = "Android Studio rebuilds its caches and reindexes projects on the next launch; local history and per-project workspace state in this directory are lost" +cleanup_policy = "review-required" +note = "Android Studio system directory for one version — caches and indexes rebuild on the next launch, but local history and per-project workspace state here are lost" +source_url = "https://developer.android.com/studio/intro/studio-config" +reviewed_at = "2026-08-30" +preconditions = [ + "the directory is an Android Studio version system directory in the local Google vendor folder", + "settings and plugins live in the separate roaming config directory and are never touched", +] + +# Match each AndroidStudio directory by its regenerable caches folder. +# Chrome, the other common tenant of the local Google folder, keeps its cache +# under a nested profile path and has no top-level caches folder, so this +# marker leaves it alone. +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Google" +matcher = "direct-child-with-markers" +required_dirs = ["caches"] diff --git a/crates/chystik-core/rules/catalog/apple.toml b/crates/chystik-core/rules/catalog/apple.toml index a6e5663..e98c28a 100644 --- a/crates/chystik-core/rules/catalog/apple.toml +++ b/crates/chystik-core/rules/catalog/apple.toml @@ -6,7 +6,7 @@ recovery_note = "pods are downloaded again on the next install" cleanup_policy = "auto-cleanable" note = "CocoaPods download cache — restored by the next pod install" source_url = "https://guides.cocoapods.org/using/faq.html" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", @@ -25,7 +25,7 @@ recovery_note = "the selected Xcode projects rebuild indexes and products" cleanup_policy = "review-required" note = "Xcode DerivedData — close Xcode; the selected project's build data is rebuilt" source_url = "https://developer.apple.com/documentation/Xcode-Release-Notes/xcode-26-release-notes" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact Xcode developer-data child", "Xcode should be closed before manual cleanup", @@ -44,7 +44,7 @@ recovery_note = "connect a device again to download its support files" cleanup_policy = "review-required" note = "Xcode iOS DeviceSupport — device symbols download again when needed" source_url = "https://developer.apple.com/forums/thread/683496" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact Xcode developer-data child", "Xcode should be closed before manual cleanup", @@ -63,7 +63,7 @@ recovery_note = "unavailable simulator devices are removed; available runtimes s cleanup_policy = "tool-managed" note = "Unavailable iOS simulators — let simctl remove only devices without a runtime" source_url = "https://developer.apple.com/forums/thread/835883" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "Chystik must not move this path to Trash", "use the owning operating-system or vendor command instead", diff --git a/crates/chystik-core/rules/catalog/chromium.toml b/crates/chystik-core/rules/catalog/chromium.toml new file mode 100644 index 0000000..bd82f43 --- /dev/null +++ b/crates/chystik-core/rules/catalog/chromium.toml @@ -0,0 +1,57 @@ +# Chromium-based browsers store regenerable disk caches (Cache, Code Cache, +# GPUCache) inside the profile directory under the user-data dir. History, +# cookies and site data are separate files and are never targeted. See: +# https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md + +[[rule]] +id = "google.chrome-cache" +category = "browser-system" +recovery = "automatic" +recovery_note = "Chrome refetches cached web resources and recompiles shaders as you browse" +cleanup_policy = "auto-cleanable" +note = "Google Chrome HTTP, code and GPU caches (Default profile) — refilled as you browse" +source_url = "https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a Chromium cache subdirectory of the Default profile", + "browsing history, cookies and site data live in separate files and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Google/Chrome/User Data/Default/Cache" + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Google/Chrome/User Data/Default/Code Cache" + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Google/Chrome/User Data/Default/GPUCache" + +[[rule]] +id = "perplexity.comet-cache" +category = "browser-system" +recovery = "automatic" +recovery_note = "Comet refetches cached web resources and recompiles shaders as you browse" +cleanup_policy = "auto-cleanable" +note = "Perplexity Comet browser HTTP and code caches (Default profile) — refilled as you browse" +source_url = "https://chromium.googlesource.com/chromium/src/+/HEAD/docs/user_data_dir.md" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a Chromium cache subdirectory of the Comet Default profile", + "browsing history, cookies and site data live in separate files and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Perplexity/Comet/User Data/Default/Cache" + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Perplexity/Comet/User Data/Default/Code Cache" diff --git a/crates/chystik-core/rules/catalog/cpp.toml b/crates/chystik-core/rules/catalog/cpp.toml index 33dd1a5..95fe349 100644 --- a/crates/chystik-core/rules/catalog/cpp.toml +++ b/crates/chystik-core/rules/catalog/cpp.toml @@ -6,7 +6,7 @@ recovery_note = "the next C/C++ build recompiles cache misses" cleanup_policy = "auto-cleanable" note = "ccache compiler outputs — rebuilt while C/C++ projects compile" source_url = "https://ccache.dev/manual/latest.html" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", @@ -45,7 +45,7 @@ recovery_note = "the next compiler run repopulates local cache entries" cleanup_policy = "auto-cleanable" note = "sccache compiler outputs — rebuilt while projects compile" source_url = "https://android.googlesource.com/toolchain/sccache/+/HEAD/docs/Local.md" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", @@ -79,7 +79,7 @@ recovery_note = "the next vcpkg install rebuilds or downloads archives" cleanup_policy = "auto-cleanable" note = "vcpkg binary archives — rebuilt or downloaded by the next install" source_url = "https://learn.microsoft.com/en-us/vcpkg/users/binarycaching" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", @@ -113,7 +113,7 @@ recovery_note = "Conan downloads or rebuilds selected packages" cleanup_policy = "tool-managed" note = "Conan package storage — clean it through Conan so package references stay valid" source_url = "https://docs.conan.io/2/reference/commands/cache.html" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "Chystik must not move this path to Trash", "use the owning operating-system or vendor command instead", diff --git a/crates/chystik-core/rules/catalog/dart.toml b/crates/chystik-core/rules/catalog/dart.toml new file mode 100644 index 0000000..1cc6647 --- /dev/null +++ b/crates/chystik-core/rules/catalog/dart.toml @@ -0,0 +1,79 @@ +# Dart/Flutter pub package cache. Hosted packages are re-downloaded on the +# next resolve, and globally activated packages are re-activated on demand. +# Location and the PUB_CACHE override are documented at: +# https://dart.dev/tools/pub/environment-variables + +[[rule]] +id = "dart.pub-cache" +category = "package-caches" +recovery = "rebuild-redownload" +recovery_note = "pub re-downloads hosted packages and re-activates global packages on the next pub get or run" +cleanup_policy = "review-required" +note = "Dart/Flutter pub package cache — re-downloaded by the next pub get" +source_url = "https://dart.dev/tools/pub/environment-variables" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the documented pub system cache root", + "the path stays inside a Chystik-owned user root", +] + +# Dart 3+ default on Windows. +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Pub/Cache" + +# Pre-Dart-3 fallback on Windows. +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Pub/Cache" + +[[rule.locator]] +platform = "linux" +root = "home" +path = ".pub-cache" + +[[rule.locator]] +platform = "macos" +root = "home" +path = ".pub-cache" + +[[rule.locator]] +platform = "all" +root = "environment" +environment = "PUB_CACHE" + +# Dart Analysis Server cache/instrumentation state. The analysis server +# recreates it on the next launch; clearing it is the documented remedy for a +# stale analyzer. dart.dev names the Windows location as the server's cache dir: +# https://dart.dev/tools/analyzer-performance +[[rule]] +id = "dart.analysis-server-cache" +category = "ide-toolchains" +recovery = "automatic" +recovery_note = "the Dart Analysis Server rebuilds its driver cache and instrumentation state on the next launch" +cleanup_policy = "auto-cleanable" +note = "Dart Analysis Server cache (.dartServer) — rebuilt on the next analyzer launch" +source_url = "https://dart.dev/tools/analyzer-performance" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the documented .dartServer analysis-server cache directory", + "the path stays inside a Chystik-owned user root", +] + +# Dart 3+ on Windows keeps .dartServer under %LOCALAPPDATA%. +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = ".dartServer" + +[[rule.locator]] +platform = "linux" +root = "home" +path = ".dartServer" + +[[rule.locator]] +platform = "macos" +root = "home" +path = ".dartServer" diff --git a/crates/chystik-core/rules/catalog/electron-apps.toml b/crates/chystik-core/rules/catalog/electron-apps.toml new file mode 100644 index 0000000..5785c16 --- /dev/null +++ b/crates/chystik-core/rules/catalog/electron-apps.toml @@ -0,0 +1,77 @@ +# Electron apps embed Chromium and write its regenerable disk caches (Cache, +# Code Cache, GPUCache) under the app's user-data directory. Account, message +# and document data live in separate stores and are never targeted. Chromium's +# cache subdirectories under userData are documented at: +# https://www.electronjs.org/docs/latest/api/app + +[[rule]] +id = "discord.cache" +category = "messengers" +recovery = "automatic" +recovery_note = "Discord refetches cached assets and recompiles code caches as you use it" +cleanup_policy = "auto-cleanable" +note = "Discord (Electron) HTTP, code and GPU caches — refilled as you use the app" +source_url = "https://www.electronjs.org/docs/latest/api/app" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a Chromium cache subdirectory of the Discord user-data folder", + "account, message and settings data live in separate stores and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "discord/Cache" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "discord/Code Cache" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "discord/GPUCache" + +[[rule]] +id = "notion.cache" +category = "office-docs" +recovery = "automatic" +recovery_note = "Notion refetches cached assets and recompiles code caches as you use it" +cleanup_policy = "auto-cleanable" +note = "Notion (Electron) HTTP and code caches — refilled as you use the app" +source_url = "https://www.electronjs.org/docs/latest/api/app" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a Chromium cache subdirectory of the Notion user-data folder", + "documents and offline data live in separate stores and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Notion/Partitions/notion/Cache" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Notion/Partitions/notion/Code Cache" + +[[rule]] +id = "antigravity.cache" +category = "ide-toolchains" +recovery = "automatic" +recovery_note = "Antigravity recompiles cached bytecode on the next launch" +cleanup_policy = "auto-cleanable" +note = "Antigravity (Electron IDE) compiled-bytecode cache — rebuilt on the next launch" +source_url = "https://www.electronjs.org/docs/latest/api/app" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a Chromium cache subdirectory of the Antigravity user-data folder", + "settings, extensions and workspace state live elsewhere and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Antigravity/CachedData" diff --git a/crates/chystik-core/rules/catalog/game-launchers.toml b/crates/chystik-core/rules/catalog/game-launchers.toml new file mode 100644 index 0000000..f5fc2d7 --- /dev/null +++ b/crates/chystik-core/rules/catalog/game-launchers.toml @@ -0,0 +1,41 @@ +# Game-launcher web-view caches. These hold the store/library web UI cache, +# rebuilt when the launcher next loads those pages. Installed games and saves +# live elsewhere and are never targeted. + +[[rule]] +id = "epic.webcache" +category = "game-launchers" +recovery = "rebuild-redownload" +recovery_note = "the launcher rebuilds its web-view cache the next time it loads the store" +cleanup_policy = "review-required" +note = "Epic Games Launcher web-view cache — rebuilt when the store next loads" +source_url = "https://www.epicgames.com/help/c-202300000001639/c-202300000001735/how-do-i-clear-the-launcher-cache-on-pc-a202300000013316" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the documented launcher web cache", + "installed games and saves live elsewhere and are not affected", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "EpicGamesLauncher/Saved/webcache" + +[[rule]] +id = "valve.steam-htmlcache" +category = "game-launchers" +recovery = "rebuild-redownload" +recovery_note = "Steam rebuilds its in-client web cache the next time you open the store or library pages" +cleanup_policy = "review-required" +note = "Steam in-client web (CEF) cache — rebuilt when store or library pages next load" +source_url = "https://help.steampowered.com/en/faqs/view/29A6-F529-F956-6292" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the Steam client web cache directory", + "installed games and saves live elsewhere and are not affected", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Steam/htmlcache" diff --git a/crates/chystik-core/rules/catalog/huggingface.toml b/crates/chystik-core/rules/catalog/huggingface.toml new file mode 100644 index 0000000..ce5dd1d --- /dev/null +++ b/crates/chystik-core/rules/catalog/huggingface.toml @@ -0,0 +1,36 @@ +# Hugging Face Hub cache. Models, datasets, spaces and the Xet chunk store are +# re-downloaded on demand; the whole tree is documented as re-downloadable cache +# (it even ships a CACHEDIR.TAG marking it as backup-excludable). Default +# location and behaviour documented at: +# https://huggingface.co/docs/huggingface_hub/en/guides/manage-cache + +[[rule]] +id = "huggingface.hub-cache" +category = "ai-models" +recovery = "rebuild-redownload" +recovery_note = "the Hugging Face Hub re-downloads models, datasets and chunks on the next load; large models can be many gigabytes to refetch" +cleanup_policy = "review-required" +note = "Hugging Face Hub cache — re-downloaded on the next model or dataset load" +source_url = "https://huggingface.co/docs/huggingface_hub/en/guides/manage-cache" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the documented ~/.cache/huggingface cache root", + "the path stays inside a Chystik-owned user root", +] + +# Default on every platform is ~/.cache/huggingface (HF_HOME/HF_HUB_CACHE can +# override it, but only the documented default is claimed here). +[[rule.locator]] +platform = "windows" +root = "home" +path = ".cache/huggingface" + +[[rule.locator]] +platform = "linux" +root = "home" +path = ".cache/huggingface" + +[[rule.locator]] +platform = "macos" +root = "home" +path = ".cache/huggingface" diff --git a/crates/chystik-core/rules/catalog/jetbrains.toml b/crates/chystik-core/rules/catalog/jetbrains.toml new file mode 100644 index 0000000..721e278 --- /dev/null +++ b/crates/chystik-core/rules/catalog/jetbrains.toml @@ -0,0 +1,48 @@ +# JetBrains IDE caches on Windows. The IDE keeps its regenerable "system" +# directory (caches, indexes, logs) under %LOCALAPPDATA%, while settings and +# plugins live in the separate roaming config directory that Chystik never +# touches. See: +# https://www.jetbrains.com/help/idea/directories-used-by-the-ide-to-store-settings-caches-plugins-and-logs.html + +[[rule]] +id = "jetbrains.ide-system-dir" +category = "ide-toolchains" +recovery = "rebuild-redownload" +recovery_note = "the IDE rebuilds its caches and reindexes projects on the next launch; local history and per-project workspace state in this directory are lost" +cleanup_policy = "review-required" +note = "JetBrains IDE system directory for one product version — caches and indexes rebuild on the next launch, but local history and per-project workspace state here are lost" +source_url = "https://www.jetbrains.com/help/idea/directories-used-by-the-ide-to-store-settings-caches-plugins-and-logs.html" +reviewed_at = "2026-08-30" +preconditions = [ + "the directory is a JetBrains product-version system directory in the local vendor folder", + "settings and plugins live in the separate roaming config directory and are never touched", +] + +# Match each product-version directory (IntelliJIdea2024.1, WebStorm2025.3, …) +# by its regenerable caches folder. The Toolbox app's own folder uses a +# singular "cache" directory, so this marker leaves it — and Fleet — alone. +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "JetBrains" +matcher = "direct-child-with-markers" +required_dirs = ["caches"] + +[[rule]] +id = "jetbrains.toolbox-cache" +category = "ide-toolchains" +recovery = "rebuild-redownload" +recovery_note = "Toolbox re-downloads installers and packages into the cache the next time you install or update a tool" +cleanup_policy = "review-required" +note = "JetBrains Toolbox download cache — re-downloaded on the next install or update" +source_url = "https://www.jetbrains.com/help/toolbox-app/frequently-asked-questions.html" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the exact Toolbox download cache root", + "installed IDEs live elsewhere and are not affected", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "JetBrains/Toolbox/cache" diff --git a/crates/chystik-core/rules/catalog/nvidia.toml b/crates/chystik-core/rules/catalog/nvidia.toml index 1c1f3e6..133a546 100644 --- a/crates/chystik-core/rules/catalog/nvidia.toml +++ b/crates/chystik-core/rules/catalog/nvidia.toml @@ -6,7 +6,7 @@ recovery_note = "the next OptiX workload recompiles kernels" cleanup_policy = "auto-cleanable" note = "NVIDIA OptiX compilation cache — recreated by the next OptiX run" source_url = "https://raytracing-docs.nvidia.com/optix9/api/OptiX_API_Reference.pdf" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", @@ -25,7 +25,7 @@ recovery_note = "the next OptiX workload recompiles kernels" cleanup_policy = "review-required" note = "NVIDIA OptiX compilation cache — recreated by the next OptiX run" source_url = "https://raytracing-docs.nvidia.com/optix9/api/OptiX_API_Reference.pdf" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the environment override names an exact tool-cache root", "the resolved path remains inside a Chystik-owned user root", @@ -44,7 +44,7 @@ recovery_note = "re-download the NVIDIA driver package if setup is needed again" cleanup_policy = "review-required" note = "NVIDIA extracted driver installer — close setup before moving it to Trash" source_url = "https://nvidia.custhelp.com/app/answers/detail/a_id/2985/" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is under the exact vendor staging root", "the documented installer layout markers are present", diff --git a/crates/chystik-core/rules/catalog/python.toml b/crates/chystik-core/rules/catalog/python.toml index 6b4be53..be78d4a 100644 --- a/crates/chystik-core/rules/catalog/python.toml +++ b/crates/chystik-core/rules/catalog/python.toml @@ -6,7 +6,7 @@ recovery_note = "the next package install re-downloads cached artifacts" cleanup_policy = "auto-cleanable" note = "pip download and wheel cache — refetched by the next install" source_url = "https://pip.pypa.io/en/stable/topics/caching/" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "the path is the exact documented cache root", "the path remains inside a Chystik-owned user root", diff --git a/crates/chystik-core/rules/catalog/vscode.toml b/crates/chystik-core/rules/catalog/vscode.toml new file mode 100644 index 0000000..7c965e0 --- /dev/null +++ b/crates/chystik-core/rules/catalog/vscode.toml @@ -0,0 +1,38 @@ +# Visual Studio Code (Electron) keeps its regenerable caches under the roaming +# user-data folder %APPDATA%\Code, while settings, extensions and workspace +# state live elsewhere. Data location documented at: +# https://code.visualstudio.com/docs/setup/uninstall + +[[rule]] +id = "microsoft.vscode-cache" +category = "ide-toolchains" +recovery = "automatic" +recovery_note = "VS Code recompiles cached bytecode and refetches cached resources on the next launch" +cleanup_policy = "auto-cleanable" +note = "Visual Studio Code compiled-bytecode and web caches — rebuilt on the next launch" +source_url = "https://code.visualstudio.com/docs/setup/uninstall" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is a VS Code cache subdirectory of the roaming user-data folder", + "settings, extensions and workspace state live elsewhere and are not touched", +] + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Code/CachedData" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Code/Cache" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Code/Code Cache" + +[[rule.locator]] +platform = "windows" +root = "roaming-app-data" +path = "Code/GPUCache" diff --git a/crates/chystik-core/rules/catalog/windows.toml b/crates/chystik-core/rules/catalog/windows.toml index 2b514fc..87d4b48 100644 --- a/crates/chystik-core/rules/catalog/windows.toml +++ b/crates/chystik-core/rules/catalog/windows.toml @@ -6,7 +6,7 @@ recovery_note = "games and graphics applications rebuild shaders on first run" cleanup_policy = "advisory-only" note = "Windows DirectX shader cache — clear it through Temporary files, not raw deletion" source_url = "https://support.microsoft.com/en-us/windows/free-up-drive-space-in-windows-85529ccb-c365-4c84-8d63-4d518db795dc" -reviewed_at = "2026-08-26" +reviewed_at = "2026-08-30" preconditions = [ "Chystik must not move this path to Trash", "use the owning operating-system or vendor command instead", @@ -17,3 +17,42 @@ advice = "Open Settings → System → Storage → Temporary files → DirectX S platform = "windows" root = "local-app-data" path = "D3DSCache" + +[[rule]] +id = "windows.crash-dumps" +category = "system-junk" +recovery = "automatic" +recovery_note = "Windows Error Reporting writes a new dump only when an application next crashes" +cleanup_policy = "review-required" +note = "Windows Error Reporting user-mode crash dumps — diagnostic files re-created only on the next crash" +source_url = "https://learn.microsoft.com/en-us/windows/win32/wer/collecting-user-mode-dumps" +reviewed_at = "2026-08-30" +preconditions = [ + "the path is the documented WER LocalDumps folder", + "the dumps are diagnostic output, not application data", +] + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "CrashDumps" + +[[rule]] +id = "windows.temp" +category = "system-junk" +recovery = "automatic" +recovery_note = "applications recreate the temporary files they still need on demand" +cleanup_policy = "advisory-only" +note = "Windows per-user temp directory — clear it through Storage Sense, not raw deletion, because running apps may hold open files here" +source_url = "https://support.microsoft.com/en-us/windows/free-up-drive-space-in-windows-85529ccb-c365-4c84-8d63-4d518db795dc" +reviewed_at = "2026-08-30" +preconditions = [ + "Chystik must not move this path to Trash", + "running applications may hold open handles in this directory", +] +advice = "Open Settings → System → Storage → Temporary files, or enable Storage Sense" + +[[rule.locator]] +platform = "windows" +root = "local-app-data" +path = "Temp" diff --git a/crates/chystik-core/src/advisories.rs b/crates/chystik-core/src/advisories.rs index 04a7e52..350f992 100644 --- a/crates/chystik-core/src/advisories.rs +++ b/crates/chystik-core/src/advisories.rs @@ -47,7 +47,7 @@ const ADVISORIES: &[Advisory] = &[ category: Category::Installers, severity: Severity::Moderate, note: "Flatpak object store, including runtimes nothing uses any more", - command: "flatpak uninstall --unused && flatpak repair", + command: "sudo flatpak uninstall --unused --system && sudo flatpak repair --system", min_bytes: 512 * MIB, }, Advisory { @@ -132,6 +132,7 @@ fn probe_one(advisory: &Advisory) -> Option { note: advisory.note.to_owned(), advice: Some(advisory.command.to_owned()), provenance: None, + version_group: None, }) } diff --git a/crates/chystik-core/src/cleaner.rs b/crates/chystik-core/src/cleaner.rs index 197251f..179bbf2 100644 --- a/crates/chystik-core/src/cleaner.rs +++ b/crates/chystik-core/src/cleaner.rs @@ -151,72 +151,139 @@ impl CleanupOutcome { } } +/// Progress from a cleanup run: one `Started` per item, then exactly one +/// terminal event for it, emitted in the order the items were given. +/// +/// A batch can be gigabytes and a single trash move can take seconds, so a +/// caller with a window to keep alive runs [`clean_streaming`] on a worker +/// thread and paints from these instead of freezing until the whole batch +/// is done. +#[derive(Debug, Clone, PartialEq)] +pub enum CleanEvent { + /// About to validate and move `path`; `index` is its 0-based position + /// in the batch. + Started { index: usize, path: PathBuf }, + /// It reached the trash, freeing `size_bytes`. + Removed { + index: usize, + path: PathBuf, + size_bytes: u64, + }, + /// It was left where it is, for `reason`. + Skipped { + index: usize, + path: PathBuf, + reason: SkipReason, + }, +} + /// Run the flow over `items`. Never panics and never stops early: one bad /// item must not prevent the rest from being cleaned. pub fn clean(items: &[CleanupItem], remover: &dyn Remover) -> CleanupOutcome { - clean_with_support(items, remover, platform::current().cleanup_support()) + clean_streaming(items, remover, |_| {}) +} + +/// [`clean`], reporting each item as it is reached. The outcome is +/// identical; `on_event` runs on the calling thread between items, so it +/// must not block — sending down a channel is the intended use. +pub fn clean_streaming( + items: &[CleanupItem], + remover: &dyn Remover, + mut on_event: impl FnMut(CleanEvent), +) -> CleanupOutcome { + clean_with_support( + items, + remover, + platform::current().cleanup_support(), + &mut on_event, + ) } fn clean_with_support( items: &[CleanupItem], remover: &dyn Remover, support: CleanupSupport, + on_event: &mut dyn FnMut(CleanEvent), ) -> CleanupOutcome { let mut outcome = CleanupOutcome::default(); if let CleanupSupport::ScanOnly { reason } = support { - outcome.skipped.extend(items.iter().map(|item| Skipped { - path: item.path.clone(), - reason: SkipReason::CleanupUnavailable(reason), - })); - return outcome; - } - for item in items { - let path = item.path.clone(); - - let Some(root) = item.scan_root.as_deref() else { - outcome.skipped.push(Skipped { - path, - reason: SkipReason::OutsideEveryTarget, - }); - continue; - }; - if guard::check(&path, root).is_err() { - outcome.skipped.push(Skipped { - path, - reason: SkipReason::Refused, + for (index, item) in items.iter().enumerate() { + let reason = SkipReason::CleanupUnavailable(reason); + on_event(CleanEvent::Skipped { + index, + path: item.path.clone(), + reason: reason.clone(), }); - continue; - } - // Captured after validation and checked again below, so a swap in - // between is caught rather than acted on. - let Some(identity) = FileIdentity::of(&path) else { - outcome.skipped.push(Skipped { - path, - reason: SkipReason::ChangedUnderUs, - }); - continue; - }; - if !identity.still_matches(&path) { outcome.skipped.push(Skipped { - path, - reason: SkipReason::ChangedUnderUs, + path: item.path.clone(), + reason, }); - continue; } - match remover.remove(&path) { + return outcome; + } + for (index, item) in items.iter().enumerate() { + on_event(CleanEvent::Started { + index, + path: item.path.clone(), + }); + match remove_one(item, remover) { Ok(()) => { outcome.freed_bytes += item.size_bytes; - outcome.removed.push(path); + outcome.removed.push(item.path.clone()); + on_event(CleanEvent::Removed { + index, + path: item.path.clone(), + size_bytes: item.size_bytes, + }); + } + Err(reason) => { + on_event(CleanEvent::Skipped { + index, + path: item.path.clone(), + reason: reason.clone(), + }); + outcome.skipped.push(Skipped { + path: item.path.clone(), + reason, + }); } - Err(e) => outcome.skipped.push(Skipped { - path, - reason: SkipReason::RemoverFailed(e.to_string()), - }), } } outcome } +/// The per-item flow, written once: validate, prove identity, validate +/// again, remove. `Err` names exactly why the path was left alone. +fn remove_one(item: &CleanupItem, remover: &dyn Remover) -> Result<(), SkipReason> { + let path = item.path.as_path(); + // An item with no owning target is refused rather than guessed at. + let root = item + .scan_root + .as_deref() + .ok_or(SkipReason::OutsideEveryTarget)?; + if guard::check(path, root).is_err() { + return Err(SkipReason::Refused); + } + // Captured after validation and checked again below, so a swap in + // between is caught rather than acted on. + let identity = FileIdentity::of(path).ok_or(SkipReason::ChangedUnderUs)?; + if !identity.still_matches(path) { + return Err(SkipReason::ChangedUnderUs); + } + // Re-run the full guard at the last instant before the destructive + // call. If the path turned into a reparse point, a protected location + // or something outside the scan root after the first check, it is + // refused rather than acted on. This narrows the check-to-act window + // as far as a name-based trash API allows; only openat/O_NOFOLLOW plus + // removal through a directory descriptor would close it entirely. + if guard::check(path, root).is_err() { + return Err(SkipReason::Refused); + } + remover + .remove(path) + .map_err(|e| SkipReason::RemoverFailed(e.to_string())) +} + #[cfg(test)] mod tests { use super::*; @@ -234,7 +301,7 @@ mod tests { /// Exercise the portable validate/identity/remover flow without claiming /// that the current host has a native recovery mechanism. fn clean_with_native_trash(items: &[CleanupItem], remover: &dyn Remover) -> CleanupOutcome { - clean_with_support(items, remover, CleanupSupport::NativeTrash) + clean_with_support(items, remover, CleanupSupport::NativeTrash, &mut |_| {}) } /// Records what it was asked to remove and leaves the disk alone. @@ -289,6 +356,88 @@ mod tests { assert_eq!(remover.seen().len(), 2); } + /// Progress must arrive item by item, not in one lump at the end — + /// that is the whole point of the streaming entry point, and a UI + /// painting from it would otherwise still look frozen. + #[test] + fn streaming_reports_every_item_as_it_is_reached() { + let root = tempdir().unwrap(); + let good = root.path().join("good"); + std::fs::create_dir_all(&good).unwrap(); + let orphan = root.path().join("orphan"); + std::fs::create_dir_all(&orphan).unwrap(); + + let remover = FakeRemover::default(); + let mut events = Vec::new(); + let outcome = clean_with_support( + &[ + item(&good, root.path(), 100), + CleanupItem { + path: orphan.clone(), + size_bytes: 40, + scan_root: None, + }, + ], + &remover, + CleanupSupport::NativeTrash, + &mut |event| events.push(event), + ); + + assert_eq!( + events, + vec![ + CleanEvent::Started { + index: 0, + path: good.clone() + }, + CleanEvent::Removed { + index: 0, + path: good.clone(), + size_bytes: 100 + }, + CleanEvent::Started { + index: 1, + path: orphan.clone() + }, + CleanEvent::Skipped { + index: 1, + path: orphan, + reason: SkipReason::OutsideEveryTarget + }, + ] + ); + // The streamed events and the returned tally never disagree. + assert_eq!(outcome.removed, vec![good]); + assert_eq!(outcome.freed_bytes, 100); + assert_eq!(outcome.skipped_count(), 1); + } + + /// `clean` is `clean_streaming` with the events dropped; it must not + /// have drifted into a second copy of the flow. + #[test] + fn streaming_and_silent_entry_points_agree() { + let root = tempdir().unwrap(); + let target = root.path().join("cache"); + std::fs::create_dir_all(&target).unwrap(); + + let remover = FakeRemover::default(); + let mut count = 0usize; + let streamed = clean_with_support( + &[item(&target, root.path(), 7)], + &remover, + CleanupSupport::NativeTrash, + &mut |_| count += 1, + ); + // The fake actually unlinks, so the second run needs its own copy + // of the fixture rather than the one just consumed. + std::fs::create_dir_all(&target).unwrap(); + let silent = clean_with_native_trash(&[item(&target, root.path(), 7)], &remover); + + assert_eq!(count, 2); // Started + one terminal event + assert_eq!(streamed.removed, silent.removed); + assert_eq!(streamed.freed_bytes, silent.freed_bytes); + } + #[test] fn scan_only_platform_never_hands_a_path_to_the_remover() { let root = tempdir().unwrap(); @@ -302,6 +451,7 @@ mod tests { CleanupSupport::ScanOnly { reason: "native trash has not been verified", }, + &mut |_| {}, ); assert!(remover.seen().is_empty()); @@ -377,6 +527,54 @@ mod tests { std::fs::remove_dir(&junction).expect("remove only the junction, not its target"); } + /// The exact TOCTOU race the flow exists to survive: a directory is + /// validated, then the very same path is swapped for a junction pointing + /// elsewhere before removal. The identity captured up front must no longer + /// match, so the swapped-in reparse point is skipped, never followed. + #[cfg(target_os = "windows")] + #[test] + fn identity_rejects_a_path_that_became_a_junction_after_validation() { + let root = tempdir().unwrap(); + let target = root.path().join("cache"); + std::fs::create_dir_all(&target).unwrap(); + let captured = FileIdentity::of(&target).expect("a real directory has an identity"); + + let elsewhere = root.path().join("victim"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::remove_dir(&target).unwrap(); + crate::platform::create_test_junction(&target, &elsewhere) + .expect("create a junction fixture"); + + assert!( + !captured.still_matches(&target), + "a junction that replaced the validated directory must fail the re-check" + ); + std::fs::remove_dir(&target).expect("remove only the junction, not its target"); + assert!(elsewhere.exists(), "the junction target must be untouched"); + } + + /// Same race on Unix: the validated path becomes a symlink to a different + /// object before removal. The captured identity must fail closed. + #[cfg(unix)] + #[test] + fn identity_rejects_a_path_that_became_a_symlink_after_validation() { + let root = tempdir().unwrap(); + let target = root.path().join("cache"); + std::fs::create_dir_all(&target).unwrap(); + let captured = FileIdentity::of(&target).expect("a real directory has an identity"); + + let elsewhere = root.path().join("victim"); + std::fs::create_dir_all(&elsewhere).unwrap(); + std::fs::remove_dir(&target).unwrap(); + std::os::unix::fs::symlink(&elsewhere, &target).unwrap(); + + assert!( + !captured.still_matches(&target), + "a symlink that replaced the validated directory must fail the re-check" + ); + assert!(elsewhere.exists(), "the symlink target must be untouched"); + } + /// The whole point of the abstraction: a refused path must never reach /// the remover at all. #[cfg(unix)] diff --git a/crates/chystik-core/src/model.rs b/crates/chystik-core/src/model.rs index 6bf1948..f8c1f9c 100644 --- a/crates/chystik-core/src/model.rs +++ b/crates/chystik-core/src/model.rs @@ -305,6 +305,17 @@ pub struct Finding { /// automation keeps receiving the same machine document it already knows. #[serde(default, skip_serializing_if = "Option::is_none")] pub provenance: Option, + /// The versioned store this finding lives in — e.g. every superseded + /// Claude Code build shares `~/.local/share/claude/versions` here — + /// when it came from a versioned-store group rule. `None` for every + /// other finding, so the machine JSON gains nothing for the + /// overwhelming majority of rows. + /// + /// The UI groups findings that share this path into one row: "N older + /// versions found, the newest is kept" instead of N look-alike entries + /// that differ only by a version number in the path. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub version_group: Option, } impl Finding { @@ -387,6 +398,7 @@ mod tests { note: "fixture".into(), advice: advice.map(str::to_owned), provenance, + version_group: None, } } diff --git a/crates/chystik-core/src/platform/linux.rs b/crates/chystik-core/src/platform/linux.rs index 4fb1e27..402bd89 100644 --- a/crates/chystik-core/src/platform/linux.rs +++ b/crates/chystik-core/src/platform/linux.rs @@ -42,6 +42,7 @@ impl Adapter for Linux { home_dir, cache_dir, local_app_data_dir: None, + roaming_app_data_dir: None, library_caches_dir: None, developer_dir: None, volume_root: None, diff --git a/crates/chystik-core/src/platform/macos.rs b/crates/chystik-core/src/platform/macos.rs index 96f9aa4..07c82ab 100644 --- a/crates/chystik-core/src/platform/macos.rs +++ b/crates/chystik-core/src/platform/macos.rs @@ -42,6 +42,7 @@ impl Adapter for MacOS { developer_dir: Some(home_dir.join("Library/Developer")), home_dir, local_app_data_dir: None, + roaming_app_data_dir: None, volume_root: None, } } diff --git a/crates/chystik-core/src/platform/mod.rs b/crates/chystik-core/src/platform/mod.rs index 4c8bd1e..0703618 100644 --- a/crates/chystik-core/src/platform/mod.rs +++ b/crates/chystik-core/src/platform/mod.rs @@ -81,6 +81,10 @@ pub(crate) struct RuleRoots { pub home_dir: PathBuf, pub cache_dir: PathBuf, pub local_app_data_dir: Option, + /// Windows roaming profile (`%APPDATA%`). Only Windows populates it; it is + /// where many Electron and vendor apps park regenerable caches that are + /// not under the local profile. + pub roaming_app_data_dir: Option, pub library_caches_dir: Option, pub developer_dir: Option, pub volume_root: Option, diff --git a/crates/chystik-core/src/platform/unsupported.rs b/crates/chystik-core/src/platform/unsupported.rs index 8b69fae..0be6d3a 100644 --- a/crates/chystik-core/src/platform/unsupported.rs +++ b/crates/chystik-core/src/platform/unsupported.rs @@ -39,6 +39,7 @@ impl Adapter for Unsupported { cache_dir: home_dir.join(".cache"), home_dir, local_app_data_dir: None, + roaming_app_data_dir: None, library_caches_dir: None, developer_dir: None, volume_root: None, diff --git a/crates/chystik-core/src/platform/windows.rs b/crates/chystik-core/src/platform/windows.rs index 91381b9..0898279 100644 --- a/crates/chystik-core/src/platform/windows.rs +++ b/crates/chystik-core/src/platform/windows.rs @@ -116,6 +116,8 @@ impl Adapter for Windows { let home_dir = super::privacy_home_dir_or_current(); let local_app_data_dir = env_absolute("LOCALAPPDATA").or_else(|| Some(home_dir.join("AppData/Local"))); + let roaming_app_data_dir = + env_absolute("APPDATA").or_else(|| Some(home_dir.join("AppData/Roaming"))); let volume_root = system_drive_root(); super::RuleRoots { cache_dir: local_app_data_dir @@ -123,6 +125,7 @@ impl Adapter for Windows { .unwrap_or_else(|| home_dir.join("AppData/Local")), home_dir, local_app_data_dir, + roaming_app_data_dir, library_caches_dir: None, developer_dir: None, volume_root: Some(volume_root), diff --git a/crates/chystik-core/src/report.rs b/crates/chystik-core/src/report.rs index 047e416..d7eb6fa 100644 --- a/crates/chystik-core/src/report.rs +++ b/crates/chystik-core/src/report.rs @@ -133,6 +133,7 @@ mod tests { note: String::new(), advice: None, provenance: None, + version_group: None, } } diff --git a/crates/chystik-core/src/rules/catalog.rs b/crates/chystik-core/src/rules/catalog.rs index f5849b9..93aa675 100644 --- a/crates/chystik-core/src/rules/catalog.rs +++ b/crates/chystik-core/src/rules/catalog.rs @@ -273,6 +273,7 @@ fn resolve_root(locator: &RawLocator, context: &RuleContext) -> Option "home" => Some(context.roots.home_dir.clone()), "cache" => Some(context.roots.cache_dir.clone()), "local-app-data" => context.roots.local_app_data_dir.clone(), + "roaming-app-data" => context.roots.roaming_app_data_dir.clone(), "library-caches" => context.roots.library_caches_dir.clone(), "developer" => context.roots.developer_dir.clone(), "volume-root" => context.roots.volume_root.clone(), @@ -366,7 +367,7 @@ mod tests { use tempfile::tempdir; - const REVIEWED_AT: &str = "2026-08-26"; + const REVIEWED_AT: &str = "2026-08-30"; fn context(kind: PlatformKind, root: &Path) -> RuleContext { RuleContext { @@ -376,6 +377,7 @@ mod tests { home_dir: root.join("home"), cache_dir: root.join("cache"), local_app_data_dir: Some(root.join("local")), + roaming_app_data_dir: Some(root.join("roaming")), library_caches_dir: Some(root.join("Library/Caches")), developer_dir: Some(root.join("Library/Developer")), volume_root: Some(root.join("volume")), @@ -490,6 +492,102 @@ mod tests { Severity::Moderate, FindingPolicy::VendorCommandOnly, ), + ( + "jetbrains.ide-system-dir", + Category::IdeToolchains, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "jetbrains.toolbox-cache", + Category::IdeToolchains, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "google.android-studio-system-dir", + Category::IdeToolchains, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "dart.pub-cache", + Category::PackageCaches, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "windows.crash-dumps", + Category::SystemJunk, + Severity::Safe, + FindingPolicy::DirectReview, + ), + ( + "windows.temp", + Category::SystemJunk, + Severity::Safe, + FindingPolicy::AdvisoryOnly, + ), + ( + "microsoft.vscode-cache", + Category::IdeToolchains, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "google.chrome-cache", + Category::BrowserSystem, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "perplexity.comet-cache", + Category::BrowserSystem, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "discord.cache", + Category::Messengers, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "notion.cache", + Category::OfficeDocs, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "antigravity.cache", + Category::IdeToolchains, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "epic.webcache", + Category::GameLaunchers, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "valve.steam-htmlcache", + Category::GameLaunchers, + Severity::Moderate, + FindingPolicy::DirectReview, + ), + ( + "dart.analysis-server-cache", + Category::IdeToolchains, + Severity::Safe, + FindingPolicy::DirectSafe, + ), + ( + "huggingface.hub-cache", + Category::AiModels, + Severity::Moderate, + FindingPolicy::DirectReview, + ), ]; let mut actual: Vec<_> = catalog_rules() .iter() @@ -747,6 +845,6 @@ mod tests { assert!(!rule.preconditions.is_empty()); assert!(!rule.locator.is_empty()); } - assert_eq!(ids.len(), 15); + assert_eq!(ids.len(), 31); } } diff --git a/crates/chystik-core/src/rules/catalog_schema.rs b/crates/chystik-core/src/rules/catalog_schema.rs index da49d0e..0e7f90e 100644 --- a/crates/chystik-core/src/rules/catalog_schema.rs +++ b/crates/chystik-core/src/rules/catalog_schema.rs @@ -12,6 +12,7 @@ pub const ENVIRONMENT_OVERRIDES: &[&str] = &[ "SCCACHE_DIR", "VCPKG_DEFAULT_BINARY_CACHE", "OPTIX_CACHE_PATH", + "PUB_CACHE", ]; #[derive(Debug, Clone, Deserialize)] @@ -200,6 +201,7 @@ fn validate_locator(rule: &RawRule, locator: &RawLocator) -> Result<(), String> "home" | "cache" | "local-app-data" + | "roaming-app-data" | "library-caches" | "developer" | "volume-root" @@ -302,6 +304,15 @@ mod tests { } } + #[test] + fn accepts_the_windows_roaming_app_data_root() { + let mut valid = rule(); + valid.locator[0].platform = "windows".into(); + valid.locator[0].root = "roaming-app-data".into(); + valid.locator[0].path = Some("discord/Cache".into()); + assert!(validate_catalog(&[valid]).is_ok()); + } + #[test] fn strict_validation_rejects_broad_or_unsafe_rules() { let mut invalid = rule(); diff --git a/crates/chystik-core/src/rules/core.rs b/crates/chystik-core/src/rules/core.rs index 3ca81cf..0578ea5 100644 --- a/crates/chystik-core/src/rules/core.rs +++ b/crates/chystik-core/src/rules/core.rs @@ -88,6 +88,12 @@ fn marker_rule(dir: &Path) -> Option { ".next" | ".nuxt" if parent_has_file(parent, &["package.json"]) => { m("framework build output — regenerated by dev/build".into()) } + // Flutter/Dart writes compiled output (APKs, intermediates, web + // bundles) to `/build`; `pubspec.yaml` beside it is the + // Dart-specific marker. `flutter build` regenerates the whole tree. + "build" if parent_has_file(parent, &["pubspec.yaml"]) => { + m("Flutter/Dart build output — regenerated by the next flutter build".into()) + } // `Cargo.toml` deliberately absent: cargo writes to `target`, never // to `build`/`dist`. CMake/Ninja evidence lives INSIDE the build // directory, which is the only reliable marker for C/C++ trees. diff --git a/crates/chystik-core/src/rules/mod.rs b/crates/chystik-core/src/rules/mod.rs index 7d861e2..e16f75f 100644 --- a/crates/chystik-core/src/rules/mod.rs +++ b/crates/chystik-core/src/rules/mod.rs @@ -16,10 +16,32 @@ pub(crate) mod media; pub(crate) mod office_docs; pub(crate) mod system_junk; +use std::borrow::Cow; use std::path::{Path, PathBuf}; use crate::model::{Category, Severity}; +/// Strip the Windows verbatim prefix (`\\?\`, or `\\?\UNC\` for UNC paths) so +/// path matching sees the same spelling the rule tables use. +/// +/// The scanner canonicalizes its roots for guard safety, which on Windows +/// yields verbatim `\\?\C:\...` paths for every walked entry. The rule tables +/// — catalog targets, `$HOME`-relative rules, group rules — are all built from +/// plain `C:\...` roots, so a `strip_prefix(home)` or path equality against a +/// verbatim path silently failed and left the whole home-anchored catalog dead +/// on Windows. Normalizing once at the classifier boundary fixes every +/// consumer without disturbing the real path reported for the finding. +pub(crate) fn strip_verbatim(dir: &Path) -> Cow<'_, Path> { + let text = dir.as_os_str().to_string_lossy(); + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + return Cow::Owned(PathBuf::from(format!(r"\\{rest}"))); + } + if let Some(rest) = text.strip_prefix(r"\\?\") { + return Cow::Owned(PathBuf::from(rest)); + } + Cow::Borrowed(dir) +} + /// A single rule hit. #[derive(Debug, Clone)] pub(crate) struct Match { @@ -54,6 +76,8 @@ impl RuleEngine { } pub(crate) fn classify_with_metadata(&self, dir: &Path) -> Option { + let dir = strip_verbatim(dir); + let dir = dir.as_ref(); if let Some((matched, catalog)) = self.catalog.classify_with_metadata(dir) { return Some(ClassifiedRule { matched, @@ -123,6 +147,8 @@ pub(crate) const GROUP_RULES: &[GroupRule] = &[ /// The group rule covering `dir`, if any. pub(crate) fn classify_group(dir: &Path) -> Option<&'static GroupRule> { + let dir = strip_verbatim(dir); + let dir = dir.as_ref(); let home = home_root()?; let rel = if let Ok(rel) = dir.strip_prefix(&home) { rel.to_string_lossy().replace('\\', "/") @@ -318,6 +344,46 @@ mod tests { assert_eq!(m.category, Category::BuildArtifacts); } + /// Regression: the scanner canonicalizes its roots, so on Windows every + /// walked path arrives verbatim-prefixed (`\\?\C:\...`). Rule tables use + /// plain roots, so without stripping the prefix the whole home-anchored + /// catalog silently matched nothing. Prefixing here reproduces that shape + /// on any platform and proves the classifier still matches. + #[test] + fn verbatim_prefixed_paths_still_classify() { + let root = tempdir().unwrap(); + let proj = root.path().join("app"); + std::fs::create_dir_all(proj.join("node_modules")).unwrap(); + std::fs::write(proj.join("package.json"), "{}").unwrap(); + std::fs::write(proj.join("package-lock.json"), "{}").unwrap(); + + let real = proj.join("node_modules"); + let verbatim = PathBuf::from(format!(r"\\?\{}", real.display())); + assert!( + RuleEngine::current() + .classify_with_metadata(&verbatim) + .is_some(), + "a verbatim-prefixed path must classify the same as its plain form" + ); + } + + #[test] + fn strip_verbatim_removes_windows_prefixes() { + assert_eq!( + strip_verbatim(Path::new(r"\\?\C:\Users\x\.cache")).as_ref(), + Path::new(r"C:\Users\x\.cache") + ); + assert_eq!( + strip_verbatim(Path::new(r"\\?\UNC\server\share\dir")).as_ref(), + Path::new(r"\\server\share\dir") + ); + // A plain path is returned untouched (and borrowed). + assert!(matches!( + strip_verbatim(Path::new("/home/x/.cache")), + Cow::Borrowed(_) + )); + } + #[cfg(target_os = "linux")] #[test] fn catalog_evidence_preempts_the_legacy_pip_rule() { diff --git a/crates/chystik-core/src/scanner.rs b/crates/chystik-core/src/scanner.rs index 87816a8..27cc8c2 100644 --- a/crates/chystik-core/src/scanner.rs +++ b/crates/chystik-core/src/scanner.rs @@ -340,6 +340,7 @@ fn scan_root( note: rule.note.to_owned(), advice: None, provenance: None, + version_group: Some(parent.to_path_buf()), }; walker_emit(ScanStreamEvent::FindingFound(Box::new(finding))); } @@ -402,6 +403,7 @@ fn scan_root( .as_ref() .and_then(|metadata| metadata.advice.clone()), provenance: catalog.map(|metadata| metadata.provenance), + version_group: None, }; walker_emit(ScanStreamEvent::FindingFound(Box::new(finding))); } diff --git a/crates/chystik-core/tests/application.rs b/crates/chystik-core/tests/application.rs index a63dfae..a611731 100644 --- a/crates/chystik-core/tests/application.rs +++ b/crates/chystik-core/tests/application.rs @@ -40,6 +40,7 @@ fn finding(path: impl AsRef, severity: Severity, size_bytes: u64) -> Findi note: "fixture".into(), advice: None, provenance: None, + version_group: None, } } @@ -54,7 +55,11 @@ fn normalizes_roots_to_absolute_non_overlapping_directories() { let roots = normalize_roots(&[nested, root.clone(), sibling.clone(), root]).unwrap(); - assert_eq!(roots, vec![sandbox.path().join("root"), sibling]); + // normalize_roots canonicalizes; on Windows that yields a verbatim `\\?\` + // path, so compare against the same canonical form on every platform. + let expected_root = std::fs::canonicalize(sandbox.path().join("root")).unwrap(); + let expected_sibling = std::fs::canonicalize(&sibling).unwrap(); + assert_eq!(roots, vec![expected_root, expected_sibling]); assert!(roots.iter().all(|path| path.is_absolute())); } @@ -132,6 +137,16 @@ fn safe_cleanup_plan_never_selects_excluded_advisory_risky_or_review_findings() std::fs::create_dir_all(path).unwrap(); } + // Findings and roots carry the canonical form the scanner produces (a + // verbatim `\\?\` path on Windows); the exclusion filter canonicalizes too. + // Canonicalize the fixtures so the whole flow matches production semantics. + let root = std::fs::canonicalize(&root).unwrap(); + let safe = std::fs::canonicalize(&safe).unwrap(); + let excluded = std::fs::canonicalize(&excluded).unwrap(); + let risky = std::fs::canonicalize(&risky).unwrap(); + let advisory = std::fs::canonicalize(&advisory).unwrap(); + let review = std::fs::canonicalize(&review).unwrap(); + let mut advisory_finding = finding(&advisory, Severity::Safe, 400); advisory_finding.advice = Some("run package-manager cleanup".into()); let mut review_finding = finding(&review, Severity::Safe, 500); diff --git a/crates/chystik-gui/Cargo.toml b/crates/chystik-gui/Cargo.toml index 2de3f82..e35a4b2 100644 --- a/crates/chystik-gui/Cargo.toml +++ b/crates/chystik-gui/Cargo.toml @@ -15,10 +15,13 @@ serde_json = { workspace = true } chrono = { workspace = true } eframe = "0.29" -egui_extras = { version = "0.29", features = ["all_loaders"] } +# Only `TableBuilder` is used, so no image/svg loader features: dropping +# `all_loaders` removes the resvg/usvg/quick-xml/ttf-parser SVG stack +# (RUSTSEC-2026-0194/0195 and the unmaintained ttf-parser) that this app +# never exercises — it decodes its PNG assets through `image` directly. +egui_extras = { version = "0.29" } rfd = "0.15" -# PNG decode for the window icon; `image` is already in the tree via -# egui_extras's loaders, so this adds no new compilation. +# PNG decode for the window icon and in-app logo. image = { version = "0.25", default-features = false, features = ["png"] } [dev-dependencies] diff --git a/crates/chystik-gui/locales/de.json b/crates/chystik-gui/locales/de.json index 8408e6b..d915621 100644 --- a/crates/chystik-gui/locales/de.json +++ b/crates/chystik-gui/locales/de.json @@ -49,6 +49,8 @@ "col_age": "Zuletzt verwendet", "sort_hint": "Klicken Sie zum Sortieren", "risky_locked_hint": "Manuelle/wertvolle Artikel sind von der Massenauswahl ausgeschlossen – öffnen Sie die Zeile, um sie gezielt zu überprüfen", + "version_group_row": "{app} — {n} ältere Versionen · {size}", + "version_group_tooltip_lead": "Eine neuere Version von {app} ist installiert — diese können gefahrlos entfernt werden:", "empty_title": "Noch nichts gescannt", "empty_body": "Chystik findet Caches, Build-Ausgaben und Downloads und erklärt sowohl die Wiederherstellungs- als auch die Bereinigungsberechtigung.", "empty_filtered_title": "Keine Übereinstimmungen", @@ -60,14 +62,14 @@ "totals_review_required": "Überprüfung erforderlich: {n} · {size}", "totals_manual_valuable": "Manuell / wertvoll: {n} · {size}", "selected_word": "ausgewählt", + "select_all": "Alle auswählen", + "select_all_hint": "Alle bereinigbaren Einträge in dieser Kategorie auswählen", "clear": "Klar", "clear_hint": "Aktuelle Auswahl löschen", "move_to_trash": "Verschieben Sie {size} in den Papierkorb", "move_to_trash_idle": "In den Papierkorb verschieben", "move_to_trash_hint": "Es wird nichts gelöscht: Die Elemente werden in den Papierkorb Ihres Desktops verschoben und bleiben wiederherstellbar", "cleanup_unavailable": "Die Bereinigung ist auf dieser Plattform erst verfügbar, wenn die nativen Sicherheitsüberprüfungen für den Papierkorb überprüft wurden", - "disks": "Festplatten", - "disks_hint": "Gemountete Volumes und wie voll sie sind", "confirm_title": "In den Papierkorb verschieben", "confirm_sub": "{n} {items} · {size} · aus dem Papierkorb Ihres Desktops wiederherstellbar", "guard_will_skip": "{n} werden übersprungen: Der Sicherheitsbeamte lehnt sie ab.", @@ -78,7 +80,11 @@ "age_months": "{n} Mo", "age_years": "{n} J", "age_unknown": "unbekannt", + "trash_progress_title": "Wird in den Papierkorb verschoben…", + "trash_progress_count": "{done} von {n} · {size} freigegeben", + "trash_failed_title": "Bereinigung nicht abgeschlossen", "trash_done_title": "In den Papierkorb verschoben", + "trash_done_recovery": "Die Wiederherstellung übernimmt Ihr Betriebssystem", "trash_moved": "{n} Elemente verschoben · {size} zurückgefordert", "trash_skipped": "{n} wurde von der Wache übersprungen", "export_done": "Export abgeschlossen", diff --git a/crates/chystik-gui/locales/en.json b/crates/chystik-gui/locales/en.json index 6039aa2..4513303 100644 --- a/crates/chystik-gui/locales/en.json +++ b/crates/chystik-gui/locales/en.json @@ -49,6 +49,8 @@ "col_age": "Last used", "sort_hint": "Click to sort", "risky_locked_hint": "Manual / valuable items are excluded from bulk selection — open the row to review deliberately", + "version_group_row": "{app} — {n} older versions · {size}", + "version_group_tooltip_lead": "A newer {app} is installed — these are safe to remove:", "empty_title": "Nothing scanned yet", "empty_body": "Chystik finds caches, build outputs and downloads and explains both recovery and cleanup permission.", "empty_filtered_title": "No matches", @@ -60,14 +62,14 @@ "totals_review_required": "Review required: {n} · {size}", "totals_manual_valuable": "Manual / valuable: {n} · {size}", "selected_word": "selected", + "select_all": "Select all", + "select_all_hint": "Select every cleanable item in this category", "clear": "Clear", "clear_hint": "Drop the current selection", "move_to_trash": "Move {size} to Trash", "move_to_trash_idle": "Move to Trash", "move_to_trash_hint": "Nothing is erased: items go to your desktop trash and stay restorable", "cleanup_unavailable": "Cleanup is unavailable on this platform until its native trash safety checks are verified", - "disks": "Disks", - "disks_hint": "Mounted volumes and how full they are", "confirm_title": "Move to Trash", "confirm_sub": "{n} {items} · {size} · recoverable from your desktop trash", "guard_will_skip": "{n} will be skipped: the safety guard refuses them.", @@ -78,7 +80,11 @@ "age_months": "{n} mo", "age_years": "{n} y", "age_unknown": "unknown", + "trash_progress_title": "Moving to Trash…", + "trash_progress_count": "{done} of {n} · {size} reclaimed", + "trash_failed_title": "Cleanup did not finish", "trash_done_title": "Moved to Trash", + "trash_done_recovery": "Recovery is handled by your operating system", "trash_moved": "{n} items moved · {size} reclaimed", "trash_skipped": "{n} skipped by the safety guard", "export_done": "Export complete", diff --git a/crates/chystik-gui/locales/es.json b/crates/chystik-gui/locales/es.json index 03fb22a..b6aa22f 100644 --- a/crates/chystik-gui/locales/es.json +++ b/crates/chystik-gui/locales/es.json @@ -49,6 +49,8 @@ "col_age": "último usado", "sort_hint": "Haga clic para ordenar", "risky_locked_hint": "Los artículos manuales o valiosos están excluidos de la selección masiva: abra la fila para revisarlos deliberadamente", + "version_group_row": "{app} — {n} versiones antiguas · {size}", + "version_group_tooltip_lead": "Hay una versión más reciente de {app} instalada — estas se pueden eliminar sin riesgo:", "empty_title": "Nada escaneado todavía", "empty_body": "Chystik busca cachés, genera resultados y descargas, y explica los permisos de recuperación y limpieza.", "empty_filtered_title": "No hay coincidencias", @@ -60,14 +62,14 @@ "totals_review_required": "Revisión requerida: {n} · {size}", "totals_manual_valuable": "Manual / valioso: {n} · {size}", "selected_word": "seleccionado", + "select_all": "Seleccionar todo", + "select_all_hint": "Seleccionar todos los elementos limpiables de esta categoría", "clear": "Claro", "clear_hint": "Dejar la selección actual", "move_to_trash": "Mover {size} a la papelera", "move_to_trash_idle": "Mover a la papelera", "move_to_trash_hint": "No se borra nada: los elementos van a la papelera de su escritorio y se pueden restaurar", "cleanup_unavailable": "La limpieza no está disponible en esta plataforma hasta que se verifiquen las comprobaciones de seguridad de la basura nativa.", - "disks": "Discos", - "disks_hint": "Volúmenes montados y qué tan llenos están.", "confirm_title": "Mover a la papelera", "confirm_sub": "{n} {items} · {size} · recuperable de la papelera de tu escritorio", "guard_will_skip": "Se omitirán {n}: el guardia de seguridad los rechaza.", @@ -78,7 +80,11 @@ "age_months": "{n} meses", "age_years": "{n} a", "age_unknown": "desconocido", + "trash_progress_title": "Moviendo a la papelera…", + "trash_progress_count": "{done} de {n} · {size} liberados", + "trash_failed_title": "La limpieza no terminó", "trash_done_title": "Movido a la papelera", + "trash_done_recovery": "La recuperación la gestiona tu sistema operativo", "trash_moved": "{n} elementos movidos · {size} recuperados", "trash_skipped": "{n} saltado por el guardia de seguridad", "export_done": "Exportación completa", diff --git a/crates/chystik-gui/locales/fr.json b/crates/chystik-gui/locales/fr.json index 450d9d3..7d2ce66 100644 --- a/crates/chystik-gui/locales/fr.json +++ b/crates/chystik-gui/locales/fr.json @@ -49,6 +49,8 @@ "col_age": "Dernière utilisation", "sort_hint": "Cliquez pour trier", "risky_locked_hint": "Les objets manuels/de valeur sont exclus de la sélection groupée : ouvrez la ligne pour l'examiner délibérément", + "version_group_row": "{app} — {n} versions obsolètes · {size}", + "version_group_tooltip_lead": "Une version plus récente de {app} est installée — celles-ci peuvent être supprimées sans risque :", "empty_title": "Rien de scanné pour l'instant", "empty_body": "Chystik trouve les caches, crée des sorties et des téléchargements et explique les autorisations de récupération et de nettoyage.", "empty_filtered_title": "Aucune correspondance", @@ -60,14 +62,14 @@ "totals_review_required": "Examen requis : {n} · {size}", "totals_manual_valuable": "Manuel / précieux : {n} · {size}", "selected_word": "choisi", + "select_all": "Tout sélectionner", + "select_all_hint": "Sélectionner tous les éléments nettoyables de cette catégorie", "clear": "Clair", "clear_hint": "Supprimer la sélection actuelle", "move_to_trash": "Déplacer {size} vers la corbeille", "move_to_trash_idle": "Déplacer vers la corbeille", "move_to_trash_hint": "Rien n'est effacé : les éléments vont dans la corbeille de votre bureau et restent restaurables", "cleanup_unavailable": "Le nettoyage n'est pas disponible sur cette plate-forme jusqu'à ce que ses contrôles de sécurité des déchets natifs soient vérifiés.", - "disks": "Disques", - "disks_hint": "Volumes montés et leur niveau de remplissage", "confirm_title": "Déplacer vers la corbeille", "confirm_sub": "{n} {items} · {size} · récupérable depuis la corbeille de votre bureau", "guard_will_skip": "{n} seront ignorés : l'agent de sécurité les refuse.", @@ -78,7 +80,11 @@ "age_months": "{n} mois", "age_years": "{n} oui", "age_unknown": "inconnu", + "trash_progress_title": "Déplacement vers la corbeille…", + "trash_progress_count": "{done} sur {n} · {size} libérés", + "trash_failed_title": "Le nettoyage ne sest pas terminé", "trash_done_title": "Déplacé vers la corbeille", + "trash_done_recovery": "La récupération est gérée par votre système d'exploitation", "trash_moved": "{n} éléments déplacés · {size} récupérés", "trash_skipped": "{n} ignoré par l'agent de sécurité", "export_done": "Exportation terminée", diff --git a/crates/chystik-gui/locales/it.json b/crates/chystik-gui/locales/it.json index 742ebb2..df5ae2d 100644 --- a/crates/chystik-gui/locales/it.json +++ b/crates/chystik-gui/locales/it.json @@ -49,6 +49,8 @@ "col_age": "Ultimo usato", "sort_hint": "Fare clic per ordinare", "risky_locked_hint": "Gli articoli manuali/di valore sono esclusi dalla selezione collettiva: apri deliberatamente la riga per esaminarli", + "version_group_row": "{app} — {n} versioni obsolete · {size}", + "version_group_tooltip_lead": "È installata una versione più recente di {app} — queste possono essere rimosse senza rischi:", "empty_title": "Non è stato ancora scansionato nulla", "empty_body": "Chystik trova cache, crea output e download e spiega sia le autorizzazioni di ripristino che quelle di pulizia.", "empty_filtered_title": "Nessuna corrispondenza", @@ -60,14 +62,14 @@ "totals_review_required": "Revisione richiesta: {n} · {size}", "totals_manual_valuable": "Manuale/di valore: {n} · {size}", "selected_word": "selezionato", + "select_all": "Seleziona tutto", + "select_all_hint": "Seleziona tutti gli elementi eliminabili in questa categoria", "clear": "Chiaro", "clear_hint": "Rilascia la selezione corrente", "move_to_trash": "Sposta {size} nel Cestino", "move_to_trash_idle": "Sposta nel cestino", "move_to_trash_hint": "Niente viene cancellato: gli elementi vanno nel cestino del desktop e rimangono ripristinabili", "cleanup_unavailable": "La pulizia non è disponibile su questa piattaforma finché non vengono verificati i controlli di sicurezza dei rifiuti nativi", - "disks": "Dischi", - "disks_hint": "Volumi montati e quanto sono pieni", "confirm_title": "Sposta nel cestino", "confirm_sub": "{n} {items} · {size} · recuperabili dal Cestino", "guard_will_skip": "{n} verranno saltati: la guardia di sicurezza li rifiuta.", @@ -78,7 +80,11 @@ "age_months": "{n} mese", "age_years": "{n} sì", "age_unknown": "sconosciuto", + "trash_progress_title": "Spostamento nel cestino…", + "trash_progress_count": "{done} di {n} · {size} liberati", + "trash_failed_title": "La pulizia non è stata completata", "trash_done_title": "Spostato nel cestino", + "trash_done_recovery": "Il ripristino è gestito dal tuo sistema operativo", "trash_moved": "{n} elementi spostati · {size} recuperati", "trash_skipped": "{n} saltato dalla guardia di sicurezza", "export_done": "Esportazione completata", diff --git a/crates/chystik-gui/locales/pl.json b/crates/chystik-gui/locales/pl.json index d0519d7..aad5c06 100644 --- a/crates/chystik-gui/locales/pl.json +++ b/crates/chystik-gui/locales/pl.json @@ -49,6 +49,8 @@ "col_age": "Ostatnio używany", "sort_hint": "Kliknij, aby posortować", "risky_locked_hint": "Elementy ręczne/wartościowe są wykluczone z selekcji zbiorczej — otwórz wiersz, aby celowo przejrzeć", + "version_group_row": "{app} — {n} starszych wersji · {size}", + "version_group_tooltip_lead": "Zainstalowano nowszą wersję {app} — te można bezpiecznie usunąć:", "empty_title": "Nic jeszcze nie zostało zeskanowane", "empty_body": "Chystik znajduje pamięć podręczną, tworzy dane wyjściowe i pliki do pobrania oraz wyjaśnia uprawnienia do odzyskiwania i czyszczenia.", "empty_filtered_title": "Brak dopasowań", @@ -60,14 +62,14 @@ "totals_review_required": "Wymaga sprawdzenia: {n} · {size}", "totals_manual_valuable": "Ręczne / cenne: {n} · {size}", "selected_word": "wybrany", + "select_all": "Zaznacz wszystko", + "select_all_hint": "Zaznacz wszystkie czyszczalne elementy w tej kategorii", "clear": "Jasne", "clear_hint": "Usuń bieżący wybór", "move_to_trash": "Przenieś {size} do Kosza", "move_to_trash_idle": "Przenieś do Kosza", "move_to_trash_hint": "Nic nie jest usuwane: elementy trafiają do kosza na pulpicie i można je przywrócić", "cleanup_unavailable": "Oczyszczanie jest niedostępne na tej platformie, dopóki nie zostaną zweryfikowane natywne kontrole bezpieczeństwa kosza", - "disks": "Dyski", - "disks_hint": "Zamontowane woluminy i stopień ich zapełnienia", "confirm_title": "Przenieś do Kosza", "confirm_sub": "{n} {items} · {size} · do odzyskania z kosza na pulpicie", "guard_will_skip": "{n} zostanie pominięte: ochroniarz odmawia.", @@ -78,7 +80,11 @@ "age_months": "{n} mies", "age_years": "{n} r", "age_unknown": "nieznany", + "trash_progress_title": "Przenoszenie do kosza…", + "trash_progress_count": "{done} z {n} · zwolniono {size}", + "trash_failed_title": "Czyszczenie nie zostało ukończone", "trash_done_title": "Przeniesiono do Kosza", + "trash_done_recovery": "Odzyskiwaniem zajmuje się Twój system operacyjny", "trash_moved": "Przeniesiono {n} elementów · Odzyskano {size}", "trash_skipped": "{n} pominięty przez ochroniarza", "export_done": "Eksport zakończony", diff --git a/crates/chystik-gui/locales/pt_br.json b/crates/chystik-gui/locales/pt_br.json index b728db2..90a03c2 100644 --- a/crates/chystik-gui/locales/pt_br.json +++ b/crates/chystik-gui/locales/pt_br.json @@ -49,6 +49,8 @@ "col_age": "Usado pela última vez", "sort_hint": "Clique para classificar", "risky_locked_hint": "Itens manuais/valiosos são excluídos da seleção em massa — abra a linha para revisar deliberadamente", + "version_group_row": "{app} — {n} versões antigas · {size}", + "version_group_tooltip_lead": "Uma versão mais recente do {app} está instalada — estas podem ser removidas com segurança:", "empty_title": "Nada digitalizado ainda", "empty_body": "Chystik encontra caches, cria saídas e downloads e explica a permissão de recuperação e limpeza.", "empty_filtered_title": "Nenhuma correspondência", @@ -60,14 +62,14 @@ "totals_review_required": "Revisão necessária: {n} · {size}", "totals_manual_valuable": "Manual / valioso: {n} · {size}", "selected_word": "selecionado", + "select_all": "Selecionar tudo", + "select_all_hint": "Selecionar todos os itens limpáveis desta categoria", "clear": "Claro", "clear_hint": "Eliminar a seleção atual", "move_to_trash": "Mover {size} para a Lixeira", "move_to_trash_idle": "Mover para a lixeira", "move_to_trash_hint": "Nada é apagado: os itens vão para a lixeira da sua área de trabalho e podem ser restaurados", "cleanup_unavailable": "A limpeza não estará disponível nesta plataforma até que as verificações de segurança do lixo nativo sejam verificadas", - "disks": "Discos", - "disks_hint": "Volumes montados e quão cheios eles estão", "confirm_title": "Mover para a lixeira", "confirm_sub": "{n} {items} · {size} · recuperáveis da Lixeira", "guard_will_skip": "{n} serão ignorados: o segurança os recusa.", @@ -78,7 +80,11 @@ "age_months": "{n} mês", "age_years": "{n} você", "age_unknown": "desconhecido", + "trash_progress_title": "Movendo para a lixeira…", + "trash_progress_count": "{done} de {n} · {size} liberados", + "trash_failed_title": "A limpeza não foi concluída", "trash_done_title": "Movido para a Lixeira", + "trash_done_recovery": "A recuperação é feita pelo seu sistema operacional", "trash_moved": "{n} itens movidos · {size} recuperados", "trash_skipped": "{n} ignorado pelo segurança", "export_done": "Exportação concluída", diff --git a/crates/chystik-gui/locales/ro.json b/crates/chystik-gui/locales/ro.json index b84c30a..9b0ae13 100644 --- a/crates/chystik-gui/locales/ro.json +++ b/crates/chystik-gui/locales/ro.json @@ -49,6 +49,8 @@ "col_age": "Ultima utilizare", "sort_hint": "Faceți clic pentru a sorta", "risky_locked_hint": "Articolele manuale/valoroase sunt excluse din selecția în bloc — deschideți rândul pentru a le revizui în mod deliberat", + "version_group_row": "{app} — {n} versiuni vechi · {size}", + "version_group_tooltip_lead": "Este instalată o versiune mai nouă de {app} — acestea pot fi eliminate în siguranță:", "empty_title": "Nimic scanat încă", "empty_body": "Chystik găsește memoria cache, build outputs și descărcări și explică atât permisiunea de recuperare, cât și de curățare.", "empty_filtered_title": "Fără meciuri", @@ -60,14 +62,14 @@ "totals_review_required": "Necesită revizuire: {n} · {size}", "totals_manual_valuable": "Manual / valoros: {n} · {size}", "selected_word": "selectat", + "select_all": "Selectează tot", + "select_all_hint": "Selectează toate elementele curățabile din această categorie", "clear": "Clar", "clear_hint": "Renunțați la selecția curentă", "move_to_trash": "Mutați {size} în Coșul de gunoi", "move_to_trash_idle": "Mutați în Coșul de gunoi", "move_to_trash_hint": "Nimic nu este șters: articolele ajung în coșul de gunoi de pe desktop și rămân restaurate", "cleanup_unavailable": "Curățarea nu este disponibilă pe această platformă până când verificările sale native de siguranță pentru gunoi nu sunt verificate", - "disks": "Discuri", - "disks_hint": "Volumele montate și cât de pline sunt", "confirm_title": "Mutați în Coșul de gunoi", "confirm_sub": "{n} {items} · {size} · recuperabile din Coșul de gunoi", "guard_will_skip": "{n} va fi omis: agentul de siguranță le refuză.", @@ -78,7 +80,11 @@ "age_months": "{n} lună", "age_years": "{n} a", "age_unknown": "necunoscut", + "trash_progress_title": "Se mută în coșul de gunoi…", + "trash_progress_count": "{done} din {n} · {size} eliberați", + "trash_failed_title": "Curățarea nu s-a finalizat", "trash_done_title": "Mutat în Coșul de gunoi", + "trash_done_recovery": "Recuperarea este gestionată de sistemul de operare", "trash_moved": "{n} articole mutate · {size} revendicate", "trash_skipped": "{n} omis de agentul de siguranță", "export_done": "Export finalizat", diff --git a/crates/chystik-gui/locales/tr.json b/crates/chystik-gui/locales/tr.json index 0192b92..c697c31 100644 --- a/crates/chystik-gui/locales/tr.json +++ b/crates/chystik-gui/locales/tr.json @@ -49,6 +49,8 @@ "col_age": "Son kullanılan", "sort_hint": "Sıralamak için tıklayın", "risky_locked_hint": "Manuel/değerli öğeler toplu seçimin dışında bırakılır — satırı bilinçli olarak incelemek için açın", + "version_group_row": "{app} — {n} eski sürüm · {size}", + "version_group_tooltip_lead": "{app} uygulamasının daha yeni bir sürümü yüklü — bunlar güvenle kaldırılabilir:", "empty_title": "Henüz taranan bir şey yok", "empty_body": "Chystik önbellekleri bulur, çıktıları ve indirmeleri oluşturur ve hem kurtarma hem de temizleme iznini açıklar.", "empty_filtered_title": "Eşleşme yok", @@ -60,14 +62,14 @@ "totals_review_required": "İnceleme gerekli: {n} · {size}", "totals_manual_valuable": "Manuel / değerli: {n} · {size}", "selected_word": "seçilmiş", + "select_all": "Tümünü seç", + "select_all_hint": "Bu kategorideki temizlenebilir tüm öğeleri seç", "clear": "Temizlemek", "clear_hint": "Geçerli seçimi bırak", "move_to_trash": "{size}'ı Çöp Kutusu'na taşıyın", "move_to_trash_idle": "Çöp Kutusuna Taşı", "move_to_trash_hint": "Hiçbir şey silinmez: öğeler masaüstü çöp kutusuna gider ve geri yüklenebilir durumda kalır", "cleanup_unavailable": "Yerel çöp güvenliği kontrolleri doğrulanana kadar bu platformda temizleme yapılamaz", - "disks": "Diskler", - "disks_hint": "Takılı hacimler ve ne kadar dolu oldukları", "confirm_title": "Çöp Kutusuna Taşı", "confirm_sub": "{n} {items} · {size} · masaüstü çöp kutunuzdan kurtarılabilir", "guard_will_skip": "{n} atlanacak: güvenlik görevlisi bunları reddediyor.", @@ -78,7 +80,11 @@ "age_months": "{n} ay", "age_years": "{n} e", "age_unknown": "bilinmiyor", + "trash_progress_title": "Çöp kutusuna taşınıyor…", + "trash_progress_count": "{n} öğeden {done} tanesi · {size} boşaltıldı", + "trash_failed_title": "Temizlik tamamlanmadı", "trash_done_title": "Çöp Kutusu'na taşındı", + "trash_done_recovery": "Kurtarma işletim sisteminiz tarafından yönetilir", "trash_moved": "{n} öğe taşındı · {size} geri alındı", "trash_skipped": "{n} güvenlik görevlisi tarafından atlandı", "export_done": "Dışa aktarma tamamlandı", diff --git a/crates/chystik-gui/locales/uk.json b/crates/chystik-gui/locales/uk.json index c344b27..4e35cf2 100644 --- a/crates/chystik-gui/locales/uk.json +++ b/crates/chystik-gui/locales/uk.json @@ -49,6 +49,8 @@ "col_age": "Востаннє", "sort_hint": "Натисніть, щоб відсортувати", "risky_locked_hint": "Ручні / цінні об'єкти не входять у масовий вибір — позначте рядок свідомо", + "version_group_row": "{app} — {n} застарілих версій · {size}", + "version_group_tooltip_lead": "Встановлено новішу версію {app} — ці версії можна безпечно видалити:", "empty_title": "Ще нічого не скановано", "empty_body": "Чистик знаходить кеші, результати збірок і завантаження та пояснює відновлення й дозвіл на очищення.", "empty_filtered_title": "Немає збігів", @@ -60,14 +62,14 @@ "totals_review_required": "Потрібна перевірка: {n} · {size}", "totals_manual_valuable": "Ручне / цінне: {n} · {size}", "selected_word": "позначено", + "select_all": "Вибрати всі", + "select_all_hint": "Вибрати всі придатні пункти в цій категорії", "clear": "Очистити", "clear_hint": "Скинути поточний вибір", "move_to_trash": "У кошик: {size}", "move_to_trash_idle": "Перемістити в кошик", "move_to_trash_hint": "Нічого не стирається: об'єкти йдуть у кошик і лишаються відновлюваними", "cleanup_unavailable": "Прибирання недоступне на цій платформі, доки не перевірено безпечну інтеграцію з кошиком", - "disks": "Диски", - "disks_hint": "Змонтовані томи та їхня заповненість", "confirm_title": "Перемістити в кошик", "confirm_sub": "{n} {items} · {size} · можна відновити з кошика", "guard_will_skip": "{n} буде пропущено: захист відмовляє.", @@ -78,7 +80,11 @@ "age_months": "{n} міс", "age_years": "{n} р", "age_unknown": "невідомо", + "trash_progress_title": "Переміщення в кошик…", + "trash_progress_count": "{done} з {n} · звільнено {size}", + "trash_failed_title": "Очищення не завершилося", "trash_done_title": "Переміщено в кошик", + "trash_done_recovery": "Відновлення забезпечує ваша операційна система", "trash_moved": "переміщено {n} · звільнено {size}", "trash_skipped": "{n} пропущено захистом", "export_done": "Експорт завершено", diff --git a/crates/chystik-gui/src/app.rs b/crates/chystik-gui/src/app.rs index f156791..b26cab9 100644 --- a/crates/chystik-gui/src/app.rs +++ b/crates/chystik-gui/src/app.rs @@ -13,6 +13,7 @@ use std::time::{Duration, Instant}; use eframe::egui; use chystik_core::app::AppScanEvent; +use chystik_core::cleaner; use chystik_core::model::{Category, Finding}; use chystik_core::platform::{self, StorageVolume}; @@ -29,10 +30,20 @@ pub(crate) struct ChystikApp { pub(crate) disks: Vec, /// Scan targets offered in the Targets popover. pub(crate) targets: Vec, + /// Canonical roots the last scan actually walked, captured from the scan + /// summary. Findings are stored in this same canonical form (on Windows a + /// verbatim `\\?\` path), so guard/owning-root checks must anchor on these + /// — not on the raw `targets`, whose spelling can differ and would fail a + /// `Path::starts_with` match across the verbatim boundary. + pub(crate) scan_roots: Vec, pub(crate) state: ScanState, pub(crate) rx: Receiver, + /// Deletion lifecycle. Cleaning runs on its own thread so the window + /// keeps painting while a large batch moves to the trash. + pub(crate) clean: CleanState, + pub(crate) findings: Vec, /// Indices into `self.findings` currently ticked by the user. pub(crate) selected: HashSet, @@ -100,6 +111,31 @@ pub(crate) struct ChystikApp { pub(crate) struct Notice { pub(crate) title: String, pub(crate) lines: Vec, + /// Draws the confirming check-mark. Set only when the operation + /// actually did what it says — a run that moved nothing is not a + /// success, however cleanly it failed. + pub(crate) success: bool, + /// When the notice first appeared, so the mark animates exactly once + /// instead of restarting on every repaint. + pub(crate) shown_at: Instant, +} + +impl Notice { + pub(crate) fn info(title: impl Into, lines: Vec) -> Self { + Self { + title: title.into(), + lines, + success: false, + shown_at: Instant::now(), + } + } + + pub(crate) fn success(title: impl Into, lines: Vec) -> Self { + Self { + success: true, + ..Self::info(title, lines) + } + } } const FOOTER_ACTION_HEIGHT: f32 = space(8.0); @@ -126,8 +162,10 @@ impl Default for ChystikApp { lang: i18n::detect(), disks: Vec::new(), targets: Vec::new(), + scan_roots: Vec::new(), state: ScanState::Idle, rx, + clean: CleanState::Idle, findings: Vec::new(), selected: HashSet::new(), deleted: HashSet::new(), @@ -169,6 +207,16 @@ impl ChystikApp { matches!(self.state, ScanState::Scanning { .. }) } + pub(crate) fn cleaning(&self) -> bool { + matches!(self.clean, CleanState::Running { .. }) + } + + /// True while any worker owns the model: neither a scan nor a second + /// cleanup may start, and no row may be re-submitted for deletion. + pub(crate) fn busy(&self) -> bool { + self.scanning() || self.cleaning() + } + pub(crate) fn cleanup_available(&self) -> bool { platform::current().cleanup_support().is_available() } @@ -203,7 +251,7 @@ impl ChystikApp { /// Send the ticked privacy traces to the trash, through the same /// validated flow the cleaner uses. pub(crate) fn clear_selected_traces(&mut self) { - use chystik_core::cleaner::{self, CleanupItem}; + use chystik_core::cleaner::CleanupItem; let items: Vec = self .traces_selected @@ -221,27 +269,7 @@ impl ChystikApp { if items.is_empty() { return; } - let outcome = cleaner::clean(&items, &cleaner::SystemTrash); - let loc = self.s(); - let mut lines = vec![i18n::fill( - loc.trash_moved.as_str(), - &[ - ("n", &outcome.removed_count().to_string()), - ("size", &format_size(outcome.freed_bytes)), - ], - )]; - if outcome.skipped_count() > 0 { - lines.push(i18n::fill( - loc.trash_skipped.as_str(), - &[("n", &outcome.skipped_count().to_string())], - )); - } - self.notice = Some(Notice { - title: loc.trash_done_title.clone(), - lines, - }); - self.traces = chystik_core::privacy::probe(); - self.traces_selected.clear(); + self.start_clean(CleanScope::Traces, items, 0); } /// Localised interface strings for the active language. @@ -284,7 +312,7 @@ impl ChystikApp { pub(crate) fn rebuild_view(&mut self) { let lowered = self.search.trim().to_lowercase(); let needle = (!lowered.is_empty()).then_some(lowered); - let mut rows: Vec = Vec::with_capacity(self.findings.len()); + let mut all_rows: Vec = Vec::with_capacity(self.findings.len()); let mut cleanup_totals = CleanupTotals::default(); let mut stats: HashMap = HashMap::new(); let (mut all_bytes, mut all_count) = (0u64, 0usize); @@ -316,18 +344,86 @@ impl ChystikApp { continue; } cleanup_totals.add(f); - rows.push(i); + all_rows.push(i); } let mut cat_stats: Vec = stats.into_values().collect(); cat_stats.sort_by_key(|c| std::cmp::Reverse(c.bytes)); + + // Several superseded builds of the same tool collapse into one row: + // real siblings of a `GroupRule`'s versioned store, tagged by the + // scanner via `Finding::version_group`. Below two members this adds + // nothing over just showing the row, so it stays a normal single. + let mut group_members: HashMap> = HashMap::new(); + for &i in &all_rows { + if let Some(dir) = &self.findings[i].version_group { + group_members.entry(dir.clone()).or_default().push(i); + } + } + let mut version_groups: Vec = Vec::new(); + let mut grouped: HashSet = HashSet::new(); + for (dir, mut members) in group_members { + if members.len() < 2 { + continue; + } + members.sort_by(|&a, &b| self.findings[b].last_used.cmp(&self.findings[a].last_used)); + let total_bytes = members.iter().map(|&i| self.findings[i].size_bytes).sum(); + let note = self.findings[members[0]].note.clone(); + let severity = self.findings[members[0]].severity; + let app_name = friendly_app_name(¬e, &dir); + grouped.extend(members.iter().copied()); + version_groups.push(VersionGroup { + dir, + app_name, + note, + members, + total_bytes, + severity, + }); + } + version_groups.sort_by_key(|g| std::cmp::Reverse(g.total_bytes)); + let (col, asc) = (self.sort_col, self.sort_asc); - rows.sort_by(|&a, &b| { - let (x, y) = (&self.findings[a], &self.findings[b]); + let findings = &self.findings; + let key_of = |r: &RowRef| -> (&Path, u64, u8, Option>) { + match *r { + RowRef::Single(i) => { + let f = &findings[i]; + ( + f.path.as_path(), + f.size_bytes, + severity_rank(f.severity), + f.last_used, + ) + } + RowRef::Group(gi) => { + let g = &version_groups[gi]; + // The oldest member — `members` is newest-first — is + // exactly what the Age column shows for this row; the + // sort key must agree with it. + let oldest = *g.members.last().expect("a group always has 2+ members"); + ( + g.dir.as_path(), + g.total_bytes, + severity_rank(g.severity), + findings[oldest].last_used, + ) + } + } + }; + let mut rows: Vec = all_rows + .iter() + .copied() + .filter(|i| !grouped.contains(i)) + .map(RowRef::Single) + .chain((0..version_groups.len()).map(RowRef::Group)) + .collect(); + rows.sort_by(|a, b| { + let (ka, kb) = (key_of(a), key_of(b)); let ord = match col { - SortCol::Path => x.path.cmp(&y.path), - SortCol::Size => x.size_bytes.cmp(&y.size_bytes), - SortCol::Severity => severity_rank(x.severity).cmp(&severity_rank(y.severity)), - SortCol::Age => x.last_used.cmp(&y.last_used), + SortCol::Path => ka.0.cmp(kb.0), + SortCol::Size => ka.1.cmp(&kb.1), + SortCol::Severity => ka.2.cmp(&kb.2), + SortCol::Age => ka.3.cmp(&kb.3), }; if asc { ord @@ -337,6 +433,8 @@ impl ChystikApp { }); self.view = ViewCache { rows, + all_rows, + version_groups, cleanup_totals, cat_stats, all_bytes, @@ -443,10 +541,21 @@ impl ChystikApp { self.roots_sig = sig; } - /// Longest configured target containing `path`; anchors guard checks - /// when several targets overlap. + /// Longest scan root containing `path`; anchors guard checks when several + /// targets overlap. + /// + /// Findings are stored in the canonical form the scan walked (on Windows a + /// verbatim `\\?\` path). Match them against `scan_roots`, captured in that + /// same form, rather than the raw `targets`: a target spelled `C:\x` never + /// prefix-matches a `\\?\C:\x` finding, which made the confirm dialog mark + /// every item "refused". `effective_roots` is the fallback only when no + /// scan has recorded its roots yet. pub(crate) fn owning_root(&self, path: &Path) -> Option { - let roots = self.effective_roots(); + let roots = if self.scan_roots.is_empty() { + self.effective_roots() + } else { + self.scan_roots.clone() + }; chystik_core::app::owning_root(&roots, path).map(Path::to_path_buf) } } @@ -454,10 +563,15 @@ impl ChystikApp { impl eframe::App for ChystikApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { self.poll_scanner(ctx); + self.poll_cleaner(ctx); self.ensure_view(); // Section shortcuts, ignored while a dialog owns the keyboard. - if !self.consent_pending && !self.confirm_delete_open && !self.settings_open { + if !self.consent_pending + && !self.confirm_delete_open + && !self.settings_open + && !self.cleaning() + { let jump = ctx.input(|i| { if i.modifiers.ctrl && i.key_pressed(egui::Key::K) { return Some(None); @@ -488,6 +602,10 @@ impl eframe::App for ChystikApp { // Deliberately first and exclusive: no scan, no selection and no // deletion is reachable until this is answered. self.show_consent_modal(ctx); + } else if self.cleaning() { + // Files are moving right now; nothing else may be opened over + // that, and the window keeps painting while it happens. + self.show_clean_progress(ctx); } else if self.privacy_confirm_open { self.show_privacy_confirm(ctx); } else if self.palette_open { @@ -497,7 +615,10 @@ impl eframe::App for ChystikApp { } else if self.confirm_delete_open { self.show_confirm_modal(ctx); } else if let Some(notice) = self.notice.take() { - self.show_notice_modal(ctx, ¬ice); + // By value, and the modal puts it back unless it was dismissed: + // taking it here and dropping it showed the result for exactly + // one frame, which read as the dialog closing on its own. + self.show_notice_modal(ctx, notice); } egui::TopBottomPanel::top("command_bar") @@ -639,6 +760,7 @@ impl ChystikApp { pub(crate) fn reset_results(&mut self) { self.findings.clear(); + self.scan_roots.clear(); self.selected.clear(); self.deleted.clear(); self.dir_count = 0; @@ -655,8 +777,9 @@ impl ChystikApp { pub(crate) fn start_scan(&mut self) { // Belt and braces: the dialog already blocks the button, but the - // headless smoke hook calls this directly. - if self.consent_pending { + // headless smoke hook calls this directly. A scan during a cleanup + // would clear the findings the worker still holds indices into. + if self.consent_pending || self.cleaning() { return; } let roots = self.effective_roots(); @@ -701,10 +824,10 @@ impl ChystikApp { let handle = match spawned { Ok(h) => h, Err(e) => { - self.notice = Some(Notice { - title: self.s().scan_failed.to_string(), - lines: vec![e.to_string()], - }); + self.notice = Some(Notice::info( + self.s().scan_failed.to_string(), + vec![e.to_string()], + )); return; } }; @@ -729,6 +852,11 @@ impl ChystikApp { AppScanEvent::Started { root } => { self.progress_text = format!("{} {}\u{2026}", s.scanning_target.as_str(), root.display()); + // Capture the canonical root now so a cancelled scan + // still resolves the findings it did collect. + if !self.scan_roots.contains(&root) { + self.scan_roots.push(root); + } } AppScanEvent::DirectoriesScanned { count } => { self.dir_count = count.max(self.dir_count); @@ -737,7 +865,11 @@ impl ChystikApp { self.live_bytes += finding.size_bytes; self.findings.push(*finding); } - AppScanEvent::Finished(_) => { + AppScanEvent::Finished(summary) => { + // The canonical roots that produced these findings. + // owning_root/guard checks anchor on these, not the + // raw targets whose spelling may differ. + self.scan_roots = summary.roots; self.view_stamp = None; self.dir_count = 0; self.refresh_disks(); // free space moved during the walk @@ -800,16 +932,16 @@ impl ChystikApp { }; match chystik_core::report::export_json(&self.findings, &path) { Ok(()) => { - self.notice = Some(Notice { - title: self.s().export_done.to_string(), - lines: vec![format!("Report written to {}", path.display())], - }); + self.notice = Some(Notice::success( + self.s().export_done.to_string(), + vec![format!("Report written to {}", path.display())], + )); } Err(e) => { - self.notice = Some(Notice { - title: self.s().export_failed.to_string(), - lines: vec![e.to_string()], - }); + self.notice = Some(Notice::info( + self.s().export_failed.to_string(), + vec![e.to_string()], + )); } } } @@ -846,12 +978,15 @@ impl ChystikApp { } pub(crate) fn execute_trash(&mut self, indices: Vec) { - use chystik_core::cleaner::{self, CleanupItem, SkipReason}; + use chystik_core::cleaner::CleanupItem; + + if self.cleaning() { + return; + } // Excluded and advisory rows are unselectable in the UI; filtering // here as well means an exclusion added after the scan still holds. let mut planned: Vec<(usize, CleanupItem)> = Vec::new(); - let mut errors: Vec = Vec::new(); let mut skipped = 0usize; for idx in indices { let Some(finding) = self.findings.get(idx) else { @@ -876,18 +1011,168 @@ impl ChystikApp { // The guard checks, the identity re-check and the tallying all live // in core, where CI exercises them against a fake remover. let items: Vec = planned.iter().map(|(_, item)| item.clone()).collect(); - let outcome = cleaner::clean(&items, &cleaner::SystemTrash); + let scope = CleanScope::Findings( + planned + .into_iter() + .map(|(idx, item)| (idx, item.path)) + .collect(), + ); + self.start_clean(scope, items, skipped); + } - let removed: std::collections::HashSet<&Path> = - outcome.removed.iter().map(PathBuf::as_path).collect(); - for (idx, item) in &planned { - if removed.contains(item.path.as_path()) { - self.deleted.insert(*idx); + /// Hand `items` to a cleaner thread and switch the window into its + /// progress state. + /// + /// Nothing about the deletion itself changes here: the same flow runs, + /// item for item, with the same guard and identity checks. It simply + /// runs somewhere the UI thread can keep painting past it. + fn start_clean( + &mut self, + scope: CleanScope, + items: Vec, + pre_skipped: usize, + ) { + use chystik_core::cleaner::{CleanupOutcome, SystemTrash}; + + if self.cleaning() { + return; + } + if items.is_empty() { + // Everything was refused before the worker: still report it, + // rather than swallowing the click. + self.finish_clean(scope, CleanupOutcome::default(), pre_skipped); + return; + } + let progress = CleanProgress { + total: items.len(), + total_bytes: items.iter().map(|item| item.size_bytes).sum(), + done: 0, + freed_bytes: 0, + current: None, + }; + let (tx, rx) = channel::(); + let spawned = std::thread::Builder::new() + .name("chystik-cleaner".to_string()) + .spawn(move || { + let event_tx = tx.clone(); + let outcome = cleaner::clean_streaming(&items, &SystemTrash, move |event| { + let _ = event_tx.send(CleanMsg::Event(event)); + }); + let _ = tx.send(CleanMsg::Done(Box::new(outcome))); + }); + match spawned { + Ok(handle) => { + self.notice = None; + self.clean = CleanState::Running { + rx, + handle, + scope, + progress, + pre_skipped, + }; + } + Err(e) => { + self.notice = Some(Notice::info( + self.s().trash_failed_title.clone(), + vec![e.to_string()], + )); } } - skipped += outcome.skipped_count(); + } + + /// Drain the cleaner channel and advance the progress counters. Keeps + /// the window repainting for as long as the worker lives. + pub(crate) fn poll_cleaner(&mut self, ctx: &egui::Context) { + let CleanState::Running { rx, progress, .. } = &mut self.clean else { + return; + }; + let mut outcome: Option> = None; + let mut died = false; + loop { + match rx.try_recv() { + Ok(CleanMsg::Event(event)) => match event { + cleaner::CleanEvent::Started { path, .. } => progress.current = Some(path), + cleaner::CleanEvent::Removed { size_bytes, .. } => { + progress.done += 1; + progress.freed_bytes += size_bytes; + } + cleaner::CleanEvent::Skipped { .. } => progress.done += 1, + }, + Ok(CleanMsg::Done(done)) => { + outcome = Some(done); + break; + } + Err(TryRecvError::Empty) => break, + // The worker sends `Done` before it drops the sender, so a + // disconnect without one means it died mid-batch. + Err(TryRecvError::Disconnected) => { + died = true; + break; + } + } + } + + if outcome.is_none() && !died { + ctx.request_repaint_after(Duration::from_millis(33)); + return; + } + + let CleanState::Running { + handle, + scope, + pre_skipped, + .. + } = std::mem::replace(&mut self.clean, CleanState::Idle) + else { + return; + }; + let _ = handle.join(); + match outcome { + Some(outcome) => self.finish_clean(scope, *outcome, pre_skipped), + None => { + // Say the batch is in an unknown state rather than + // pretending a partial run was a clean result. + if matches!(scope, CleanScope::Traces) { + self.traces = chystik_core::privacy::probe(); + self.traces_selected.clear(); + } + self.refresh_disks(); + self.notice = Some(Notice::info( + self.s().trash_failed_title.clone(), + vec![ + "the cleanup worker stopped before reporting; rescan to see what is left" + .to_owned(), + ], + )); + } + } + } + + /// Apply a finished cleanup to the model and describe it to the user. + fn finish_clean( + &mut self, + scope: CleanScope, + outcome: cleaner::CleanupOutcome, + pre_skipped: usize, + ) { + use chystik_core::cleaner::SkipReason; + + if let CleanScope::Findings(planned) = &scope { + let removed: std::collections::HashSet<&Path> = + outcome.removed.iter().map(PathBuf::as_path).collect(); + for (idx, path) in planned { + if removed.contains(path.as_path()) { + self.deleted.insert(*idx); + } + } + // Drop stale selections after deletion; the table refreshes + // automatically because deleted entries leave the cached view. + self.selected.retain(|i| !self.deleted.contains(i)); + } + + let mut errors: Vec = Vec::new(); for skip in &outcome.skipped { - let path = truncate_middle(&skip.path.display().to_string(), 48); + let path = truncate_middle(&display_path(&skip.path), 48); let detail = match &skip.reason { SkipReason::OutsideEveryTarget => "outside every scan target".to_owned(), SkipReason::Refused => "refused by the safety guard".to_owned(), @@ -896,14 +1181,16 @@ impl ChystikApp { SkipReason::ChangedUnderUs => "changed on disk during the operation".to_owned(), SkipReason::RemoverFailed(e) => e.clone(), }; - eprintln!("[chystik] skipped {}: {detail}", skip.path.display()); + eprintln!("[chystik] skipped {}: {detail}", display_path(&skip.path)); errors.push(format!("{path}: {detail}")); } let (moved, freed) = (outcome.removed_count(), outcome.freed_bytes); + let skipped = pre_skipped + outcome.skipped_count(); - // Drop stale selections after deletion; the table refreshes - // automatically because deleted entries leave the cached view. - self.selected.retain(|i| !self.deleted.contains(i)); + if matches!(scope, CleanScope::Traces) { + self.traces = chystik_core::privacy::probe(); + self.traces_selected.clear(); + } // Free-space numbers changed — re-stat mounts for the header chips. self.refresh_disks(); @@ -912,6 +1199,11 @@ impl ChystikApp { loc.trash_moved.as_str(), &[("n", &moved.to_string()), ("size", &format_size(freed))], )]; + // Make the safety contract explicit: nothing was erased, the OS Trash + // owns recovery — not a bare "Cleaned". + if moved > 0 { + info.push(loc.trash_done_recovery.to_string()); + } if skipped > 0 { info.push(i18n::fill( loc.trash_skipped.as_str(), @@ -919,9 +1211,12 @@ impl ChystikApp { )); } info.extend(errors); - self.notice = Some(Notice { - title: loc.trash_done_title.to_string(), - lines: info, + // The check-mark is a claim about what happened, so it is earned by + // something actually reaching the trash. + self.notice = Some(if moved > 0 { + Notice::success(loc.trash_done_title.to_string(), info) + } else { + Notice::info(loc.trash_done_title.to_string(), info) }); } } @@ -950,9 +1245,54 @@ mod tests { note: "test".into(), advice: None, provenance: None, + version_group: None, } } + #[test] + fn owning_root_resolves_findings_against_the_canonical_scan_roots() { + // Findings are stored in the scan's canonical root form. owning_root + // must consult scan_roots, not the raw targets — otherwise the confirm + // dialog marks every item "refused" because the raw target spelling + // fails a starts_with match against the canonical finding path. + let root = PathBuf::from(if cfg!(windows) { + r"\\?\C:\scanroot" + } else { + "/scanroot" + }); + let item = root.join("proj").join(".dart_tool"); + let mut app = app_with(vec![]); + // No raw targets configured; only the canonical scan roots are known. + app.scan_roots = vec![root.clone()]; + assert_eq!(app.owning_root(&item).as_deref(), Some(root.as_path())); + } + + /// Reproduces the Windows bug directly: findings carry the verbatim `\\?\` + /// root from `canonicalize`, while the raw target keeps the plain `C:\` + /// spelling. The plain target fails `starts_with` on the verbatim finding, + /// so the guard used to refuse everything. `scan_roots` holds the verbatim + /// form and bridges the gap. + #[cfg(target_os = "windows")] + #[test] + fn owning_root_bridges_the_verbatim_prefix_gap() { + let verbatim = PathBuf::from(r"\\?\C:\scanroot"); + let item = verbatim.join("proj").join("node_modules"); + let mut app = app_with(vec![]); + app.targets = vec![ScanTarget { + root: PathBuf::from(r"C:\scanroot"), + label: String::new(), + enabled: true, + user_added: true, + }]; + assert_eq!( + chystik_core::app::owning_root(&app.effective_roots(), &item), + None, + "verbatim finding must not match the plain target — that is the bug" + ); + app.scan_roots = vec![verbatim.clone()]; + assert_eq!(app.owning_root(&item).as_deref(), Some(verbatim.as_path())); + } + #[test] fn footer_action_row_keeps_a_thirty_pixel_gap_below_the_divider() { let footer = egui::Rect::from_min_size(egui::pos2(0.0, 8.0), egui::vec2(800.0, 64.0)); @@ -1012,13 +1352,126 @@ mod tests { assert_eq!(app.view.all_bytes, 99, "the view kept the old contents"); } + /// Two-plus findings sharing a `version_group` collapse into one row, + /// while every member stays reachable through `all_rows` for bulk + /// selection. + #[test] + fn superseded_versions_collapse_into_one_group_row() { + use chrono::{TimeZone, Utc}; + + let dir = PathBuf::from("/home/me/.local/share/claude/versions"); + let mut older = finding("/home/me/.local/share/claude/versions/2.1.216", 100); + older.category = Category::AiAgents; + older.severity = Severity::Moderate; + older.note = "superseded Claude Code build — the current one is kept".into(); + older.version_group = Some(dir.clone()); + older.last_used = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()); + + let mut newer = finding("/home/me/.local/share/claude/versions/2.1.217", 50); + newer.category = Category::AiAgents; + newer.severity = Severity::Moderate; + newer.note = older.note.clone(); + newer.version_group = Some(dir.clone()); + newer.last_used = Some(Utc.with_ymd_and_hms(2026, 2, 1, 0, 0, 0).unwrap()); + + let mut app = app_with(vec![older, newer, finding("/unrelated", 10)]); + app.ensure_view(); + + assert_eq!(app.view.version_groups.len(), 1, "the pair must collapse"); + let group = &app.view.version_groups[0]; + assert_eq!(group.app_name, "Claude Code"); + assert_eq!(group.total_bytes, 150); + assert_eq!(group.members, vec![1, 0], "newest member listed first"); + + let group_rows = app + .view + .rows + .iter() + .filter(|r| matches!(r, RowRef::Group(_))) + .count(); + assert_eq!(group_rows, 1, "exactly one collapsed row, not two singles"); + assert_eq!( + app.view.rows.len(), + 2, + "the group row plus the unrelated finding" + ); + assert_eq!( + app.view.all_rows.len(), + 3, + "bulk selection must still reach every member" + ); + } + + /// The Age column sorts a group row by its oldest member — the same + /// value that column actually displays for that row (see + /// `version_group_tooltip`/the Age cell in `panels::table_ui`). A stale + /// sort key of `None` would silently disagree with what is on screen. + #[test] + fn version_group_sorts_by_its_oldest_members_age() { + use chrono::{TimeZone, Utc}; + + let dir = PathBuf::from("/home/me/.local/share/claude/versions"); + let mut oldest = finding("/home/me/.local/share/claude/versions/2.1.216", 100); + oldest.version_group = Some(dir.clone()); + oldest.last_used = Some(Utc.with_ymd_and_hms(2020, 1, 1, 0, 0, 0).unwrap()); + + let mut newest_superseded = finding("/home/me/.local/share/claude/versions/2.1.217", 50); + newest_superseded.version_group = Some(dir); + newest_superseded.last_used = Some(Utc.with_ymd_and_hms(2026, 1, 1, 0, 0, 0).unwrap()); + + // Between the two members in age: the group's key must place it on + // the OLDEST side, not vanish to whichever end `None` sorts to. + let mut between = finding("/between", 10); + between.last_used = Some(Utc.with_ymd_and_hms(2023, 1, 1, 0, 0, 0).unwrap()); + + let mut app = app_with(vec![oldest, newest_superseded, between]); + app.sort_col = SortCol::Age; + app.sort_asc = true; // oldest first + app.ensure_view(); + + assert_eq!(app.view.version_groups.len(), 1); + assert_eq!( + app.view.rows, + vec![RowRef::Group(0), RowRef::Single(2)], + "the group (2020) must sort before the standalone 2023 finding" + ); + } + + /// A single superseded build is not worth collapsing — nothing to + /// summarize away — so it renders as an ordinary row. + #[test] + fn a_lone_superseded_version_stays_a_normal_row() { + let mut only = finding("/home/me/.codex/packages/standalone/releases/1.0.0", 10); + only.version_group = Some(PathBuf::from( + "/home/me/.codex/packages/standalone/releases", + )); + + let mut app = app_with(vec![only]); + app.ensure_view(); + + assert!(app.view.version_groups.is_empty()); + assert_eq!(app.view.rows.len(), 1); + assert!(matches!(app.view.rows[0], RowRef::Single(0))); + } + /// Every cached index must be addressable, whatever the app has done. #[test] fn cached_rows_never_outlive_their_findings() { let mut app = app_with(vec![finding("/a", 10)]); app.ensure_view(); app.reset_results(); - for &i in &app.view.rows { + for i in &app.view.all_rows { + assert!( + app.findings.get(*i).is_some(), + "row {i} points past the end of findings" + ); + } + // None of the fixtures here set `version_group`, so every row must + // still be a single — this also exercises `RowRef` matching. + for row in &app.view.rows { + let RowRef::Single(i) = *row else { + panic!("unexpected version group with no grouped findings"); + }; assert!( app.findings.get(i).is_some(), "row {i} points past the end of findings" diff --git a/crates/chystik-gui/src/exclusions.rs b/crates/chystik-gui/src/exclusions.rs index 8bcbbe7..f08be33 100644 --- a/crates/chystik-gui/src/exclusions.rs +++ b/crates/chystik-gui/src/exclusions.rs @@ -44,19 +44,23 @@ mod tests { #[test] fn nested_exclusions_collapse_into_their_parent() { + // Use an absolute base valid on the host: a leading `/` is drive-relative + // (not absolute) on Windows, so normalize_exclusions would prepend the + // current drive and the fixture would no longer be a stable literal. + let base = if cfg!(windows) { + PathBuf::from(r"C:\home\u") + } else { + PathBuf::from("/home/u") + }; + let repo = base.join("repo"); + let other = base.join("other"); let list = normalise(vec![ - PathBuf::from("/home/u/repo/app/node_modules"), - PathBuf::from("/home/u/repo"), - PathBuf::from("/home/u/repo"), - PathBuf::from("/home/u/other"), + repo.join("app").join("node_modules"), + repo.clone(), + repo.clone(), + other.clone(), ]); - assert_eq!( - list, - vec![ - PathBuf::from("/home/u/other"), - PathBuf::from("/home/u/repo") - ] - ); + assert_eq!(list, vec![other, repo]); } #[test] diff --git a/crates/chystik-gui/src/format.rs b/crates/chystik-gui/src/format.rs index eb65d3b..4e9e1ce 100644 --- a/crates/chystik-gui/src/format.rs +++ b/crates/chystik-gui/src/format.rs @@ -66,15 +66,6 @@ pub(crate) fn capacity_summary(disks: &[StorageVolume]) -> String { ) } -/// `used / total` usage pair for one volume chip. -pub(crate) fn disk_usage_label(d: &StorageVolume) -> String { - format!( - "{} / {}", - format_size(d.total_bytes.saturating_sub(d.free_bytes)), - format_size(d.total_bytes) - ) -} - /// Middle-truncate a string with an ellipsis, keeping head and tail visible. pub(crate) fn truncate_middle(s: &str, max_chars: usize) -> String { let chars: Vec = s.chars().collect(); @@ -87,6 +78,30 @@ pub(crate) fn truncate_middle(s: &str, max_chars: usize) -> String { format!("{head}\u{2026}{tail}") } +/// User-facing rendering of a filesystem path. +/// +/// On Windows `std::fs::canonicalize` yields a verbatim `\\?\C:\…` path, and +/// findings are stored in that form so deep (>260-char) paths keep working +/// during the walk and cleanup. That prefix is noise to a human — and it also +/// defeats the `~`-home collapse below, since the verbatim spelling no longer +/// prefix-matches the plain home dir. Strip it for display only; never for +/// filesystem use. +pub(crate) fn display_path(path: &std::path::Path) -> String { + strip_verbatim_prefix(&path.display().to_string()) +} + +/// Remove the Windows verbatim `\\?\` (or `\\?\UNC\`) prefix. A no-op on every +/// path that lacks it, so it is safe to call on any platform. +fn strip_verbatim_prefix(text: &str) -> String { + if let Some(rest) = text.strip_prefix(r"\\?\UNC\") { + format!(r"\\{rest}") + } else if let Some(rest) = text.strip_prefix(r"\\?\") { + rest.to_string() + } else { + text.to_string() + } +} + /// Split an absolute path into a dimmable directory prefix and the final /// component. The last component is what identifies a finding, so it gets /// full contrast while the prefix recedes — the one trick that makes a @@ -124,8 +139,6 @@ mod tests { use crate::i18n::Lang; use std::path::PathBuf; - const GB: u64 = 1024 * 1024 * 1024; - /// Minimal volume for the capacity helpers. fn disk(mount: &str, total: u64, free: u64) -> StorageVolume { StorageVolume { @@ -181,6 +194,26 @@ mod tests { assert!(t.contains('\u{2026}')); } + #[test] + fn display_path_strips_the_windows_verbatim_prefix() { + use std::path::Path; + // The prefix findings carry on Windows is noise for a human. + assert_eq!( + display_path(Path::new(r"\\?\C:\Users\me\proj\.dart_tool")), + r"C:\Users\me\proj\.dart_tool" + ); + // UNC verbatim paths collapse back to their `\\server\share` spelling. + assert_eq!( + display_path(Path::new(r"\\?\UNC\server\share\cache")), + r"\\server\share\cache" + ); + // Anything without the prefix is returned unchanged, on any platform. + assert_eq!( + display_path(Path::new("/home/me/.cache")), + "/home/me/.cache" + ); + } + #[test] fn path_tail_joins_last_components() { assert_eq!( @@ -196,12 +229,4 @@ mod tests { assert_eq!(capacity_summary(&disks), "\u{3a3} 800 B \u{b7} 400 B free"); assert_eq!(capacity_summary(&[]), "\u{3a3} 0 B \u{b7} 0 B free"); } - - #[test] - fn disk_usage_label_shows_used_over_total() { - let d = disk("/mnt/data", 3 * GB, GB); - assert_eq!(disk_usage_label(&d), "2.0 GB / 3.0 GB"); - let tiny = disk("/scratch", 512, 512); - assert_eq!(disk_usage_label(&tiny), "0 B / 512 B"); - } } diff --git a/crates/chystik-gui/src/i18n.rs b/crates/chystik-gui/src/i18n.rs index 215d839..c92cbea 100644 --- a/crates/chystik-gui/src/i18n.rs +++ b/crates/chystik-gui/src/i18n.rs @@ -209,6 +209,12 @@ pub struct Strings { pub col_age: String, pub sort_hint: String, pub risky_locked_hint: String, + /// Tail label for a collapsed version-group row: "{app} — {n} older + /// versions · {size}". + pub version_group_row: String, + /// First line of a version-group row's tooltip, naming the app that has + /// a newer build installed. + pub version_group_tooltip_lead: String, pub empty_title: String, pub empty_body: String, pub empty_filtered_title: String, @@ -221,14 +227,14 @@ pub struct Strings { pub totals_review_required: String, pub totals_manual_valuable: String, pub selected_word: String, + pub select_all: String, + pub select_all_hint: String, pub clear: String, pub clear_hint: String, pub move_to_trash: String, pub move_to_trash_idle: String, pub move_to_trash_hint: String, pub cleanup_unavailable: String, - pub disks: String, - pub disks_hint: String, // confirm modal pub confirm_title: String, pub confirm_sub: String, @@ -316,8 +322,13 @@ pub struct Strings { pub privacy_confirm_trash: String, pub privacy_confirm_erase: String, pub privacy_confirm_risky: String, + // progress + pub trash_progress_title: String, + pub trash_progress_count: String, // results + pub trash_failed_title: String, pub trash_done_title: String, + pub trash_done_recovery: String, pub trash_moved: String, pub trash_skipped: String, pub export_done: String, diff --git a/crates/chystik-gui/src/modals.rs b/crates/chystik-gui/src/modals.rs index ae4f13d..e4380a6 100644 --- a/crates/chystik-gui/src/modals.rs +++ b/crates/chystik-gui/src/modals.rs @@ -466,7 +466,7 @@ impl ChystikApp { "mono_sm", if *passed { COL_TEXT } else { COL_TEXT3 }, )) - .on_hover_text(path.display().to_string()); + .on_hover_text(display_path(path)); severity_pill(ui, *sev, lang); ui.end_row(); } @@ -638,11 +638,11 @@ impl ChystikApp { for path in self.exclusions.clone() { ui.horizontal(|ui| { ui.label(txt( - truncate_middle(&path.display().to_string(), 52), + truncate_middle(&display_path(&path), 52), "mono_sm", COL_TEXT, )) - .on_hover_text(path.display().to_string()); + .on_hover_text(display_path(&path)); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -752,8 +752,16 @@ impl ChystikApp { } } - pub(crate) fn show_notice_modal(&mut self, ctx: &egui::Context, notice: &Notice) { + pub(crate) fn show_notice_modal(&mut self, ctx: &egui::Context, notice: Notice) { let mut close = false; + // 300 ms, once: the clock lives on the notice, not on a frame + // counter, so it cannot restart just because something else in the + // window repainted. + let anim_t = (notice.shown_at.elapsed().as_secs_f32() / 0.3).clamp(0.0, 1.0); + if anim_t < 1.0 { + ctx.request_repaint(); + } + dim_backdrop(ctx, "notice_backdrop"); egui::Window::new("notice") .title_bar(false) @@ -769,7 +777,15 @@ impl ChystikApp { ) .show(ctx, |ui| { ui.set_min_width(440.0); - ui.label(txt(¬ice.title, "title", COL_TEXT)); + if notice.success { + ui.horizontal(|ui| { + paint_success_check(ui, 28.0, severity_color(Severity::Safe), anim_t); + ui.add_space(space(2.0)); + ui.label(txt(¬ice.title, "title", COL_TEXT)); + }); + } else { + ui.label(txt(¬ice.title, "title", COL_TEXT)); + } ui.add_space(space(2.0)); for line in ¬ice.lines { ui.label(txt(line, "caption", COL_TEXT2)); @@ -785,8 +801,80 @@ impl ChystikApp { || ctx.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Enter)) { self.notice = None; + } else { + // Not dismissed: this frame's `take()` must not lose it. + self.notice = Some(notice); } } + + /// Cleanup in progress: current item, a bar, and a running count. + /// No cancel — the same manifest already confirmed everything in the + /// batch, and the safety guard still runs per item underneath this. + pub(crate) fn show_clean_progress(&mut self, ctx: &egui::Context) { + let s = self.s(); + let crate::state::CleanState::Running { progress, .. } = &self.clean else { + return; + }; + let done = progress.done; + let total = progress.total; + let freed = progress.freed_bytes; + let total_bytes = progress.total_bytes; + let fraction = progress.fraction(); + let current = progress + .current + .as_ref() + .map(|p| truncate_middle(&display_path(p), 56)); + + ctx.request_repaint(); // an active worker always has more to show + + dim_backdrop(ctx, "clean_progress_backdrop"); + egui::Window::new("clean_progress") + .title_bar(false) + .id(egui::Id::new("clean_progress_window")) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .order(egui::Order::Tooltip) + .resizable(false) + .collapsible(false) + .frame( + egui::Frame::window(&ctx.style()) + .fill(COL_RAISED) + .inner_margin(egui::Margin::same(space(6.0))), + ) + .show(ctx, |ui| { + ui.set_width(440.0); + ui.label(txt(s.trash_progress_title.as_str(), "title", COL_TEXT)); + ui.add_space(space(2.0)); + ui.label(txt( + i18n::fill( + s.trash_progress_count.as_str(), + &[ + ("done", &done.to_string()), + ("n", &total.to_string()), + ( + "size", + &format!("{} / {}", format_size(freed), format_size(total_bytes)), + ), + ], + ), + "caption", + COL_TEXT2, + )); + ui.add_space(space(3.0)); + ui.add( + egui::ProgressBar::new(fraction) + .fill(COL_ACCENT) + .rounding(egui::Rounding::same(R_SM)), + ); + ui.add_space(space(2.0)); + if let Some(current) = current { + ui.label(txt(current, "mono_sm", COL_TEXT3)); + } else { + // Reserve the line's height so the window doesn't jump + // between "no current item yet" and the first one. + ui.label(txt(" ", "mono_sm", COL_TEXT3)); + } + }); + } } /// The settings window must not cover a ComboBox popup. egui renders those diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index 65e1db4..5d086c3 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -254,6 +254,11 @@ impl ChystikApp { (buckets.moderate_bytes, Severity::Moderate), (buckets.risky_bytes, Severity::Risky), ] { + // A zero bucket is noise — show only the classes + // that actually have reclaimable bytes. + if bytes == 0 { + continue; + } paint_severity_glyph(ui, sev, 8.0, severity_color(sev)); ui.label(txt(format_size(bytes), "caption", COL_TEXT2)) .on_hover_text(format!( @@ -267,9 +272,13 @@ impl ChystikApp { }); }); - ui.add_space(space(3.5)); - self.severity_segments_ui(ui); - ui.add_space(space(2.5)); + // No findings yet means nothing to filter — keep the pre-scan + // sidebar clean instead of showing a lone "All" tab. + if !self.findings.is_empty() { + ui.add_space(space(3.5)); + self.severity_segments_ui(ui); + ui.add_space(space(2.5)); + } let selected = self.category_filter; let mut clicked: Option = None; @@ -325,70 +334,101 @@ impl ChystikApp { /// which is what made it wrap in the first place. pub(crate) fn severity_segments_ui(&mut self, ui: &mut egui::Ui) { let (lang, s) = (self.lang, self.s()); - let options = [ - ( - SeverityFilter::All, - s.filter_all.as_str(), - COL_TEXT, - s.filter_all_hint.as_str(), - ), - ( + + // Only offer a recovery-class filter that actually has findings — an + // empty Automatic/Rebuild/Manual tab is just noise. "All" always shows. + let safe_present = self.view.cat_stats.iter().any(|c| c.safe_bytes > 0); + let moderate_present = self.view.cat_stats.iter().any(|c| c.moderate_bytes > 0); + let risky_present = self.view.cat_stats.iter().any(|c| c.risky_bytes > 0); + + // A filter whose class just disappeared would strand the table on an + // empty view, so fall back to All. + let active_present = match self.severity_filter { + SeverityFilter::All => true, + SeverityFilter::One(Severity::Safe) => safe_present, + SeverityFilter::One(Severity::Moderate) => moderate_present, + SeverityFilter::One(Severity::Risky) => risky_present, + }; + if !active_present { + self.severity_filter = SeverityFilter::All; + } + + let mut options: Vec<(SeverityFilter, &str, egui::Color32, &str)> = vec![( + SeverityFilter::All, + s.filter_all.as_str(), + COL_TEXT, + s.filter_all_hint.as_str(), + )]; + if safe_present { + options.push(( SeverityFilter::One(Severity::Safe), i18n::severity_label(lang, Severity::Safe), severity_color(Severity::Safe), s.filter_safe_hint.as_str(), - ), - ( + )); + } + if moderate_present { + options.push(( SeverityFilter::One(Severity::Moderate), s.filter_review.as_str(), severity_color(Severity::Moderate), s.filter_review_hint.as_str(), - ), - ( + )); + } + if risky_present { + options.push(( SeverityFilter::One(Severity::Risky), i18n::severity_label(lang, Severity::Risky), severity_color(Severity::Risky), s.filter_risky_hint.as_str(), - ), - ]; + )); + } - const GAP: f32 = 6.0; - let cell = (SIDEBAR_W - SIDEBAR_PAD * 2.0 - GAP) / 2.0; + const GAP: f32 = 5.0; + let full_w = SIDEBAR_W - SIDEBAR_PAD * 2.0; let mut chosen: Option = None; egui::Frame::none() .inner_margin(egui::Margin::symmetric(SIDEBAR_PAD, 0.0)) .show(ui, |ui| { - ui.spacing_mut().item_spacing = egui::vec2(GAP, GAP); - egui::Grid::new("severity_segments") - .num_columns(2) - .spacing(egui::vec2(GAP, GAP)) - .show(ui, |ui| { - for (i, (value, label, color, hint)) in options.into_iter().enumerate() { - let active = self.severity_filter == value; - let (fill, stroke, fg) = if active { - (COL_ACCENT_SOFT, COL_ACCENT, COL_TEXT) - } else { - (egui::Color32::TRANSPARENT, COL_LINE, color) - }; - if ui - .add( - egui::Button::new(txt(label, "micro", fg)) - .fill(fill) - .stroke(egui::Stroke::new(1.0_f32, stroke)) - .rounding(egui::Rounding::same(R_MD)) - .min_size(egui::vec2(cell, space(6.5))), - ) - .on_hover_text(hint) - .clicked() - { - chosen = Some(value); - } - if i % 2 == 1 { - ui.end_row(); - } - } - }); + ui.spacing_mut().item_spacing = egui::vec2(0.0, GAP); + let micro = ui + .style() + .text_styles + .get(&ts("micro")) + .cloned() + .unwrap_or_default(); + // One thin full-width button per row, label aligned to the + // start. A full-width column fits every label on one line in any + // locale, so nothing can stretch the sidebar the way the old + // 2x2 grid did. + for (value, label, color, hint) in options { + let active = self.severity_filter == value; + let (fill, stroke, fg) = if active { + (COL_ACCENT_SOFT, COL_ACCENT, COL_TEXT) + } else { + (egui::Color32::TRANSPARENT, COL_LINE, color) + }; + let (rect, resp) = ui + .allocate_exact_size(egui::vec2(full_w, space(5.5)), egui::Sense::click()); + let painter = ui.painter(); + painter.rect( + rect, + egui::Rounding::same(R_MD), + fill, + egui::Stroke::new(1.0_f32, stroke), + ); + painter.text( + egui::pos2(rect.left() + space(2.5), rect.center().y), + egui::Align2::LEFT_CENTER, + label, + micro.clone(), + fg, + ); + if resp.on_hover_text(hint).clicked() { + chosen = Some(value); + } + } }); if let Some(value) = chosen { @@ -430,10 +470,13 @@ impl ChystikApp { } }; - // Rows the bulk action may legitimately touch: never Risky. + // Rows the bulk action may legitimately touch: never Risky. Uses + // `all_rows`, not the collapsed `rows`, so a version group's + // members stay reachable by this bulk action even though they no + // longer have a row of their own. let selectable: Vec = self .view - .rows + .all_rows .iter() .copied() .filter(|i| is_bulk_safe_finding(&self.findings[*i])) @@ -524,6 +567,7 @@ impl ChystikApp { pub(crate) fn table_ui(&mut self, ui: &mut egui::Ui) { let (lang, s) = (self.lang, self.s()); let rows = &self.view.rows; + let version_groups = &self.view.version_groups; let scanning = self.scanning(); let findings = &self.findings; let selected = &self.selected; @@ -540,7 +584,9 @@ impl ChystikApp { let mut copied_advice_request = None; if rows.is_empty() { - ui.add_space(space(14.0)); + let pre_scan = !scanning && findings.is_empty(); + ui.add_space(space(if pre_scan { 20.0 } else { 14.0 })); + let mut scan_clicked = false; ui.vertical_centered(|ui| { let (title, body) = if scanning { (s.scanning_title.as_str(), s.scanning_body.as_str()) @@ -552,11 +598,37 @@ impl ChystikApp { s.empty_filtered_body.as_str(), ) }; - ui.label(txt(title, "title", COL_TEXT2)); - ui.add_space(space(1.5)); + ui.label(txt(title, "display", COL_TEXT)); + ui.add_space(space(2.0)); ui.set_max_width(420.0); ui.label(txt(body, "caption", COL_TEXT3)); + // Prominent green call to action on the pre-scan empty screen. + if pre_scan { + ui.add_space(space(6.0)); + let enabled = self.roots_nonempty; + let resp = ui.add_enabled( + enabled, + egui::Button::new(txt( + s.scan.as_str(), + "title", + if enabled { COL_ACCENT_FG } else { COL_TEXT3 }, + )) + .fill(if enabled { + severity_color(Severity::Safe) + } else { + COL_RAISED + }) + .rounding(egui::Rounding::same(R_MD)) + .min_size(egui::vec2(space(44.0), space(12.0))), + ); + if resp.clicked() { + scan_clicked = true; + } + } }); + if scan_clicked { + self.start_scan(); + } return; } @@ -648,15 +720,84 @@ impl ChystikApp { // The per-row `TableBody::row` API renders EVERY row // and froze the window at ~6 fps on a large scan. body.rows(ROW_H, rows.len(), |mut row| { - let idx = rows[row.index()]; - let finding = &findings[idx]; - let risky = finding.severity == Severity::Risky; - let mut checked = selected.contains(&idx); + // Both a plain finding and a collapsed version + // group render through the same fields below — + // extracted once here so the drawing code stays + // written once instead of twice. + let ( + path_for_menu, + head, + tail, + tooltip, + advice, + note, + size_bytes, + severity, + last_used, + actionable, + members, + ) = match rows[row.index()] { + RowRef::Single(idx) => { + let finding = &findings[idx]; + let full = display_path(&finding.path); + // Dim the directory prefix so the eye + // lands on the last component, which is + // what identifies the item. + let (head, tail) = split_path_tail(&full); + ( + finding.path.clone(), + head, + tail, + finding_tooltip(lang, finding), + finding.advice.as_deref(), + finding.note.as_str(), + finding.size_bytes, + finding.severity, + finding.last_used, + finding.is_actionable(), + vec![idx], + ) + } + RowRef::Group(gi) => { + let group = &version_groups[gi]; + let (head, _) = split_path_tail(&display_path(&group.dir)); + let tail = i18n::fill( + s.version_group_row.as_str(), + &[ + ("app", group.app_name.as_str()), + ("n", &group.count().to_string()), + ("size", &format_size(group.total_bytes)), + ], + ); + // Oldest member: the one that has sat + // untouched the longest, which is what + // the Age column means for a single row. + let oldest = *group + .members + .last() + .expect("a version group always has at least two members"); + ( + group.dir.clone(), + head, + tail, + version_group_tooltip(s, group, findings), + None, + group.note.as_str(), + group.total_bytes, + group.severity, + findings[oldest].last_used, + group.members.iter().all(|&i| findings[i].is_actionable()), + group.members.clone(), + ) + } + }; + let risky = severity == Severity::Risky; + let mut checked = members.iter().all(|i| selected.contains(i)); row.col(|ui| { - if !finding.is_actionable() { + if !actionable { paint_info_mark(ui, 13.0, COL_ACCENT) - .on_hover_text(finding_tooltip(lang, finding)); + .on_hover_text(tooltip.as_str()); } else if risky { // Never bulk-selectable: say why instead // of showing a dead checkbox. @@ -669,16 +810,13 @@ impl ChystikApp { ui.allocate_response(egui::Vec2::ZERO, egui::Sense::hover()) .on_hover_text(s.risky_locked_hint.as_str()); } else if ui.checkbox(&mut checked, "").changed() { - row_toggles.push((idx, checked)); + for &m in &members { + row_toggles.push((m, checked)); + } } }); row.col(|ui| { - let full = finding.path.display().to_string(); - // Dim the directory prefix so the eye lands - // on the last component, which is what - // identifies the item. - let (head, tail) = split_path_tail(&full); ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); let row_response = ui.vertical(|ui| { ui.add_space(space(1.0)); @@ -706,12 +844,16 @@ impl ChystikApp { // the path title only. Advisory commands // own their copy tooltip, and two nested // tooltips otherwise fight every frame. - .on_hover_text(finding_tooltip(lang, finding)); + // + // Last use of `tooltip` in this row, so + // it moves here instead of cloning. + .on_hover_text(tooltip); ui.add_space(1.0); - match finding.advice.as_deref() { + match advice { // For advisory rows the command IS // the useful line; the note is in - // the tooltip. + // the tooltip. Never set for a + // version group. Some(command) => { // Clicking copies it: the row is // useless unless the command can @@ -723,15 +865,17 @@ impl ChystikApp { .truncate() .sense(egui::Sense::click()), ); - let copy_hint = if advice_copy_feedback_is_active( + let just_copied = advice_copy_feedback_is_active( copied_advice, - idx, + members[0], now, - ) { + ); + let copy_hint = if just_copied { s.advice_copied.as_str() } else { s.advice_copy.as_str() }; + let hit_rect = hit.rect; if hit .on_hover_cursor(egui::CursorIcon::PointingHand) .on_hover_text(copy_hint) @@ -740,24 +884,50 @@ impl ChystikApp { ui.output_mut(|o| { o.copied_text = command.to_owned() }); - copied_advice_request = Some(idx); + copied_advice_request = Some(members[0]); + } + // The hover tooltip above is + // invisible once the pointer + // leaves after the click, which is + // the common case — so also show a + // floating pill that needs no + // hover to be seen. + if just_copied { + egui::Area::new(egui::Id::new("advice_copy_toast")) + .order(egui::Order::Tooltip) + .fixed_pos( + hit_rect.left_top() + - egui::vec2(0.0, space(3.0)), + ) + .show(ui.ctx(), |ui| { + egui::Frame::popup(ui.style()) + .fill(COL_ACCENT) + .rounding(egui::Rounding::same(R_SM)) + .inner_margin(egui::Margin::symmetric( + space(1.5), + space(0.5), + )) + .show(ui, |ui| { + ui.label(txt( + s.advice_copied.as_str(), + "micro", + COL_RAISED, + )); + }); + }); } } None => { ui.add( - egui::Label::new(txt( - &finding.note, - "micro", - COL_TEXT3, - )) - .truncate(), + egui::Label::new(txt(note, "micro", COL_TEXT3)) + .truncate(), ); } } }); row_response.response.context_menu(|ui| { if ui.button(s.exclusions_add.as_str()).clicked() { - exclude_request = Some(finding.path.clone()); + exclude_request = Some(path_for_menu.clone()); ui.close_menu(); } }); @@ -767,11 +937,7 @@ impl ChystikApp { ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { - ui.label(txt( - format_size(finding.size_bytes), - "mono_lg", - COL_TEXT, - )); + ui.label(txt(format_size(size_bytes), "mono_lg", COL_TEXT)); }, ); }); @@ -779,24 +945,19 @@ impl ChystikApp { row.col(|ui| { ui.with_layout(egui::Layout::top_down(egui::Align::Center), |ui| { ui.add_space((ROW_H - RECOVERY_DOT_SIZE) / 2.0); - recovery_dot(ui, finding.severity, lang); + recovery_dot(ui, severity, lang); }); }); row.col(|ui| { - let stale = finding - .last_used - .is_some_and(|t| (age_now - t).num_days() >= 180); + let stale = + last_used.is_some_and(|t| (age_now - t).num_days() >= 180); let color = if stale { severity_color(Severity::Moderate) } else { COL_TEXT3 }; - ui.label(txt( - age_label(finding.last_used, age_now, s), - "caption", - color, - )); + ui.label(txt(age_label(last_used, age_now, s), "caption", color)); }); }); }); @@ -831,37 +992,64 @@ impl ChystikApp { let selected = self.selected_visible_rows(); let sel_count = selected.len(); let sel_bytes: u64 = selected.iter().map(|(_, f)| f.size_bytes).sum(); - let busy = self.scanning(); + // A cleanup owns the findings just as a scan does: no re-select, + // no second submission while the worker is running. + let busy = self.busy(); let totals = self.view.cleanup_totals; + // Every selectable row in the current category view — what "Select all" + // ticks. Advisory/manual rows are not actionable and stay out, and an + // excluded path is never offered. `all_rows`, not `rows`: a version + // group's members must stay reachable even though they render as + // one collapsed row. + let all_actionable: Vec = self + .view + .all_rows + .iter() + .copied() + .filter(|i| { + self.findings[*i].is_actionable() + && !crate::exclusions::is_excluded(&self.findings[*i].path, &self.exclusions) + }) + .collect(); + ui.horizontal_wrapped(|ui| { if sel_count == 0 { - for (label, count, bytes, color) in [ + for (label, count, bytes, color, always) in [ ( s.totals_found.as_str(), totals.found_count, totals.found_bytes, COL_TEXT3, + true, ), ( s.totals_auto_cleanable.as_str(), totals.auto_cleanable_count, totals.auto_cleanable_bytes, severity_color(Severity::Safe), + false, ), ( s.totals_review_required.as_str(), totals.review_required_count, totals.review_required_bytes, severity_color(Severity::Moderate), + false, ), ( s.totals_manual_valuable.as_str(), totals.manual_count, totals.manual_bytes, severity_color(Severity::Risky), + false, ), ] { + // Keep the grand total always; drop a recovery class that + // has nothing in it rather than printing "0 · 0 B". + if count == 0 && !always { + continue; + } ui.label(txt( i18n::fill( label, @@ -913,15 +1101,16 @@ impl ChystikApp { { self.selected.clear(); } - if !self.disks.is_empty() { - let summary = self - .disks - .iter() - .map(|d| format!("{} {}", d.mount_point.display(), disk_usage_label(d))) - .collect::>() - .join("\n"); - ui.label(txt(s.disks.as_str(), "caption", COL_TEXT3)) - .on_hover_text(format!("{}\n\n{}", s.disks_hint.as_str(), summary)); + // Select every selectable row in the current category. Disabled + // once they are all ticked (use Clear to undo) or when scanning. + let can_select_all = !busy + && !all_actionable.is_empty() + && !all_actionable.iter().all(|i| self.selected.contains(i)); + if ghost_button(ui, s.select_all.as_str(), can_select_all) + .on_hover_text(s.select_all_hint.as_str()) + .clicked() + { + self.selected.extend(all_actionable.iter().copied()); } }); }); @@ -1251,7 +1440,7 @@ impl ChystikApp { ui, &label, severity_color(Severity::Risky), - count > 0 && !self.scanning() && self.cleanup_available(), + count > 0 && !self.busy() && self.cleanup_available(), ) .on_hover_text(if self.cleanup_available() { s.move_to_trash_hint.as_str() @@ -1277,7 +1466,7 @@ impl ChystikApp { fn finding_tooltip(lang: i18n::Lang, finding: &chystik_core::model::Finding) -> String { let strings = i18n::strings(lang); let mut lines = vec![ - finding.path.display().to_string(), + display_path(&finding.path), finding.note.clone(), format!( "{}: {} — {}", @@ -1324,6 +1513,32 @@ fn finding_tooltip(lang: i18n::Lang, finding: &chystik_core::model::Finding) -> lines.join("\n\n") } +/// Explain a collapsed version-group row: which app has a newer build +/// installed, then every superseded build it is offering to remove. +fn version_group_tooltip( + s: &i18n::Strings, + group: &crate::state::VersionGroup, + findings: &[chystik_core::model::Finding], +) -> String { + let mut lines = vec![ + i18n::fill( + s.version_group_tooltip_lead.as_str(), + &[("app", group.app_name.as_str())], + ), + group.note.clone(), + ]; + for &i in &group.members { + let finding = &findings[i]; + let label = finding + .path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| display_path(&finding.path)); + lines.push(format!("• {label} — {}", format_size(finding.size_bytes))); + } + lines.join("\n") +} + /// Bulk selection is a stricter promise than a manual tick: only findings /// that have automatic recovery and the explicit `DirectSafe` policy may enter it. /// A `DirectReview` finding remains available as a deliberate per-row choice. @@ -1346,7 +1561,7 @@ fn usage_bar(ui: &mut egui::Ui, fraction: f32, width: f32, color: egui::Color32) /// The platform home directory collapsed to `~`, which is how paths are recognised. fn short_home_path(path: &std::path::Path) -> String { - let full = path.display().to_string(); + let full = display_path(path); let home = chystik_core::platform::current().app_paths().home_dir; full.strip_prefix(&home.to_string_lossy().into_owned()) .map(|tail| format!("~{tail}")) @@ -1384,6 +1599,7 @@ mod tests { reviewed_at: "2026-08-26".into(), preconditions: vec!["fixture precondition".into()], }), + version_group: None, } } diff --git a/crates/chystik-gui/src/state.rs b/crates/chystik-gui/src/state.rs index 8e42c9d..d47813a 100644 --- a/crates/chystik-gui/src/state.rs +++ b/crates/chystik-gui/src/state.rs @@ -3,9 +3,11 @@ use std::path::{Path, PathBuf}; use std::sync::atomic::AtomicBool; +use std::sync::mpsc::Receiver; use std::sync::Arc; use std::thread::JoinHandle; +use chystik_core::cleaner::{CleanEvent, CleanupOutcome}; use chystik_core::model::{Category, Finding, FindingPolicy, RecoveryClass, Severity}; use chystik_core::platform::StorageVolume; @@ -21,6 +23,65 @@ pub(crate) enum ScanState { }, } +/// What the cleaner thread sends back. The events are progress; the single +/// `Done` carries the authoritative tally the notice is built from, so the +/// UI never has to re-derive it from the events it happened to observe. +pub(crate) enum CleanMsg { + Event(CleanEvent), + Done(Box), +} + +/// Which model a running cleanup has to update when it lands. +pub(crate) enum CleanScope { + /// Rows submitted from the findings table, paired with the index each + /// path came from so the removed ones can be struck off. + Findings(Vec<(usize, PathBuf)>), + /// The privacy view keeps no per-row bookkeeping; it re-probes instead. + Traces, +} + +/// Live counters the progress modal paints. Advanced from `CleanMsg`s on +/// the UI thread, never read across threads. +pub(crate) struct CleanProgress { + pub(crate) total: usize, + pub(crate) total_bytes: u64, + /// Items that reached a terminal event, removed or skipped. + pub(crate) done: usize, + pub(crate) freed_bytes: u64, + /// The path currently being validated and moved. + pub(crate) current: Option, +} + +impl CleanProgress { + /// 0.0..=1.0 by item count. Byte-weighted progress would be a lie: the + /// scanner sizes a directory, it does not time the move. + pub(crate) fn fraction(&self) -> f32 { + if self.total == 0 { + return 1.0; + } + self.done as f32 / self.total as f32 + } +} + +/// Deletion lifecycle, mirroring `ScanState`. +/// +/// Cleaning ran inline on the UI thread until it did not: moving a +/// multi-gigabyte cache to the recycle bin froze the window for the whole +/// batch, so the confirmation dialog looked hung and the result notice +/// flashed past. The work belongs on a worker, reporting as it goes. +pub(crate) enum CleanState { + Idle, + Running { + rx: Receiver, + handle: JoinHandle<()>, + scope: CleanScope, + progress: CleanProgress, + /// Rows the UI itself refused before the worker started (excluded + /// or advisory), folded into the final skipped count. + pre_skipped: usize, + }, +} + /// Column the findings table is currently sorted by. #[derive(Clone, Copy, PartialEq, Eq)] pub(crate) enum SortCol { @@ -106,12 +167,98 @@ pub(crate) struct ViewStamp { pub(crate) sort_asc: bool, } +/// One table row: either a single finding, or several collapsed into one +/// version group. `usize`/`Group` index into `findings`/`version_groups` +/// respectively rather than borrowing, so `ViewCache` stays self-contained +/// and `Copy`-cheap to sort. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum RowRef { + Single(usize), + Group(usize), +} + +/// Several historical builds of the same tool collapsed into one table row. +/// +/// A versioned store (`~/.local/share/claude/versions` and similar — see +/// `chystik_core::rules::GROUP_RULES`) keeps every build it ever fetched +/// while only the newest runs. The scanner already reports each superseded +/// build as its own finding; showing them as N look-alike rows that differ +/// only by a version number in the path buried the actual message ("a +/// newer build exists, these are safe to remove") in repetition. Grouping +/// is purely a display concern — every member is still a real, independent +/// `Finding`, selected and deleted exactly like any other. +pub(crate) struct VersionGroup { + /// The shared parent directory — `Finding::version_group` — and where + /// the "exclude this" context action points. + pub(crate) dir: PathBuf, + /// Human name for the row's headline, e.g. "Claude Code". + pub(crate) app_name: String, + /// The rule's shared note, reused verbatim as the row's sub-line — + /// same convention as an ordinary finding row. + pub(crate) note: String, + /// Indices into `findings`, newest `last_used` first. + pub(crate) members: Vec, + pub(crate) total_bytes: u64, + /// Shared by every member — a `GroupRule` sets one severity for all + /// the builds it supersedes. + pub(crate) severity: Severity, +} + +impl VersionGroup { + pub(crate) fn count(&self) -> usize { + self.members.len() + } +} + +/// Best-effort human name for a versioned store, derived from its rule note +/// first and its own directory name otherwise. Kept beside `GROUP_RULES`' +/// notes in intent, not in code: a new group rule works today, just with a +/// generic label, until its note text (or this table) is taught the name. +pub(crate) fn friendly_app_name(note: &str, dir: &Path) -> String { + const KNOWN: &[(&str, &str)] = &[ + ("Claude Code", "Claude Code"), + ("Codex CLI", "Codex CLI"), + ("Node.js", "Node.js"), + ("Toolbox", "JetBrains Toolbox"), + ]; + for (needle, name) in KNOWN { + if note.contains(needle) { + return (*name).to_owned(); + } + } + // Fall back to the store's own directory name, skipping a generic leaf + // that names the mechanism ("versions", "releases") rather than the + // application that owns it. + const GENERIC_LEAVES: &[&str] = &["versions", "releases", "apps", "toolchains", "packages"]; + let mut components: Vec<&str> = dir + .components() + .filter_map(|c| c.as_os_str().to_str()) + .collect(); + while let Some(last) = components.last() { + if GENERIC_LEAVES.contains(&last.to_lowercase().as_str()) { + components.pop(); + } else { + break; + } + } + components + .last() + .map(|s| s.trim_start_matches('.').to_owned()) + .unwrap_or_else(|| "this app".to_owned()) +} + /// Filtered, sorted view over `findings` plus aggregates derived from it, /// so the per-frame UI work is O(visible rows) instead of O(all findings). #[derive(Default)] pub(crate) struct ViewCache { - /// Indices into `findings` passing filters, in current sort order. - pub(crate) rows: Vec, + /// Table rows in current sort order: singles and version groups mixed. + pub(crate) rows: Vec, + /// Every currently-filtered finding index, singles and group members + /// alike — what a bulk "select all" must cover, since collapsing a + /// group into one `rows` entry must never shrink what bulk selection + /// reaches. + pub(crate) all_rows: Vec, + pub(crate) version_groups: Vec, pub(crate) cleanup_totals: CleanupTotals, /// Per-category rollup over everything the severity/search filters /// admit, IGNORING the category filter — the sidebar must keep showing @@ -257,6 +404,57 @@ impl CleanBuckets { mod tests { use super::*; + #[test] + fn friendly_app_name_recognises_every_shipped_group_rule() { + assert_eq!( + friendly_app_name( + "superseded Claude Code build — the current one is kept", + Path::new("/home/me/.local/share/claude/versions"), + ), + "Claude Code" + ); + assert_eq!( + friendly_app_name( + "superseded Codex CLI build — the current one is kept", + Path::new("/home/me/.codex/packages/standalone/releases"), + ), + "Codex CLI" + ); + assert_eq!( + friendly_app_name( + "older Node.js install — reinstall with `nvm install `", + Path::new("/home/me/.nvm/versions/node"), + ), + "Node.js" + ); + assert_eq!( + friendly_app_name( + "superseded Toolbox build — the current one is kept", + Path::new("/home/me/.local/share/JetBrains/Toolbox/apps"), + ), + "JetBrains Toolbox" + ); + } + + /// A future group rule with no entry in `KNOWN` still gets a readable + /// name, from its own directory rather than the generic leaf. + #[test] + fn friendly_app_name_falls_back_to_the_store_directory() { + assert_eq!( + friendly_app_name( + "superseded Widget build", + Path::new("/home/me/.widget/versions"), + ), + "widget" + ); + // A path that is nothing but a generic leaf leaves no name to fall + // back to. + assert_eq!( + friendly_app_name("no match here", Path::new("versions")), + "this app" + ); + } + fn disk(mount: &str, total: u64, free: u64) -> StorageVolume { StorageVolume { source: format!("/dev/{}", mount.trim_matches('/')), @@ -278,6 +476,7 @@ mod tests { note: String::new(), advice: None, provenance: None, + version_group: None, } } diff --git a/crates/chystik-gui/src/widgets.rs b/crates/chystik-gui/src/widgets.rs index 23d881a..ef3a173 100644 --- a/crates/chystik-gui/src/widgets.rs +++ b/crates/chystik-gui/src/widgets.rs @@ -494,3 +494,69 @@ pub(crate) fn draw_settings_mark(painter: &egui::Painter, c: egui::Pos2, color: painter.circle(egui::pos2(c.x + knob_x, c.y + dy), 2.0, COL_SURFACE, stroke); } } + +/// A ring that draws itself in, then a check-mark that draws itself in, +/// as `t` runs 0.0 (nothing shown yet) to 1.0 (fully drawn). Caller owns +/// the clock — see `Notice::shown_at` — so the animation plays exactly +/// once no matter how many frames repaint while it does. +/// +/// PAINTED for the same reason as the other marks: nothing in IBM Plex +/// reads as unambiguously "done" the way a drawn check does, and drawing +/// it also gets the partial-stroke animation for free. +pub(crate) fn paint_success_check( + ui: &mut egui::Ui, + size: f32, + color: egui::Color32, + t: f32, +) -> egui::Response { + let (rect, response) = ui.allocate_exact_size(egui::vec2(size, size), egui::Sense::hover()); + let c = rect.center(); + let r = size / 2.0 - 1.2; + + // Ring: eased scale-in over the first 45% of the animation. + let ring_t = (t / 0.45).clamp(0.0, 1.0); + let ring_r = r * ease_out_back(ring_t); + if ring_r > 0.0 { + ui.painter() + .circle_stroke(c, ring_r, egui::Stroke::new(1.8_f32, color)); + } + + // Check: two segments, short arm then long arm, drawn as the trailing + // 65% of the animation so the tick visibly follows the ring. + let check_t = ((t - 0.35) / 0.65).clamp(0.0, 1.0); + if check_t > 0.0 { + let p0 = egui::pos2(c.x - r * 0.5, c.y + r * 0.05); + let p1 = egui::pos2(c.x - r * 0.12, c.y + r * 0.42); + let p2 = egui::pos2(c.x + r * 0.55, c.y - r * 0.35); + let stroke = egui::Stroke::new(2.0_f32, color); + let short_len = (p1 - p0).length(); + let long_len = (p2 - p1).length(); + let total = short_len + long_len; + let drawn = total * check_t; + if drawn <= short_len { + let f = if short_len > 0.0 { + drawn / short_len + } else { + 1.0 + }; + ui.painter().line_segment([p0, p0 + (p1 - p0) * f], stroke); + } else { + ui.painter().line_segment([p0, p1], stroke); + let f = if long_len > 0.0 { + (drawn - short_len) / long_len + } else { + 1.0 + }; + ui.painter().line_segment([p1, p1 + (p2 - p1) * f], stroke); + } + } + response +} + +/// A small overshoot-then-settle curve, so the ring pops in rather than +/// just growing linearly. +fn ease_out_back(t: f32) -> f32 { + let c1 = 1.70158_f32; + let c3 = c1 + 1.0; + 1.0 + c3 * (t - 1.0).powi(3) + c1 * (t - 1.0).powi(2) +}