From e64da05f97d905ae8c90c44b43f77152d6a639bc Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:47:51 +0100 Subject: [PATCH 01/15] cleanup: skip .Trash/.Trashes in walk() to avoid offering already-trashed items Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/cleanup.rs b/src/cleanup.rs index e467d32..d5f1cde 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -423,6 +423,10 @@ fn walk(tree: &FileTree, id: NodeId, out: &mut Vec) { return; } let path = tree.full_path(id); + // Don't enter Trash dirs — candidates inside would be already-trashed items. + if matches!(path.file_name().and_then(|n| n.to_str()), Some(".Trash") | Some(".Trashes")) { + return; + } if let Some((category, description)) = classify(&path) { let size = node.size; // Threshold: ignore tiny matches — they're noise. From 12884e96d1c71b5539ada05db54da5ba10411e37 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:48:21 +0100 Subject: [PATCH 02/15] relocate: refuse ~/.Trash and /Volumes/*/.Trashes in protected_reason Co-Authored-By: Claude Sonnet 4.6 --- src/relocate.rs | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/relocate.rs b/src/relocate.rs index b5e25ef..95ba60e 100644 --- a/src/relocate.rs +++ b/src/relocate.rs @@ -88,6 +88,15 @@ pub fn protected_reason(path: &Path, home: Option<&Path>) -> Option { if path == home.join("Library") { return Some("refusing to move ~/Library itself; pick something inside it".into()); } + if path == home.join(".Trash") { + return Some("refusing to touch ~/.Trash; use Empty Trash in Finder".into()); + } + } + // /Volumes//.Trashes — trashing it nests Trash inside itself. + if path.file_name().and_then(|n| n.to_str()) == Some(".Trashes") + && path.parent().map_or(false, |p| p.starts_with("/Volumes")) + { + return Some("refusing to touch .Trashes on a volume; use Empty Trash in Finder".into()); } None } From ceefa22f827c92699f0ab2c5a6e00c43aff532f9 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:48:54 +0100 Subject: [PATCH 03/15] flywheel: add app_open and scan_completed to TAXONOMY Co-Authored-By: Claude Sonnet 4.6 --- src/flywheel.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/flywheel.rs b/src/flywheel.rs index ae6e219..110d996 100644 --- a/src/flywheel.rs +++ b/src/flywheel.rs @@ -22,7 +22,8 @@ use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; -/// The shared event taxonomy (mirrors `TAXONOMY` in flywheel-client.ts). +/// The shared event taxonomy (mirrors `TAXONOMY` in flywheel-client.ts) plus +/// app-specific events fired by this binary. pub const TAXONOMY: &[&str] = &[ "page_view", "signup_started", @@ -31,6 +32,8 @@ pub const TAXONOMY: &[&str] = &[ "key_action", "feedback_given", "error", + "app_open", + "scan_completed", ]; #[derive(Clone)] @@ -394,6 +397,8 @@ mod tests { assert!(TAXONOMY.contains(&"conversion")); assert!(TAXONOMY.contains(&"feedback_given")); assert!(TAXONOMY.contains(&"error")); - assert_eq!(TAXONOMY.len(), 7); + assert!(TAXONOMY.contains(&"app_open")); + assert!(TAXONOMY.contains(&"scan_completed")); + assert_eq!(TAXONOMY.len(), 9); } } From 2c7e86105a53afc8630c9f14700e6b3055dc93d9 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:49:07 +0100 Subject: [PATCH 04/15] ci: run cargo test before building the release .app Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 22637cf..ac5a4a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,6 +66,9 @@ jobs: echo "signed=true" >> "$GITHUB_OUTPUT" rm -f "$RUNNER_TEMP/cert.p12" + - name: Run tests + run: cargo test + - name: Build .app env: # Sign when an identity was imported above. From 1a2a630d36bf4a7ab0261f6fa2f34c95c23b566a Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:50:11 +0100 Subject: [PATCH 05/15] dir_tree: extract truncate_label helper, add founder-case-2 test for non-ASCII Co-Authored-By: Claude Sonnet 4.6 --- src/ui/dir_tree.rs | 36 ++++++++++++++++++++++++++++++------ 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index d0f0bff..3a5b3b7 100644 --- a/src/ui/dir_tree.rs +++ b/src/ui/dir_tree.rs @@ -470,12 +470,7 @@ fn row_ui( } let name = tree.name(row.id); let max_chars = (((text_right - name_x) - 6.0) / 6.5).max(0.0) as usize; - let shown = if name.chars().count() > max_chars && max_chars > 3 { - let s: String = name.chars().take(max_chars - 1).collect(); - format!("{s}…") - } else { - name.to_string() - }; + let shown = truncate_label(&name, max_chars); painter.text( Pos2::new(name_x, rect.center().y), egui::Align2::LEFT_CENTER, @@ -522,6 +517,17 @@ fn size_and_bar(painter: &egui::Painter, rect: Rect, size: u64, scale: u64, colo x } +/// Truncate `name` to `max_chars` characters, appending `…` if truncated. +/// Uses char boundaries so multi-byte Unicode names never panic or split. +fn truncate_label(name: &str, max_chars: usize) -> String { + if name.chars().count() > max_chars && max_chars > 3 { + let s: String = name.chars().take(max_chars - 1).collect(); + format!("{s}…") + } else { + name.to_string() + } +} + /// Compact item-count formatting: 1234 → "1.2k", 12 → "12". fn format_count(n: u64) -> String { if n >= 1_000_000 { @@ -532,3 +538,21 @@ fn format_count(n: u64) -> String { n.to_string() } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn truncate_label_non_ascii_cuts_on_char_boundary() { + // Founder case 2: a non-ASCII name must truncate on a char boundary + // without panicking and must produce valid UTF-8. + let result = truncate_label("Příliš.pdf", 5); + assert!(result.ends_with('…'), "expected ellipsis: {result}"); + assert!(std::str::from_utf8(result.as_bytes()).is_ok()); + // No truncation when name fits. + assert_eq!(truncate_label("short", 10), "short"); + // No truncation when max_chars <= 3 (guard against tiny boxes). + assert_eq!(truncate_label("Příliš.pdf", 3), "Příliš.pdf"); + } +} From 050a58223babc2a3bb30738c505490db9364b926 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:50:49 +0100 Subject: [PATCH 06/15] relocate: extend case_folded_paths_are_still_protected with /USERS/Shared and /VOLUMES/ Co-Authored-By: Claude Sonnet 4.6 --- src/relocate.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/relocate.rs b/src/relocate.rs index 95ba60e..5627b71 100644 --- a/src/relocate.rs +++ b/src/relocate.rs @@ -963,6 +963,9 @@ mod tests { format!("/Users/{user}/library"), format!("/Users/{}", user.to_uppercase()), format!("/users/{user}"), + // Founder case 6: /USERS/Shared and a /Volumes/ in wrong case. + "/USERS/Shared".to_string(), + "/VOLUMES/SomeDisk".to_string(), ] { let n = normalize(Path::new(&p)).unwrap(); assert!( From 5a983532636aacadb599f2691ad7b1c13da01039 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:52:09 +0100 Subject: [PATCH 07/15] scanner: remove dead ScanProgress::Error variant and all dead match arms Co-Authored-By: Claude Sonnet 4.6 --- examples/bench.rs | 3 +-- src/app.rs | 16 ---------------- src/scanner/mod.rs | 1 - src/server/api.rs | 4 ---- tests/walk_tests.rs | 1 - 5 files changed, 1 insertion(+), 24 deletions(-) diff --git a/examples/bench.rs b/examples/bench.rs index 80bb8a9..ebdc0ad 100644 --- a/examples/bench.rs +++ b/examples/bench.rs @@ -15,8 +15,7 @@ fn main() { let tree = loop { match rx.recv().unwrap() { ScanProgress::Done(t) => break t, - ScanProgress::Error(e) => panic!("{e}"), - _ => {} + ScanProgress::Counting { .. } => {} } }; let scan = t0.elapsed(); diff --git a/src/app.rs b/src/app.rs index 5e0401a..f7a95fb 100644 --- a/src/app.rs +++ b/src/app.rs @@ -302,7 +302,6 @@ impl App { fn poll_partial_refresh(&mut self) { let mut completed: Option<(NodeId, crate::scanner::tree::FileTree)> = None; - let mut error_occurred = false; if let Some((target_id, rx)) = self.state.partial_refresh_receiver.as_ref() { let target_id = *target_id; while let Ok(msg) = rx.try_recv() { @@ -312,17 +311,9 @@ impl App { completed = Some((target_id, new_subtree)); break; } - ScanProgress::Error(e) => { - eprintln!("Partial refresh error: {}", e); - error_occurred = true; - break; - } } } } - if error_occurred { - self.state.partial_refresh_receiver = None; - } if let Some((target_id, new_subtree)) = completed { if let Some(tree) = &mut self.state.tree { tree.graft_under(target_id, new_subtree); @@ -426,13 +417,6 @@ impl App { crate::history::save(&self.state.history); } } - ScanProgress::Error(msg) => { - eprintln!("Scan error: {}", msg); - self.state - .flywheel - .log_error(&msg, serde_json::json!({ "where": "scan" })); - self.state.scan_progress.scanning = false; - } }, Err(crossbeam_channel::TryRecvError::Empty) => break, Err(crossbeam_channel::TryRecvError::Disconnected) => { diff --git a/src/scanner/mod.rs b/src/scanner/mod.rs index 83c2769..7771e4f 100644 --- a/src/scanner/mod.rs +++ b/src/scanner/mod.rs @@ -14,7 +14,6 @@ pub enum ScanProgress { current_path: Option, }, Done(FileTree), - Error(String), } pub fn scan(root: PathBuf, tx: Sender) { diff --git a/src/server/api.rs b/src/server/api.rs index ab40849..fe6e2ad 100644 --- a/src/server/api.rs +++ b/src/server/api.rs @@ -122,10 +122,6 @@ pub fn start_scan(sh: &Arc, root: PathBuf) { s.scanning = false; s.finished = Some(now()); } - ScanProgress::Error(_) => { - s.scanning = false; - s.finished = Some(now()); - } } } }); diff --git a/tests/walk_tests.rs b/tests/walk_tests.rs index b23da57..ea8e0cc 100644 --- a/tests/walk_tests.rs +++ b/tests/walk_tests.rs @@ -12,7 +12,6 @@ fn scan_temp(root: PathBuf) -> mac_dir_stat::scanner::tree::FileTree { for msg in rx { match msg { ScanProgress::Done(tree) => return tree, - ScanProgress::Error(e) => panic!("scan error: {}", e), ScanProgress::Counting { .. } => {} } } From cb57e078416ee2280afeaae3ade5e0c3b2302f38 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:52:49 +0100 Subject: [PATCH 08/15] app: clear scan_receiver on Done to prevent spurious disconnect log message Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index f7a95fb..429690e 100644 --- a/src/app.rs +++ b/src/app.rs @@ -348,6 +348,7 @@ impl App { fn poll_scan(&mut self) { let mut disconnected = false; + let mut done = false; if let Some(rx) = &self.state.scan_receiver { loop { match rx.try_recv() { @@ -416,6 +417,8 @@ impl App { crate::history::record(&mut self.state.history, s); crate::history::save(&self.state.history); } + done = true; + break; } }, Err(crossbeam_channel::TryRecvError::Empty) => break, @@ -430,7 +433,7 @@ impl App { } } } - if disconnected { + if disconnected || done { self.state.scan_receiver = None; } } From 88ae47c9e26fd13b920c9ed88fcf866420a317ef Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:53:21 +0100 Subject: [PATCH 09/15] app: fire conversion event on successful move-to-disk in poll_relocate Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/app.rs b/src/app.rs index 429690e..2cefce6 100644 --- a/src/app.rs +++ b/src/app.rs @@ -616,6 +616,11 @@ impl App { if let Some((id, size)) = moved { self.state.freed_this_session = self.state.freed_this_session.saturating_add(size); + self.state.flywheel.conversion(serde_json::json!({ + "freed_bytes": size, + "items": 1, + "kind": "move_to_disk", + })); self.state.remove_trashed(&[id]); } } From 42d710cab974ecbe632761e3937671e0a33ba516 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:53:51 +0100 Subject: [PATCH 10/15] help_window: only set feedback_sent when telemetry is enabled Co-Authored-By: Claude Sonnet 4.6 --- src/ui/help_window.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/ui/help_window.rs b/src/ui/help_window.rs index f60961d..588bdb9 100644 --- a/src/ui/help_window.rs +++ b/src/ui/help_window.rs @@ -134,7 +134,7 @@ fn feedback_section(ui: &mut egui::Ui, state: &mut AppState) { None, if text.is_empty() { None } else { Some(text) }, ); - state.feedback_sent = true; + state.feedback_sent = state.flywheel.is_enabled(); state.feedback_text.clear(); } if !state.flywheel.is_enabled() { From 4c7ad6366c0fc22cb3483b1dc07fdbed4568e5de Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:54:03 +0100 Subject: [PATCH 11/15] main: remove dead windows_subsystem attribute (macOS-only binary) Co-Authored-By: Claude Sonnet 4.6 --- src/main.rs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/main.rs b/src/main.rs index 5dbd706..e4a2eda 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,5 +1,3 @@ -#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] - #[global_allocator] static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc; From 643170f2013c6f3d4a7713dd03dd8e888b72ba4e Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:54:32 +0100 Subject: [PATCH 12/15] cleanup: only subtract build-output candidates from only_yours totals, not .gradle/.m2/.cache Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index d5f1cde..6a5602c 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -214,9 +214,13 @@ pub fn find_only_yours( out.push(OnlyYours { node_id: id, path, label, size }); } for o in &mut out { + // Only subtract candidates that are actual build output (node_modules, + // target/, __pycache__, .next, etc.). .gradle, .m2, and .cache are + // cleanup candidates but may hold credentials — they belong to the + // "only yours" column, not the rebuildable column. let inside: u64 = rebuildable .iter() - .filter(|c| c.path.starts_with(&o.path)) + .filter(|c| c.path.starts_with(&o.path) && is_build_output(&c.path)) .map(|c| c.size) .sum(); o.size = o.size.saturating_sub(inside); From 4ccf2b8adfed7cd805169d9c027d91ab57d8b4b7 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:56:29 +0100 Subject: [PATCH 13/15] state: share one state_dir() across state, flywheel, fda, history Co-Authored-By: Claude Opus 5.5 --- src/flywheel.rs | 7 +------ src/history.rs | 4 +--- src/platform/fda.rs | 6 +----- src/state.rs | 3 ++- 4 files changed, 5 insertions(+), 15 deletions(-) diff --git a/src/flywheel.rs b/src/flywheel.rs index 110d996..edba428 100644 --- a/src/flywheel.rs +++ b/src/flywheel.rs @@ -17,7 +17,7 @@ //! Opt-out: `MACDIRSTAT_NO_TELEMETRY=1` in the environment, or the user toggle //! persisted in `telemetry.txt`. With no config baked, the SDK ships dark. -use std::path::PathBuf; +use crate::state::state_dir; use std::process::{Command, Stdio}; use std::sync::OnceLock; use std::time::{SystemTime, UNIX_EPOCH}; @@ -46,11 +46,6 @@ pub struct Flywheel { session_id: String, } -fn state_dir() -> Option { - std::env::var_os("HOME") - .map(|h| PathBuf::from(h).join("Library/Application Support/mac-dir-stat")) -} - /// Resolve `(url, publishable_key)` from runtime env first, then a compile-time bake. /// Returns `None` when neither is present — the ships-dark default. fn resolve_config() -> Option<(String, String)> { diff --git a/src/history.rs b/src/history.rs index 94a4e29..525937a 100644 --- a/src/history.rs +++ b/src/history.rs @@ -177,9 +177,7 @@ pub fn from_json(text: &str) -> Vec { } fn file() -> Option { - std::env::var_os("HOME").map(|h| { - PathBuf::from(h).join("Library/Application Support/mac-dir-stat/history.json") - }) + crate::state::state_dir().map(|d| d.join("history.json")) } pub fn load() -> Vec { diff --git a/src/platform/fda.rs b/src/platform/fda.rs index 11379b0..efb9004 100644 --- a/src/platform/fda.rs +++ b/src/platform/fda.rs @@ -6,14 +6,10 @@ //! again. Without FDA the scanner still works; it just can't see TCC-protected //! locations, which it already prunes to avoid a wall of permission prompts. +use crate::state::state_dir; use std::path::PathBuf; use std::process::Command; -fn state_dir() -> Option { - std::env::var_os("HOME") - .map(|h| PathBuf::from(h).join("Library/Application Support/mac-dir-stat")) -} - /// Open System Settings → Privacy & Security → Full Disk Access. pub fn open_settings() { let _ = Command::new("open") diff --git a/src/state.rs b/src/state.rs index 189e07f..033c0e6 100644 --- a/src/state.rs +++ b/src/state.rs @@ -10,7 +10,8 @@ pub struct PersistedState { pub color_mode: Option, } -fn state_dir() -> Option { +/// `~/Library/Application Support/mac-dir-stat` — shared by every module that persists files. +pub fn state_dir() -> Option { std::env::var_os("HOME").map(|h| { PathBuf::from(h).join("Library/Application Support/mac-dir-stat") }) From 70460a93b8167d0d43f172a02f4955bb122bbce0 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:58:08 +0100 Subject: [PATCH 14/15] dir_tree: derive visible_dirs from flatten instead of a second traversal Co-Authored-By: Claude Opus 5.5 --- src/ui/dir_tree.rs | 31 +++++++------------------------ 1 file changed, 7 insertions(+), 24 deletions(-) diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index 3a5b3b7..1d36f5c 100644 --- a/src/ui/dir_tree.rs +++ b/src/ui/dir_tree.rs @@ -31,31 +31,14 @@ fn sorted_children(tree: &FileTree, parent: NodeId, sort: crate::app::DirSortMod } /// Directories in top-to-bottom display order (pre-order, honoring the active -/// sort and collapsed nodes). Drives keyboard navigation; mirrors what -/// `show_node` renders. +/// sort and collapsed nodes). Drives keyboard navigation; the folder rows of +/// `flatten`, so it always matches what the list renders. pub fn visible_dirs(tree: &FileTree, root: NodeId, sort: crate::app::DirSortMode) -> Vec { - fn rec(tree: &FileTree, id: NodeId, sort: crate::app::DirSortMode, out: &mut Vec) { - if !tree.is_alive(id) || !tree.node(id).is_dir() { - return; - } - let is_root = out.is_empty(); - out.push(id); - let expanded = is_root - || matches!( - &tree.node(id).kind, - NodeKind::Directory { expanded: true, .. } - ); - if expanded { - for c in sorted_children(tree, id, sort) { - if tree.node(c).is_dir() { - rec(tree, c, sort, out); - } - } - } - } - let mut out = Vec::new(); - rec(tree, root, sort, &mut out); - out + flatten(tree, root, sort) + .into_iter() + .filter(|r| r.kind == RowKind::Dir) + .map(|r| r.id) + .collect() } /// The folder the list starts from: the one the map shows. From e848d0350d69272ddf480647dcb3c10a492571ba Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Mon, 9 Nov 2026 00:58:51 +0100 Subject: [PATCH 15/15] plan: reuse server::now() for plan timestamps Co-Authored-By: Claude Opus 5.5 --- src/plan.rs | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/plan.rs b/src/plan.rs index bcfe0f8..f8ce259 100644 --- a/src/plan.rs +++ b/src/plan.rs @@ -239,9 +239,7 @@ pub struct PlanStore { impl PlanStore { pub fn create(&mut self, ops: Vec, note: &str, origin: &str) -> &Plan { self.next_id += 1; - let created = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.as_secs()); + let created = crate::server::now(); self.plans.push(Plan { id: self.next_id, note: note.into(),