From c8577476df01fb544d1bcb21ae6640a1b8193d0b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 17:55:50 +0300 Subject: [PATCH 01/13] feat(catalog): add Windows app caches and %APPDATA% roaming root Add a roaming-app-data locator root (%APPDATA%) to the schema, platform adapters and resolver, plus 14 evidence-backed rules for Windows app caches: JetBrains IDEs and Toolbox, Android Studio, Dart pub, VS Code, Chrome/Comet, Discord/Notion/Antigravity, Epic and Steam, and Windows crash dumps and temp. Restamp the catalog review date to 2026-08-30 and update the golden rule list (15 -> 29). --- crates/chystik-core/rules/catalog/amd.toml | 4 +- .../rules/catalog/android-studio.toml | 30 +++++++ crates/chystik-core/rules/catalog/apple.toml | 8 +- .../chystik-core/rules/catalog/chromium.toml | 57 ++++++++++++ crates/chystik-core/rules/catalog/cpp.toml | 8 +- crates/chystik-core/rules/catalog/dart.toml | 45 ++++++++++ .../rules/catalog/electron-apps.toml | 77 ++++++++++++++++ .../rules/catalog/game-launchers.toml | 41 +++++++++ .../chystik-core/rules/catalog/jetbrains.toml | 48 ++++++++++ crates/chystik-core/rules/catalog/nvidia.toml | 6 +- crates/chystik-core/rules/catalog/python.toml | 2 +- crates/chystik-core/rules/catalog/vscode.toml | 38 ++++++++ .../chystik-core/rules/catalog/windows.toml | 41 ++++++++- crates/chystik-core/src/platform/linux.rs | 1 + crates/chystik-core/src/platform/macos.rs | 1 + crates/chystik-core/src/platform/mod.rs | 4 + .../chystik-core/src/platform/unsupported.rs | 1 + crates/chystik-core/src/platform/windows.rs | 3 + crates/chystik-core/src/rules/catalog.rs | 90 ++++++++++++++++++- .../chystik-core/src/rules/catalog_schema.rs | 11 +++ 20 files changed, 499 insertions(+), 17 deletions(-) create mode 100644 crates/chystik-core/rules/catalog/android-studio.toml create mode 100644 crates/chystik-core/rules/catalog/chromium.toml create mode 100644 crates/chystik-core/rules/catalog/dart.toml create mode 100644 crates/chystik-core/rules/catalog/electron-apps.toml create mode 100644 crates/chystik-core/rules/catalog/game-launchers.toml create mode 100644 crates/chystik-core/rules/catalog/jetbrains.toml create mode 100644 crates/chystik-core/rules/catalog/vscode.toml 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..9dcaeee --- /dev/null +++ b/crates/chystik-core/rules/catalog/dart.toml @@ -0,0 +1,45 @@ +# 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" 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/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/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/rules/catalog.rs b/crates/chystik-core/src/rules/catalog.rs index f5849b9..6629818 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,90 @@ 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, + ), ]; let mut actual: Vec<_> = catalog_rules() .iter() @@ -747,6 +833,6 @@ mod tests { assert!(!rule.preconditions.is_empty()); assert!(!rule.locator.is_empty()); } - assert_eq!(ids.len(), 15); + assert_eq!(ids.len(), 29); } } 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(); From 063696aef574aad59c1e091ca0e6276b66aca22c Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 17:56:02 +0300 Subject: [PATCH 02/13] test: make Windows-only path assertions platform-aware normalize_roots, the safe-cleanup plan, the exclusion collapse and the JSON scan-roots test assumed Unix path spellings and failed on Windows because of verbatim \\?\ prefixes and drive-relative roots. Compare canonical forms and assert absoluteness portably so the suite is green on every platform. --- crates/chystik-cli/tests/cli.rs | 8 +++++++- crates/chystik-core/tests/application.rs | 16 ++++++++++++++- crates/chystik-gui/src/exclusions.rs | 26 ++++++++++++++---------- 3 files changed, 37 insertions(+), 13 deletions(-) diff --git a/crates/chystik-cli/tests/cli.rs b/crates/chystik-cli/tests/cli.rs index 09c7820..8e6970b 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] diff --git a/crates/chystik-core/tests/application.rs b/crates/chystik-core/tests/application.rs index a63dfae..d76f51c 100644 --- a/crates/chystik-core/tests/application.rs +++ b/crates/chystik-core/tests/application.rs @@ -54,7 +54,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 +136,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/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] From 138971ccee876f2bfe8749ab43b6d5ee0e5659c8 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 17:56:14 +0300 Subject: [PATCH 03/13] fix(gui): repair Windows cleanup and refine the cleanup view Anchor owning_root and the guard on the canonical scan roots so cleanup no longer refuses every item on Windows, and strip the verbatim \\?\ prefix for display only (deep paths keep it). Hide zero-value recovery segments, replace the footer Disks label with a category-wide Select all, stack the severity filters as a left-aligned vertical column so no locale stretches them, and give the empty screen a prominent green Scan button. --- crates/chystik-gui/locales/de.json | 4 +- crates/chystik-gui/locales/en.json | 4 +- crates/chystik-gui/locales/es.json | 4 +- crates/chystik-gui/locales/fr.json | 4 +- crates/chystik-gui/locales/it.json | 4 +- crates/chystik-gui/locales/pl.json | 4 +- crates/chystik-gui/locales/pt_br.json | 4 +- crates/chystik-gui/locales/ro.json | 4 +- crates/chystik-gui/locales/tr.json | 4 +- crates/chystik-gui/locales/uk.json | 4 +- crates/chystik-gui/src/app.rs | 84 +++++++++++++- crates/chystik-gui/src/format.rs | 57 ++++++--- crates/chystik-gui/src/i18n.rs | 4 +- crates/chystik-gui/src/modals.rs | 6 +- crates/chystik-gui/src/panels.rs | 161 ++++++++++++++++++-------- 15 files changed, 257 insertions(+), 95 deletions(-) diff --git a/crates/chystik-gui/locales/de.json b/crates/chystik-gui/locales/de.json index 8408e6b..b78142c 100644 --- a/crates/chystik-gui/locales/de.json +++ b/crates/chystik-gui/locales/de.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/en.json b/crates/chystik-gui/locales/en.json index 6039aa2..b794ece 100644 --- a/crates/chystik-gui/locales/en.json +++ b/crates/chystik-gui/locales/en.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/es.json b/crates/chystik-gui/locales/es.json index 03fb22a..db70362 100644 --- a/crates/chystik-gui/locales/es.json +++ b/crates/chystik-gui/locales/es.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/fr.json b/crates/chystik-gui/locales/fr.json index 450d9d3..667e8fc 100644 --- a/crates/chystik-gui/locales/fr.json +++ b/crates/chystik-gui/locales/fr.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/it.json b/crates/chystik-gui/locales/it.json index 742ebb2..bb8422d 100644 --- a/crates/chystik-gui/locales/it.json +++ b/crates/chystik-gui/locales/it.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/pl.json b/crates/chystik-gui/locales/pl.json index d0519d7..5430c97 100644 --- a/crates/chystik-gui/locales/pl.json +++ b/crates/chystik-gui/locales/pl.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/pt_br.json b/crates/chystik-gui/locales/pt_br.json index b728db2..78f12ca 100644 --- a/crates/chystik-gui/locales/pt_br.json +++ b/crates/chystik-gui/locales/pt_br.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/ro.json b/crates/chystik-gui/locales/ro.json index b84c30a..5dc1d78 100644 --- a/crates/chystik-gui/locales/ro.json +++ b/crates/chystik-gui/locales/ro.json @@ -60,14 +60,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ă.", diff --git a/crates/chystik-gui/locales/tr.json b/crates/chystik-gui/locales/tr.json index 0192b92..8361f89 100644 --- a/crates/chystik-gui/locales/tr.json +++ b/crates/chystik-gui/locales/tr.json @@ -60,14 +60,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.", diff --git a/crates/chystik-gui/locales/uk.json b/crates/chystik-gui/locales/uk.json index c344b27..862301e 100644 --- a/crates/chystik-gui/locales/uk.json +++ b/crates/chystik-gui/locales/uk.json @@ -60,14 +60,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} буде пропущено: захист відмовляє.", diff --git a/crates/chystik-gui/src/app.rs b/crates/chystik-gui/src/app.rs index f156791..041c80b 100644 --- a/crates/chystik-gui/src/app.rs +++ b/crates/chystik-gui/src/app.rs @@ -29,6 +29,12 @@ 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, @@ -126,6 +132,7 @@ impl Default for ChystikApp { lang: i18n::detect(), disks: Vec::new(), targets: Vec::new(), + scan_roots: Vec::new(), state: ScanState::Idle, rx, findings: Vec::new(), @@ -443,10 +450,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) } } @@ -639,6 +657,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; @@ -729,6 +748,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 +761,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 @@ -887,7 +915,7 @@ impl ChystikApp { } skipped += outcome.skipped_count(); 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,7 +924,7 @@ 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); @@ -953,6 +981,50 @@ mod tests { } } + #[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)); diff --git a/crates/chystik-gui/src/format.rs b/crates/chystik-gui/src/format.rs index eb65d3b..038bfb0 100644 --- a/crates/chystik-gui/src/format.rs +++ b/crates/chystik-gui/src/format.rs @@ -66,14 +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 { @@ -87,6 +79,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 @@ -181,6 +197,23 @@ 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..b86e90f 100644 --- a/crates/chystik-gui/src/i18n.rs +++ b/crates/chystik-gui/src/i18n.rs @@ -221,14 +221,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, diff --git a/crates/chystik-gui/src/modals.rs b/crates/chystik-gui/src/modals.rs index ae4f13d..b6af639 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| { diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index 65e1db4..28b0806 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!( @@ -352,43 +357,51 @@ impl ChystikApp { ), ]; - 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 { @@ -540,7 +553,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 +567,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; } @@ -674,7 +715,7 @@ impl ChystikApp { }); row.col(|ui| { - let full = finding.path.display().to_string(); + let full = display_path(&finding.path); // Dim the directory prefix so the eye lands // on the last component, which is what // identifies the item. @@ -834,34 +875,57 @@ impl ChystikApp { let busy = self.scanning(); 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. + let all_actionable: Vec = self + .view + .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 +977,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()); } }); }); @@ -1277,7 +1342,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!( "{}: {} — {}", @@ -1346,7 +1411,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}")) From 554c9e0f1a2385ec5c07ebb10517fac2cbe165d5 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 17:59:54 +0300 Subject: [PATCH 04/13] feat(gui): show only recovery-class filters present in findings Hide the Automatic / Rebuild / Manual filter tabs when no finding has that recovery class, and fall back to All if the active filter's class disappears, so the sidebar never offers an empty tab. --- crates/chystik-gui/src/panels.rs | 51 +++++++++++++++++++++++--------- 1 file changed, 37 insertions(+), 14 deletions(-) diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index 28b0806..d7873eb 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -330,32 +330,55 @@ 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 = 5.0; let full_w = SIDEBAR_W - SIDEBAR_PAD * 2.0; From cde69316f6a2a0c56f2bfdb4440ad82dca9df0fe Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 18:00:50 +0300 Subject: [PATCH 05/13] feat(gui): hide severity filters until a scan has findings The recovery-class filter row is meaningless before a scan, so keep it out of the pre-scan sidebar and show it only once findings exist. --- crates/chystik-gui/src/panels.rs | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index d7873eb..2b99764 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -272,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; From eaa14cb6c99d4fa23511b666b243057af3ae908c Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 18:13:55 +0300 Subject: [PATCH 06/13] fix(cleaner): re-validate at the last instant and test the swap race Re-run guard::check immediately before the destructive call so a path that became a reparse point, a protected location or an escape after the first check is refused rather than acted on. Add paranoid tests for the exact TOCTOU race: a validated directory whose own path is swapped for a junction (Windows) or symlink (Unix) before removal must fail the captured-identity re-check and leave the swap target untouched. --- crates/chystik-core/src/cleaner.rs | 61 ++++++++++++++++++++++++++++++ 1 file changed, 61 insertions(+) diff --git a/crates/chystik-core/src/cleaner.rs b/crates/chystik-core/src/cleaner.rs index 197251f..a6c8f20 100644 --- a/crates/chystik-core/src/cleaner.rs +++ b/crates/chystik-core/src/cleaner.rs @@ -203,6 +203,19 @@ fn clean_with_support( }); continue; } + // 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() { + outcome.skipped.push(Skipped { + path, + reason: SkipReason::Refused, + }); + continue; + } match remover.remove(&path) { Ok(()) => { outcome.freed_bytes += item.size_bytes; @@ -377,6 +390,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)] From 721d0681a53432c3a17217cc6dec13e399e2a23b Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 18:13:55 +0300 Subject: [PATCH 07/13] feat(gui): state OS-handled recovery in the trash-done notice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a line to the cleanup result making the safety contract explicit — items were moved to the Trash/Recycle Bin and recovery is handled by the operating system, not erased. Localized across all ten locales. --- crates/chystik-gui/locales/de.json | 1 + crates/chystik-gui/locales/en.json | 1 + crates/chystik-gui/locales/es.json | 1 + crates/chystik-gui/locales/fr.json | 1 + crates/chystik-gui/locales/it.json | 1 + crates/chystik-gui/locales/pl.json | 1 + crates/chystik-gui/locales/pt_br.json | 1 + crates/chystik-gui/locales/ro.json | 1 + crates/chystik-gui/locales/tr.json | 1 + crates/chystik-gui/locales/uk.json | 1 + crates/chystik-gui/src/app.rs | 5 +++++ crates/chystik-gui/src/i18n.rs | 1 + 12 files changed, 16 insertions(+) diff --git a/crates/chystik-gui/locales/de.json b/crates/chystik-gui/locales/de.json index b78142c..0dcfe00 100644 --- a/crates/chystik-gui/locales/de.json +++ b/crates/chystik-gui/locales/de.json @@ -79,6 +79,7 @@ "age_years": "{n} J", "age_unknown": "unbekannt", "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 b794ece..2e4c125 100644 --- a/crates/chystik-gui/locales/en.json +++ b/crates/chystik-gui/locales/en.json @@ -79,6 +79,7 @@ "age_years": "{n} y", "age_unknown": "unknown", "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 db70362..f740de6 100644 --- a/crates/chystik-gui/locales/es.json +++ b/crates/chystik-gui/locales/es.json @@ -79,6 +79,7 @@ "age_years": "{n} a", "age_unknown": "desconocido", "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 667e8fc..a97b86e 100644 --- a/crates/chystik-gui/locales/fr.json +++ b/crates/chystik-gui/locales/fr.json @@ -79,6 +79,7 @@ "age_years": "{n} oui", "age_unknown": "inconnu", "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 bb8422d..8e663ae 100644 --- a/crates/chystik-gui/locales/it.json +++ b/crates/chystik-gui/locales/it.json @@ -79,6 +79,7 @@ "age_years": "{n} sì", "age_unknown": "sconosciuto", "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 5430c97..f2ee3a8 100644 --- a/crates/chystik-gui/locales/pl.json +++ b/crates/chystik-gui/locales/pl.json @@ -79,6 +79,7 @@ "age_years": "{n} r", "age_unknown": "nieznany", "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 78f12ca..c997572 100644 --- a/crates/chystik-gui/locales/pt_br.json +++ b/crates/chystik-gui/locales/pt_br.json @@ -79,6 +79,7 @@ "age_years": "{n} você", "age_unknown": "desconhecido", "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 5dc1d78..43961af 100644 --- a/crates/chystik-gui/locales/ro.json +++ b/crates/chystik-gui/locales/ro.json @@ -79,6 +79,7 @@ "age_years": "{n} a", "age_unknown": "necunoscut", "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 8361f89..83c2391 100644 --- a/crates/chystik-gui/locales/tr.json +++ b/crates/chystik-gui/locales/tr.json @@ -79,6 +79,7 @@ "age_years": "{n} e", "age_unknown": "bilinmiyor", "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 862301e..400c3a0 100644 --- a/crates/chystik-gui/locales/uk.json +++ b/crates/chystik-gui/locales/uk.json @@ -79,6 +79,7 @@ "age_years": "{n} р", "age_unknown": "невідомо", "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 041c80b..4784fe0 100644 --- a/crates/chystik-gui/src/app.rs +++ b/crates/chystik-gui/src/app.rs @@ -940,6 +940,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(), diff --git a/crates/chystik-gui/src/i18n.rs b/crates/chystik-gui/src/i18n.rs index b86e90f..5a86aaa 100644 --- a/crates/chystik-gui/src/i18n.rs +++ b/crates/chystik-gui/src/i18n.rs @@ -318,6 +318,7 @@ pub struct Strings { pub privacy_confirm_risky: String, // results pub trash_done_title: String, + pub trash_done_recovery: String, pub trash_moved: String, pub trash_skipped: String, pub export_done: String, From 0904744225d808f5b40b49cecefbde825b798927 Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 18:25:34 +0300 Subject: [PATCH 08/13] build(gui): drop egui_extras all_loaders to shed the unused SVG stack Only TableBuilder is used and PNG assets decode through `image` directly, so the image/svg loader features were dead weight. Dropping all_loaders removes resvg/usvg/ttf-parser and one quick-xml consumer (~33 crates), trimming build time and attack surface. The remaining quick-xml advisory (RUSTSEC-2026-0194/0195) is a Linux-only accessibility (AT-SPI) transitive dep parsing local D-Bus XML and is not reachable on Windows; a full bump needs a newer eframe/accesskit and is left as a separate change. --- Cargo.lock | 318 +--------------------------------- crates/chystik-gui/Cargo.toml | 9 +- 2 files changed, 8 insertions(+), 319 deletions(-) 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-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] From 855df57f507edec3113067232fa58a07f88fb9cf Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 22:42:55 +0300 Subject: [PATCH 09/13] fix(rules): strip \?\ verbatim prefix so Windows catalog matches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The scanner canonicalizes its roots for guard safety, which on Windows hands every walked entry to the classifier as a verbatim `\?\C:\...` path. Every rule table — catalog `exact` targets, `$HOME`-relative rules, group rules — is built from plain `C:\...` roots, so `path_eq`, `strip_prefix(home)` and `is_under` all silently failed and the entire home-anchored catalog matched nothing on Windows. Only filename-based marker rules (node_modules, __pycache__, ...) survived, which is why real caches such as ~/.cache/huggingface (13 GB) or %LOCALAPPDATA%\.dartServer never appeared in a scan. Normalize the incoming path once at the classifier boundary (RuleEngine::classify_with_metadata and classify_group) by stripping the verbatim prefix, so matching sees the same spelling the tables use while the untouched real path is still what the finding reports. Add a cross-platform regression test that feeds a verbatim-prefixed path and a unit test for the stripping itself. --- crates/chystik-core/src/rules/mod.rs | 66 ++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) 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() { From 84dd4588427467c4ca6779c51c1ab6c9bec44a4d Mon Sep 17 00:00:00 2001 From: pasichDev Date: Sun, 30 Aug 2026 22:43:05 +0300 Subject: [PATCH 10/13] feat(rules): catch Flutter build/, .dartServer and the Hugging Face cache Ground three large, previously-missed developer caches in real disk data (measured on a Windows dev box): - Flutter/Dart `build/` (hundreds of MB per project): the `dist`/`build` marker arm only fired on package.json/pyproject.toml/CMake evidence, so Flutter output next to `pubspec.yaml` was skipped. Add a dedicated arm. - `.dartServer` (~1.3 GB): the Dart Analysis Server cache dart.dev documents at %LOCALAPPDATA%\.dartServer; rebuilt on the next launch, so automatic recovery / auto-cleanable. - `~/.cache/huggingface` (~13 GB here): Hugging Face Hub model/dataset cache, re-downloaded on demand; review-required because a refetch can be many gigabytes. Each rule carries a real HTTPS provenance source. Update the golden rule list and count (29 -> 31). --- crates/chystik-core/rules/catalog/dart.toml | 34 ++++++++++++++++++ .../rules/catalog/huggingface.toml | 36 +++++++++++++++++++ crates/chystik-core/src/rules/catalog.rs | 14 +++++++- crates/chystik-core/src/rules/core.rs | 6 ++++ 4 files changed, 89 insertions(+), 1 deletion(-) create mode 100644 crates/chystik-core/rules/catalog/huggingface.toml diff --git a/crates/chystik-core/rules/catalog/dart.toml b/crates/chystik-core/rules/catalog/dart.toml index 9dcaeee..1cc6647 100644 --- a/crates/chystik-core/rules/catalog/dart.toml +++ b/crates/chystik-core/rules/catalog/dart.toml @@ -43,3 +43,37 @@ path = ".pub-cache" 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/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/src/rules/catalog.rs b/crates/chystik-core/src/rules/catalog.rs index 6629818..93aa675 100644 --- a/crates/chystik-core/src/rules/catalog.rs +++ b/crates/chystik-core/src/rules/catalog.rs @@ -576,6 +576,18 @@ mod tests { 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() @@ -833,6 +845,6 @@ mod tests { assert!(!rule.preconditions.is_empty()); assert!(!rule.locator.is_empty()); } - assert_eq!(ids.len(), 29); + assert_eq!(ids.len(), 31); } } 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. From 2f92b4dd5319f91cef8413544eeedfee9c39cdae Mon Sep 17 00:00:00 2001 From: pasichDev Date: Mon, 31 Aug 2026 10:38:03 +0300 Subject: [PATCH 11/13] feat(gui): stream cleanup off the UI thread and group superseded versions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two independent fixes to the cleanup flow: - Moving findings to Trash ran cleaner::clean() inline on the UI thread, so a large batch froze the window for the whole operation and the result notice, taken via Option::take() and never restored, flashed for a single frame instead of staying open. Add chystik_core::cleaner::clean_streaming(), which reports a Started/Removed/Skipped event per item; the GUI now runs it on a worker thread (mirroring the existing scan worker), paints a progress modal from the events, and shows a result notice with an animated success check-mark (paint_success_check) only when something actually moved. clean() is now clean_streaming() with the events dropped, so the validate/identity-check/guard flow itself is unchanged. - A versioned store (~/.local/share/claude/versions and friends, see rules::GROUP_RULES) keeps every build it ever fetched while only the newest runs, so a scan reported each superseded build as its own finding — N look-alike rows differing only by a version number. Tag such findings with their shared store directory (Finding::version_group) and collapse 2+ of them into one table row ("Claude Code — 2 older versions · 128 MB") with a tooltip listing each build. Grouping is display-only: every member is still a real, independently selectable and deletable Finding, and bulk-select actions read the pre-collapse index list (ViewCache::all_rows) so collapsing a group never shrinks what "Select all" reaches. Adds GUI-side tests for both (worker lifecycle covered by the existing scan-worker pattern; grouping, sorting and the Age-column key covered in crates/chystik-gui/src/app.rs). --- crates/chystik-cli/src/tui.rs | 1 + crates/chystik-core/src/advisories.rs | 1 + crates/chystik-core/src/cleaner.rs | 249 ++++++++--- crates/chystik-core/src/model.rs | 12 + crates/chystik-core/src/report.rs | 1 + crates/chystik-core/src/scanner.rs | 2 + crates/chystik-core/tests/application.rs | 1 + crates/chystik-gui/locales/de.json | 5 + crates/chystik-gui/locales/en.json | 5 + crates/chystik-gui/locales/es.json | 5 + crates/chystik-gui/locales/fr.json | 5 + crates/chystik-gui/locales/it.json | 5 + crates/chystik-gui/locales/pl.json | 5 + crates/chystik-gui/locales/pt_br.json | 5 + crates/chystik-gui/locales/ro.json | 5 + crates/chystik-gui/locales/tr.json | 5 + crates/chystik-gui/locales/uk.json | 5 + crates/chystik-gui/src/app.rs | 500 ++++++++++++++++++++--- crates/chystik-gui/src/format.rs | 6 +- crates/chystik-gui/src/i18n.rs | 10 + crates/chystik-gui/src/modals.rs | 92 ++++- crates/chystik-gui/src/panels.rs | 180 ++++++-- crates/chystik-gui/src/state.rs | 203 ++++++++- crates/chystik-gui/src/widgets.rs | 66 +++ 24 files changed, 1206 insertions(+), 168 deletions(-) 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-core/src/advisories.rs b/crates/chystik-core/src/advisories.rs index 04a7e52..42720f1 100644 --- a/crates/chystik-core/src/advisories.rs +++ b/crates/chystik-core/src/advisories.rs @@ -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 a6c8f20..179bbf2 100644 --- a/crates/chystik-core/src/cleaner.rs +++ b/crates/chystik-core/src/cleaner.rs @@ -151,85 +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, + 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; - }; - if guard::check(&path, root).is_err() { outcome.skipped.push(Skipped { - path, - reason: SkipReason::Refused, + path: item.path.clone(), + reason, }); - 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, - }); - continue; - } - // 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() { - outcome.skipped.push(Skipped { - path, - reason: SkipReason::Refused, - }); - 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::*; @@ -247,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. @@ -302,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(); @@ -315,6 +451,7 @@ mod tests { CleanupSupport::ScanOnly { reason: "native trash has not been verified", }, + &mut |_| {}, ); assert!(remover.seen().is_empty()); 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/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/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 d76f51c..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, } } diff --git a/crates/chystik-gui/locales/de.json b/crates/chystik-gui/locales/de.json index 0dcfe00..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/en.json b/crates/chystik-gui/locales/en.json index 2e4c125..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/es.json b/crates/chystik-gui/locales/es.json index f740de6..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/fr.json b/crates/chystik-gui/locales/fr.json index a97b86e..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/it.json b/crates/chystik-gui/locales/it.json index 8e663ae..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/pl.json b/crates/chystik-gui/locales/pl.json index f2ee3a8..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ń", @@ -78,6 +80,9 @@ "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}", diff --git a/crates/chystik-gui/locales/pt_br.json b/crates/chystik-gui/locales/pt_br.json index c997572..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/ro.json b/crates/chystik-gui/locales/ro.json index 43961af..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", @@ -78,6 +80,9 @@ "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", diff --git a/crates/chystik-gui/locales/tr.json b/crates/chystik-gui/locales/tr.json index 83c2391..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", @@ -78,6 +80,9 @@ "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ı", diff --git a/crates/chystik-gui/locales/uk.json b/crates/chystik-gui/locales/uk.json index 400c3a0..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": "Немає збігів", @@ -78,6 +80,9 @@ "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}", diff --git a/crates/chystik-gui/src/app.rs b/crates/chystik-gui/src/app.rs index 4784fe0..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}; @@ -39,6 +40,10 @@ pub(crate) struct ChystikApp { 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, @@ -106,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); @@ -135,6 +165,7 @@ impl Default for ChystikApp { scan_roots: Vec::new(), state: ScanState::Idle, rx, + clean: CleanState::Idle, findings: Vec::new(), selected: HashSet::new(), deleted: HashSet::new(), @@ -176,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() } @@ -210,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 @@ -228,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. @@ -291,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); @@ -323,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 @@ -344,6 +433,8 @@ impl ChystikApp { }); self.view = ViewCache { rows, + all_rows, + version_groups, cleanup_totals, cat_stats, all_bytes, @@ -472,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); @@ -506,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 { @@ -515,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") @@ -674,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(); @@ -720,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; } }; @@ -828,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()], + )); } } } @@ -874,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 { @@ -904,16 +1011,166 @@ 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(&display_path(&skip.path), 48); let detail = match &skip.reason { @@ -928,10 +1185,12 @@ impl ChystikApp { 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(); @@ -952,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) }); } } @@ -983,6 +1245,7 @@ mod tests { note: "test".into(), advice: None, provenance: None, + version_group: None, } } @@ -1089,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/format.rs b/crates/chystik-gui/src/format.rs index 038bfb0..17ce38f 100644 --- a/crates/chystik-gui/src/format.rs +++ b/crates/chystik-gui/src/format.rs @@ -66,7 +66,6 @@ pub(crate) fn capacity_summary(disks: &[StorageVolume]) -> String { ) } - /// 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(); @@ -211,7 +210,10 @@ mod tests { 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"); + assert_eq!( + display_path(Path::new("/home/me/.cache")), + "/home/me/.cache" + ); } #[test] diff --git a/crates/chystik-gui/src/i18n.rs b/crates/chystik-gui/src/i18n.rs index 5a86aaa..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, @@ -316,7 +322,11 @@ 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, diff --git a/crates/chystik-gui/src/modals.rs b/crates/chystik-gui/src/modals.rs index b6af639..e4380a6 100644 --- a/crates/chystik-gui/src/modals.rs +++ b/crates/chystik-gui/src/modals.rs @@ -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 2b99764..66776d8 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -470,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])) @@ -564,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; @@ -716,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. @@ -737,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 = 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); ui.spacing_mut().item_spacing = egui::vec2(0.0, 0.0); let row_response = ui.vertical(|ui| { ui.add_space(space(1.0)); @@ -774,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 @@ -793,7 +867,7 @@ impl ChystikApp { ); let copy_hint = if advice_copy_feedback_is_active( copied_advice, - idx, + members[0], now, ) { s.advice_copied.as_str() @@ -808,24 +882,20 @@ impl ChystikApp { ui.output_mut(|o| { o.copied_text = command.to_owned() }); - copied_advice_request = Some(idx); + copied_advice_request = Some(members[0]); } } 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(); } }); @@ -835,11 +905,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)); }, ); }); @@ -847,24 +913,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)); }); }); }); @@ -899,15 +960,19 @@ 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. + // 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 - .rows + .all_rows .iter() .copied() .filter(|i| { @@ -1343,7 +1408,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() @@ -1416,6 +1481,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. @@ -1476,6 +1567,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) +} From f18ba50d289c8fb169e17a6fd9a309baa3689a15 Mon Sep 17 00:00:00 2001 From: pasichDev <67899666+pasichDev@users.noreply.github.com> Date: Mon, 14 Sep 2026 12:59:28 +0300 Subject: [PATCH 12/13] fix: require sudo for the flatpak advisory command, add visible copy feedback The flatpak advisory hint (/var/lib/flatpak/repo) ran without sudo or --system, so it silently targeted the nonexistent per-user flatpak repo instead of the root-owned system one, failing with an "openat(config): No such file or directory" error. The advisory command copy-to-clipboard feedback relied solely on a hover tooltip, which disappears the moment the pointer leaves after the click that triggered it. Add a floating pill next to the row that shows without requiring continued hover. --- crates/chystik-core/src/advisories.rs | 2 +- crates/chystik-gui/src/panels.rs | 38 +++++++++++++++++++++++++-- 2 files changed, 37 insertions(+), 3 deletions(-) diff --git a/crates/chystik-core/src/advisories.rs b/crates/chystik-core/src/advisories.rs index 42720f1..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 { diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index 66776d8..0b8021a 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -865,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, 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) @@ -884,6 +886,38 @@ impl ChystikApp { }); 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( From ef889b6760ab260f9c207600ec7043e73fe185c5 Mon Sep 17 00:00:00 2001 From: pasichDev <67899666+pasichDev@users.noreply.github.com> Date: Mon, 14 Sep 2026 13:13:34 +0300 Subject: [PATCH 13/13] =?UTF-8?q?fix:=20unbreak=20CI=20=E2=80=94=20rustfmt?= =?UTF-8?q?,=20dead-code,=20and=20a=20stale=20test=20fixture=20date?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cargo fmt the copy-feedback overlay from the previous commit; the hand-formatted chained calls didn't match rustfmt's output. - Drop the unused `GB` test constant in format.rs — dead_code under -D warnings, breaking every native-platform test build (Windows, macOS). - python.pip.cache's reviewed_at moved to 2026-08-30 earlier in this branch (Windows app-cache catalog work); two CLI tests still asserted the old 2026-08-26 value. --- crates/chystik-cli/tests/cli.rs | 4 +-- crates/chystik-gui/src/format.rs | 2 -- crates/chystik-gui/src/panels.rs | 46 +++++++++++++++----------------- 3 files changed, 24 insertions(+), 28 deletions(-) diff --git a/crates/chystik-cli/tests/cli.rs b/crates/chystik-cli/tests/cli.rs index 8e6970b..1089168 100644 --- a/crates/chystik-cli/tests/cli.rs +++ b/crates/chystik-cli/tests/cli.rs @@ -240,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())); @@ -264,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-gui/src/format.rs b/crates/chystik-gui/src/format.rs index 17ce38f..4e9e1ce 100644 --- a/crates/chystik-gui/src/format.rs +++ b/crates/chystik-gui/src/format.rs @@ -139,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 { diff --git a/crates/chystik-gui/src/panels.rs b/crates/chystik-gui/src/panels.rs index 0b8021a..5d086c3 100644 --- a/crates/chystik-gui/src/panels.rs +++ b/crates/chystik-gui/src/panels.rs @@ -893,30 +893,28 @@ impl ChystikApp { // 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, - )); - }); - }); + 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 => {