From 3fd0b9ce6a16c64d5fbc172ff6f64b5aeb7eeb2b Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 00:58:56 +0200 Subject: [PATCH 01/35] fix: char-safe truncation in dir_tree to avoid UTF-8 byte-slice panic Co-Authored-By: Claude Sonnet 4.6 --- src/ui/dir_tree.rs | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index bc2ef2f..5cb989e 100644 --- a/src/ui/dir_tree.rs +++ b/src/ui/dir_tree.rs @@ -187,8 +187,13 @@ fn show_node( // (and bar, when there's room). let avail = (ui.available_width() - 70.0).max(30.0); let max_chars = (avail / 6.5) as usize; - let display_name = if name.len() > max_chars && max_chars > 3 { - format!("{}…", &name[..max_chars - 1]) + let display_name = if name.chars().count() > max_chars && max_chars > 3 { + let end = name + .char_indices() + .nth(max_chars - 1) + .map(|(i, _)| i) + .unwrap_or(name.len()); + format!("{}…", &name[..end]) } else { name.clone() }; From 2611a657c2c9a7ee642c4f224fb1101ae2a4af22 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 00:59:44 +0200 Subject: [PATCH 02/35] fix: char-safe truncation in treemap_view to avoid UTF-8 byte-slice panic Co-Authored-By: Claude Sonnet 4.6 --- src/ui/treemap_view.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index 84c57c8..430e280 100644 --- a/src/ui/treemap_view.rs +++ b/src/ui/treemap_view.rs @@ -432,11 +432,14 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let node = tree.node(cr.node_id); let name = tree.name(cr.node_id); let max_chars = (w / 7.0) as usize; - let display_name = if name.len() > max_chars && max_chars > 3 { - format!( - "{}...", - &name[..max_chars.min(name.len()).saturating_sub(3)] - ) + let char_count = name.chars().count(); + let display_name = if char_count > max_chars && max_chars > 3 { + let end = name + .char_indices() + .nth(max_chars.saturating_sub(3)) + .map(|(i, _)| i) + .unwrap_or(name.len()); + format!("{}...", &name[..end]) } else { name.to_string() }; From 089192e129c333bc885b864c8fab8fca9991536d Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:00:32 +0200 Subject: [PATCH 03/35] fix: use taxonomy event name 'page_view' instead of 'app_open' Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 7fe0453..1a3339c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -173,7 +173,7 @@ impl App { let color_mode = persisted.color_mode.unwrap_or(ColorMode::Extension); let flywheel = crate::flywheel::Flywheel::init("mac-dir-stat"); flywheel.track( - "app_open", + "page_view", serde_json::json!({ "version": env!("CARGO_PKG_VERSION") }), ); From 1bcea23c3d128ee93704a1a827506e11b8b479b6 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:01:21 +0200 Subject: [PATCH 04/35] fix: use taxonomy event name 'key_action' instead of 'scan_completed' Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 1a3339c..196e795 100644 --- a/src/app.rs +++ b/src/app.rs @@ -377,7 +377,7 @@ impl App { self.state.scan_duration_secs = start.elapsed().as_secs_f32(); } self.state.flywheel.track( - "scan_completed", + "key_action", serde_json::json!({ "files": self.state.scan_progress.files, "dirs": self.state.scan_progress.dirs, From d21b0564e8ad22bce0a7a57ead98bb4b36e461ed Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:02:24 +0200 Subject: [PATCH 05/35] fix: use node.file_count instead of collect_files().len() in status bar Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app.rs b/src/app.rs index 196e795..e5b3bda 100644 --- a/src/app.rs +++ b/src/app.rs @@ -779,7 +779,7 @@ impl eframe::App for App { ui.horizontal(|ui| { if let Some(tree) = &self.state.tree { let root = tree.root(); - let file_count = tree.collect_files(root).len(); + let file_count = tree.node(root).file_count as usize; let freed_str = if self.state.freed_this_session > 0 { format!( " • freed {}", From 9f03ead254ad8d1f7dc502d51a8a047670fd5d36 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:03:25 +0200 Subject: [PATCH 06/35] fix: require path-component boundary in cleanup suffix matching Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index c673b78..1f29692 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -89,7 +89,9 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> { ), ]; for &(suffix, label, desc) in SUFFIX_RULES { - if s.ends_with(suffix) { + // Require a path-component boundary: either the path IS the suffix or + // it ends with /, preventing "MyLibrary/Caches" matching "Library/Caches". + if s == suffix || s.ends_with(&format!("/{suffix}")) { return Some((label, desc)); } } From cb8f3abf9a9f1f56d7d74a3addd18b119f8c52ce Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:06:58 +0200 Subject: [PATCH 07/35] fix: surface move_to_trash failure as status bar error message Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/src/app.rs b/src/app.rs index e5b3bda..d2653c4 100644 --- a/src/app.rs +++ b/src/app.rs @@ -92,6 +92,9 @@ pub struct AppState { // Cached FDA state — drives the persistent "Grant Full Disk Access" // toolbar affordance. Rechecked on each scan (see start_scan). pub has_fda: bool, + + // Transient status message shown in the status bar (e.g. trash errors). + pub status_message: Option, } pub struct ScanProgressInfo { @@ -235,6 +238,7 @@ impl App { feedback_sent: false, show_fda_prompt, has_fda, + status_message: None, // Auto-scan on launch (default target = whole disk via the `/` // fallback above). The first update() frame starts the scan, so // the app opens straight into a scan instead of a welcome screen. @@ -570,6 +574,7 @@ impl App { } Err(e) => { eprintln!("Delete failed: {}", e); + self.state.status_message = Some(format!("Delete failed: {e}")); } } } @@ -818,6 +823,14 @@ impl eframe::App for App { ); } + if let Some(msg) = &self.state.status_message { + ui.label( + egui::RichText::new(msg) + .color(egui::Color32::from_rgb(220, 80, 80)) + .size(11.0), + ); + } + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { // Partial-refresh indicator takes priority over the // hovered-file path so the user sees feedback during the From 5ba9f8edd4e04f74dbe56b0efcd59ca5ad8ebb1f Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:07:44 +0200 Subject: [PATCH 08/35] fix: only set feedback_sent when telemetry is enabled Co-Authored-By: Claude Sonnet 4.6 --- src/ui/help_window.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/ui/help_window.rs b/src/ui/help_window.rs index 46dfb36..6595e11 100644 --- a/src/ui/help_window.rs +++ b/src/ui/help_window.rs @@ -134,7 +134,9 @@ fn feedback_section(ui: &mut egui::Ui, state: &mut AppState) { None, if text.is_empty() { None } else { Some(text) }, ); - state.feedback_sent = true; + if state.flywheel.is_enabled() { + state.feedback_sent = true; + } state.feedback_text.clear(); } if !state.flywheel.is_enabled() { From 28f0fcaf679844d747da363a38952498d3173ec7 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:08:30 +0200 Subject: [PATCH 09/35] fix: path truncation in cleanup window (20+ellipsis+20 chars) Co-Authored-By: Claude Sonnet 4.6 --- src/ui/cleanup_window.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ui/cleanup_window.rs b/src/ui/cleanup_window.rs index fd7718c..a9a6536 100644 --- a/src/ui/cleanup_window.rs +++ b/src/ui/cleanup_window.rs @@ -222,12 +222,15 @@ fn candidate_row(ui: &mut egui::Ui, state: &mut AppState, cand: &CleanupCandidat // Path on the left, truncated ui.vertical(|ui| { let path_str = cand.path.display().to_string(); - let display_path = if path_str.chars().count() > 64 { - let mut iter = path_str.chars(); - let head: String = iter.by_ref().take(28).collect(); - let tail: String = iter - .skip(path_str.chars().count().saturating_sub(64).saturating_sub(28)) - .collect(); + let char_count = path_str.chars().count(); + let display_path = if char_count > 44 { + let head: String = path_str.chars().take(20).collect(); + let tail: String = path_str + .char_indices() + .nth(char_count - 20) + .map(|(i, _)| &path_str[i..]) + .unwrap_or("") + .to_string(); format!("{}…{}", head, tail) } else { path_str From 81337c05827100c0c669b6ec00783b22fa6a71af Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:10:26 +0200 Subject: [PATCH 10/35] fix: remove dead code children_sorted, node_count, StrRef::EMPTY Co-Authored-By: Claude Sonnet 4.6 --- src/scanner/tree.rs | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index 330fc7a..280be13 100644 --- a/src/scanner/tree.rs +++ b/src/scanner/tree.rs @@ -13,9 +13,6 @@ pub struct StrRef { len: u32, } -impl StrRef { - pub const EMPTY: StrRef = StrRef { offset: 0, len: 0 }; -} /// Bump arena holding raw bytes for all node names + extensions. /// One contiguous `Vec` instead of millions of small heap allocs. @@ -221,10 +218,6 @@ impl FileTree { id } - pub fn node_count(&self) -> usize { - self.nodes.iter().filter(|n| n.alive).count() - } - /// Aggregate a directory's `size`, `file_count` and `subdir_count` from its /// direct children. Requires the children's own counts to already be /// correct (true in a bottom-up reverse pass and after grafting). @@ -389,13 +382,6 @@ impl FileTree { } } - pub fn children_sorted(&self, id: NodeId) -> Vec { - let mut children: Vec = self.node(id).children().to_vec(); - children.retain(|&c| self.nodes[c].alive); - children.sort_by(|&a, &b| self.nodes[b].size.cmp(&self.nodes[a].size)); - children - } - pub fn is_alive(&self, id: NodeId) -> bool { self.nodes[id].alive } From 97f9558e8e7a3d97a987ac4faee421aac21ff2a4 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:12:16 +0200 Subject: [PATCH 11/35] fix: remove unused BAR_FILL, simplify ext_list branch, drop unused ColorMode import Co-Authored-By: Claude Sonnet 4.6 --- src/ui/ext_list.rs | 2 +- src/ui/theme.rs | 3 --- tests/color_tests.rs | 2 +- 3 files changed, 2 insertions(+), 5 deletions(-) diff --git a/src/ui/ext_list.rs b/src/ui/ext_list.rs index 0e46e82..9f7542e 100644 --- a/src/ui/ext_list.rs +++ b/src/ui/ext_list.rs @@ -47,7 +47,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { for i in 0..show_count { let (ext, bytes, count) = &stats[i]; let is_selected = state.selected_extension.as_deref() == Some(ext.as_str()); - let colors = extension_color(if ext.is_empty() { "" } else { ext }); + let colors = extension_color(ext); let swatch_color = Color32::from_rgba_premultiplied( colors.0[0], colors.0[1], colors.0[2], colors.0[3], ); diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 5b883e2..4802031 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -25,9 +25,6 @@ pub const ACCENT_HOVER: Color32 = Color32::from_rgb(0x9d, 0x74, 0xf8); // Cool near-white, no violet cast. pub const ACCENT_LIGHT: Color32 = Color32::from_rgb(0xd6, 0xda, 0xe2); -// Mini-bar fill (dir-tree size bars, scan progress indicator) -pub const BAR_FILL: Color32 = Color32::from_rgb(0x6b, 0x7c, 0x96); - // Danger pub const DANGER: Color32 = Color32::from_rgb(0xef, 0x44, 0x44); pub const DANGER_HOVER: Color32 = Color32::from_rgb(0xf8, 0x71, 0x71); diff --git a/tests/color_tests.rs b/tests/color_tests.rs index 264f05f..e7299ef 100644 --- a/tests/color_tests.rs +++ b/tests/color_tests.rs @@ -1,4 +1,4 @@ -use mac_dir_stat::treemap::color::{extension_color, depth_color, age_color, ColorMode, GradientPair}; +use mac_dir_stat::treemap::color::{extension_color, depth_color, age_color, GradientPair}; use std::time::{SystemTime, Duration}; #[test] From 07a299a2943f7addf6a95a1f7cc66eeddfa23730 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:16:50 +0200 Subject: [PATCH 12/35] refactor: drop never-constructed PendingAction variants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RevealInFinder and MoveToTrash were never built by any caller — the context menu performs both inline. Removing them makes the dispatch match exhaustive, so a future variant fails to compile instead of being silently swallowed by the catch-all arm. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 7 ------- 1 file changed, 7 deletions(-) diff --git a/src/app.rs b/src/app.rs index d2653c4..f342efe 100644 --- a/src/app.rs +++ b/src/app.rs @@ -160,8 +160,6 @@ impl ExtSortMode { } pub enum PendingAction { - RevealInFinder(PathBuf), - MoveToTrash(NodeId), ConfirmTrash(NodeId, String, u64), ConfirmBatchTrash(Vec, u64), ConfirmEmptyTrash, @@ -1076,11 +1074,6 @@ impl eframe::App for App { }); }); } - _ => { - // RevealInFinder / MoveToTrash variants are unused — - // context menu performs those actions inline. - self.state.pending_action = None; - } } } if let Some(result) = action_to_process { From 54dfbc0ac1c917278b93fbd0fd419f24858592d9 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:18:42 +0200 Subject: [PATCH 13/35] refactor: use stdlib where code was hand-rolling it - cleanup_selected pruning: collect-to-Vec then remove-in-loop is HashSet::retain, one line instead of twelve - Option::is_some_and replaces map_or(false, ..) in two predicates - collapse the nested hover-detection if into one condition Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 13 +++---------- src/scanner/walk.rs | 2 +- src/ui/treemap_view.rs | 9 +++++---- 3 files changed, 9 insertions(+), 15 deletions(-) diff --git a/src/app.rs b/src/app.rs index f342efe..cf9302d 100644 --- a/src/app.rs +++ b/src/app.rs @@ -340,17 +340,10 @@ impl App { self.state.context_menu_target = None; self.state.scroll_dir_tree_to = Some(target_id); // Drop any cleanup selections that became dead during graft. - let live_check: Vec = self - .state + let tree_ref = &*tree; + self.state .cleanup_selected - .iter() - .copied() - .collect(); - for id in live_check { - if !tree.is_alive(id) { - self.state.cleanup_selected.remove(&id); - } - } + .retain(|id| tree_ref.is_alive(*id)); } self.state.partial_refresh_receiver = None; self.state.treemap_dirty = true; diff --git a/src/scanner/walk.rs b/src/scanner/walk.rs index 399e9eb..91de769 100644 --- a/src/scanner/walk.rs +++ b/src/scanner/walk.rs @@ -97,7 +97,7 @@ fn build_skip_paths() -> Vec { fn is_photos_library(path: &Path) -> bool { path.extension() - .map_or(false, |e| e == "photoslibrary" || e == "photolibrary") + .is_some_and(|e| e == "photoslibrary" || e == "photolibrary") } fn should_skip(path: &Path, scan_root: &Path, skip_paths: &[PathBuf]) -> bool { diff --git a/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index 430e280..4dfd99a 100644 --- a/src/ui/treemap_view.rs +++ b/src/ui/treemap_view.rs @@ -213,10 +213,11 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let is_large = w > 3.0 && h > 3.0; // Hover detection only for visible-sized rects - if is_large && state.hovered_node.is_none() { - if pointer_pos.map_or(false, |p| rect.contains(p)) { - state.hovered_node = Some(cr.node_id); - } + if is_large + && state.hovered_node.is_none() + && pointer_pos.is_some_and(|p| rect.contains(p)) + { + state.hovered_node = Some(cr.node_id); } // Compute alpha for filtering From 7d885ae5ea0c37e378fa259668eefaa1edcb3785 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:23:29 +0200 Subject: [PATCH 14/35] refactor: one confirm_dialog widget for all three confirmations Delete / empty-trash / batch-delete each hand-built the same modal: hidden title bar, center anchor, heading + detail label, right-aligned danger+ghost pair. Extracted to ui::widgets::confirm_dialog returning Option (None = undecided, Some = chose), next to the buttons it uses. Also flattens the Option> result plumbing into plain Option / Option> / bool. Only visual change: the single-delete dialog now uses the same 320/360 width as the other two. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 226 +++++++++++++--------------------------------- src/ui/widgets.rs | 48 ++++++++++ 2 files changed, 112 insertions(+), 162 deletions(-) diff --git a/src/app.rs b/src/app.rs index cf9302d..7c2d34b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -907,186 +907,88 @@ impl eframe::App for App { // One-time Full Disk Access prompt (first launch without access) ui::fda_window::show(ctx, &mut self.state); - // Handle pending actions - let mut action_to_process: Option> = None; - let mut batch_to_process: Option>> = None; - let mut empty_trash_confirmed: Option = None; + // Handle pending actions. Each confirm dialog resolves to + // Some(true)/Some(false); the chosen action runs after the borrow of + // `pending_action` ends. + let mut dialog_resolved = false; + let mut delete_single: Option = None; + let mut delete_batch: Option> = None; + let mut empty_trash = false; if let Some(action) = &self.state.pending_action { match action { PendingAction::ConfirmTrash(node_id, name, size) => { - let node_id = *node_id; - let name = name.clone(); - let size = *size; let display_name = if name.chars().count() > 36 { - let mut iter = name.chars(); - let head: String = iter.by_ref().take(34).collect(); - format!("{}…", head) + let head: String = name.chars().take(34).collect(); + format!("{head}…") } else { name.clone() }; - egui::Window::new("Confirm Delete") - .title_bar(false) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .default_width(300.0) - .show(ctx, |ui| { - ui.set_max_width(320.0); - ui.label( - egui::RichText::new("Move to Trash?") - .color(ui::theme::TEXT_PRIMARY) - .size(13.0) - .strong(), - ); - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!( - "{} · {}", - display_name, - ui::theme::format_size(size), - )) - .color(ui::theme::TEXT_SECONDARY) - .size(11.0), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui::widgets::danger_button(ui, "Move to Trash") - .clicked() - { - action_to_process = Some(Some(node_id)); - } - if ui::widgets::ghost_button(ui, "Cancel").clicked() { - action_to_process = Some(None); - } - }, - ); - }); - }); + let confirmed = ui::widgets::confirm_dialog( + ctx, + "Confirm Delete", + "Move to Trash?", + &format!("{} · {}", display_name, ui::theme::format_size(*size)), + "Move to Trash", + ); + if let Some(confirmed) = confirmed { + dialog_resolved = true; + if confirmed { + delete_single = Some(*node_id); + } + } + } + PendingAction::ConfirmEmptyTrash => { + let confirmed = ui::widgets::confirm_dialog( + ctx, + "Confirm Empty Trash", + "Empty Trash?", + "Permanently deletes everything currently in your Trash. \ + Cannot be undone.", + "Empty Trash", + ); + if let Some(confirmed) = confirmed { + dialog_resolved = true; + empty_trash = confirmed; + } + } + PendingAction::ConfirmBatchTrash(ids, total_size) => { + let confirmed = ui::widgets::confirm_dialog( + ctx, + "Confirm Batch Delete", + &format!( + "Move {} item{} to Trash?", + ids.len(), + if ids.len() == 1 { "" } else { "s" }, + ), + &format!("Total {}", ui::theme::format_size(*total_size)), + &format!("Move {} to Trash", ids.len()), + ); + if let Some(confirmed) = confirmed { + dialog_resolved = true; + if confirmed { + delete_batch = Some(ids.clone()); + } + } } PendingAction::RefreshSubtree(node_id) => { let node_id = *node_id; self.state.pending_action = None; self.start_partial_refresh(node_id); } - PendingAction::ConfirmEmptyTrash => { - egui::Window::new("Confirm Empty Trash") - .title_bar(false) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .default_width(320.0) - .show(ctx, |ui| { - ui.set_max_width(360.0); - ui.label( - egui::RichText::new("Empty Trash?") - .color(ui::theme::TEXT_PRIMARY) - .size(13.0) - .strong(), - ); - ui.add_space(4.0); - ui.label( - egui::RichText::new( - "Permanently deletes everything currently in your Trash. \ - Cannot be undone.", - ) - .color(ui::theme::TEXT_SECONDARY) - .size(11.0), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui::widgets::danger_button(ui, "Empty Trash") - .clicked() - { - empty_trash_confirmed = Some(true); - } - if ui::widgets::ghost_button(ui, "Cancel").clicked() { - empty_trash_confirmed = Some(false); - } - }, - ); - }); - }); - } - PendingAction::ConfirmBatchTrash(ids, total_size) => { - let ids = ids.clone(); - let total_size = *total_size; - egui::Window::new("Confirm Batch Delete") - .title_bar(false) - .collapsible(false) - .resizable(false) - .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) - .default_width(320.0) - .show(ctx, |ui| { - ui.set_max_width(360.0); - ui.label( - egui::RichText::new(format!( - "Move {} item{} to Trash?", - ids.len(), - if ids.len() == 1 { "" } else { "s" }, - )) - .color(ui::theme::TEXT_PRIMARY) - .size(13.0) - .strong(), - ); - ui.add_space(4.0); - ui.label( - egui::RichText::new(format!( - "Total {}", - ui::theme::format_size(total_size), - )) - .color(ui::theme::TEXT_SECONDARY) - .size(11.0), - ); - ui.add_space(12.0); - ui.horizontal(|ui| { - ui.spacing_mut().item_spacing.x = 6.0; - ui.with_layout( - egui::Layout::right_to_left(egui::Align::Center), - |ui| { - if ui::widgets::danger_button( - ui, - &format!("Move {} to Trash", ids.len()), - ) - .clicked() - { - batch_to_process = Some(Some(ids.clone())); - } - if ui::widgets::ghost_button(ui, "Cancel").clicked() { - batch_to_process = Some(None); - } - }, - ); - }); - }); - } } } - if let Some(result) = action_to_process { + if dialog_resolved { self.state.pending_action = None; - if let Some(node_id) = result { - self.perform_delete(node_id); - } } - if let Some(result) = batch_to_process { - self.state.pending_action = None; - if let Some(ids) = result { - self.perform_batch_delete(ids); - } + if let Some(node_id) = delete_single { + self.perform_delete(node_id); } - if let Some(confirmed) = empty_trash_confirmed { - self.state.pending_action = None; - if confirmed { - if let Err(e) = crate::platform::trash::empty_trash() { - eprintln!("Empty trash failed: {}", e); - } + if let Some(ids) = delete_batch { + self.perform_batch_delete(ids); + } + if empty_trash { + if let Err(e) = crate::platform::trash::empty_trash() { + eprintln!("Empty trash failed: {}", e); } } } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 5d8b95e..cc0f9d7 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -68,6 +68,54 @@ pub fn danger_button(ui: &mut Ui, label: &str) -> egui::Response { .inner } +/// Centered, chrome-free confirmation modal: heading, one line of detail, and +/// a danger/cancel button pair. `id` is the egui window id (the title bar is +/// hidden, so it is never shown). Returns `None` while the user hasn't chosen, +/// `Some(true)` on confirm, `Some(false)` on cancel. +pub fn confirm_dialog( + ctx: &egui::Context, + id: &str, + heading: &str, + body: &str, + confirm_label: &str, +) -> Option { + let mut choice = None; + egui::Window::new(id) + .title_bar(false) + .collapsible(false) + .resizable(false) + .anchor(egui::Align2::CENTER_CENTER, [0.0, 0.0]) + .default_width(320.0) + .show(ctx, |ui| { + ui.set_max_width(360.0); + ui.label( + RichText::new(heading) + .color(theme::TEXT_PRIMARY) + .size(13.0) + .strong(), + ); + ui.add_space(4.0); + ui.label( + RichText::new(body) + .color(theme::TEXT_SECONDARY) + .size(11.0), + ); + ui.add_space(12.0); + ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 6.0; + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if danger_button(ui, confirm_label).clicked() { + choice = Some(true); + } + if ghost_button(ui, "Cancel").clicked() { + choice = Some(false); + } + }); + }); + }); + choice +} + /// Pill-style segmented control. Returns true if the selection changed. pub fn segmented_control( ui: &mut Ui, From 9b5444cb26cca89673099f29a30b7f07b8753b39 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:33:05 +0200 Subject: [PATCH 15/35] fix: clear pending_action/hovered_dir/cleanup/scroll_dir_tree_to in start_scan 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 7c2d34b..55bec2a 100644 --- a/src/app.rs +++ b/src/app.rs @@ -268,8 +268,13 @@ impl App { self.state.extension_stats.clear(); self.state.selected_node = None; self.state.hovered_node = None; + self.state.hovered_dir = None; self.state.view_root = None; self.state.zoom_stack.clear(); + self.state.pending_action = None; + self.state.cleanup_candidates.clear(); + self.state.cleanup_selected.clear(); + self.state.scroll_dir_tree_to = None; crate::scanner::scan(self.state.scan_root.clone(), tx); } From 53c669e3fb21df128b4208a31508993f392c8621 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:33:54 +0200 Subject: [PATCH 16/35] fix: clear pending_action/hovered_dir/scroll/selected_extension in scan Done branch Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app.rs b/src/app.rs index 55bec2a..e011d01 100644 --- a/src/app.rs +++ b/src/app.rs @@ -398,6 +398,10 @@ impl App { // Old NodeIds are gone; reset selection state. self.state.cleanup_selected.clear(); self.state.context_menu_target = None; + self.state.pending_action = None; + self.state.hovered_dir = None; + self.state.scroll_dir_tree_to = None; + self.state.selected_extension = None; crate::state::save( &self.state.scan_root, self.state.color_mode, From ea06fa91d895b90280b48d19a5387f973198d918 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:34:41 +0200 Subject: [PATCH 17/35] fix: remove .Trash/.Trashes from cleanup candidates (nonsensical + root-owned) Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index 1f29692..21516de 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -139,10 +139,6 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> { ".cache directories", "App- and tool-specific caches. Generally regenerable.", )), - ".Trash" | ".Trashes" => Some(( - "Trash", - "Files in the macOS Trash. Empty when you're sure you don't need them.", - )), _ => None, }; } From 3862bb463077d1972ed0f6527ecc5592fff480c9 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:35:26 +0200 Subject: [PATCH 18/35] fix: remove iCloud Drive evictable cache rule (data loss risk for non-uploaded files) Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index 21516de..7e251ee 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -72,11 +72,6 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> { "Docker Desktop Data", "Docker images, volumes, build cache. Prefer `docker system prune -a` for finer-grained cleanup.", ), - ( - "Library/Containers/com.apple.iCloud.iCloudDrive", - "iCloud Drive evictable cache", - "Local cache of iCloud-stored files. Files reload from iCloud on next access.", - ), ( ".cargo/registry", "Cargo registry cache", From f6fab583005b59cdedf942414df971355a02667e Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:35:51 +0200 Subject: [PATCH 19/35] fix: warn about credential files in .gradle and .m2 cleanup descriptions Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index 7e251ee..43a3ee8 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -108,11 +108,11 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> { )), ".gradle" => Some(( "Gradle caches", - "Gradle redownloads dependencies on next build.", + "Gradle redownloads dependencies on next build. Warning: gradle.properties (which may hold credentials) will also be deleted.", )), ".m2" => Some(( "Maven local repo", - "Maven local artifacts cache; rebuilt on next dependency resolve.", + "Maven cache — settings.xml (credentials) will also be deleted.", )), "DerivedData" => Some(( "DerivedData (loose)", From 032015854fec539a1d9a4507439e610e4c09585a Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:36:45 +0200 Subject: [PATCH 20/35] fix: only classify 'target' as Cargo output when Cargo.toml exists in parent Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cleanup.rs b/src/cleanup.rs index 43a3ee8..46451a3 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -118,7 +118,7 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> { "DerivedData (loose)", "Xcode build artifacts found outside the standard location.", )), - "target" => Some(( + "target" if path.parent().map(|p| p.join("Cargo.toml").exists()).unwrap_or(false) => Some(( "Rust target/", "`cargo build` recreates these. Often the largest single dir on a Rust dev machine.", )), From 921802cc9b380d95df51b385b5435b05c99e2f04 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:37:16 +0200 Subject: [PATCH 21/35] fix: surface write error in set_telemetry_opt_out via eprintln Co-Authored-By: Claude Sonnet 4.6 --- src/flywheel.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/flywheel.rs b/src/flywheel.rs index 7dc8ef4..40eb578 100644 --- a/src/flywheel.rs +++ b/src/flywheel.rs @@ -85,10 +85,12 @@ pub fn set_telemetry_opt_out(opted_out: bool) { if std::fs::create_dir_all(&dir).is_err() { return; } - let _ = std::fs::write( + if let Err(e) = std::fs::write( dir.join("telemetry.txt"), if opted_out { "disabled" } else { "enabled" }, - ); + ) { + eprintln!("mac-dir-stat: failed to persist telemetry choice: {e}"); + } } /// 16 random bytes from `/dev/urandom`, or a time-seeded fallback. From c0c255a1b82a551527ab0cdec36d08632b9fb0f9 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:38:04 +0200 Subject: [PATCH 22/35] fix: default telemetry to opted-out when telemetry.txt is absent Co-Authored-By: Claude Sonnet 4.6 --- src/flywheel.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/flywheel.rs b/src/flywheel.rs index 40eb578..e72cf29 100644 --- a/src/flywheel.rs +++ b/src/flywheel.rs @@ -72,11 +72,12 @@ pub fn telemetry_opted_out() -> bool { ) { return true; } + // Absent file = opted out; only "enabled" written by explicit opt-in counts. state_dir() .map(|d| d.join("telemetry.txt")) .and_then(|p| std::fs::read_to_string(p).ok()) - .map(|s| s.trim() == "disabled") - .unwrap_or(false) + .map(|s| s.trim() != "enabled") + .unwrap_or(true) } /// Persist the user's telemetry choice so it survives restarts. From 84f57b471140ab489573007d3d13c5a470d67f73 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:38:52 +0200 Subject: [PATCH 23/35] fix: sort cleanup candidates by category then size for contiguous grouping Co-Authored-By: Claude Sonnet 4.6 --- src/ui/cleanup_window.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/ui/cleanup_window.rs b/src/ui/cleanup_window.rs index a9a6536..d5b5592 100644 --- a/src/ui/cleanup_window.rs +++ b/src/ui/cleanup_window.rs @@ -142,7 +142,12 @@ pub fn show(ctx: &Context, state: &mut AppState) { egui::ScrollArea::vertical() .auto_shrink([false, false]) .show(ui, |ui| { - let candidates = state.cleanup_candidates.clone(); + let mut candidates = state.cleanup_candidates.clone(); + // Sort by category first so headers are contiguous, then + // by size descending within each category. + candidates.sort_by(|a, b| { + a.category.cmp(b.category).then(b.size.cmp(&a.size)) + }); let mut current_category = ""; // Group totals From 77e1b692e8731f4e9f0a25f2fd9ff65cd91e2e44 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:40:12 +0200 Subject: [PATCH 24/35] fix: empty_trash uses spawn() to avoid blocking UI thread; surface error in status_message Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 1 + src/platform/trash.rs | 19 +++++++++---------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app.rs b/src/app.rs index e011d01..a528db9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -998,6 +998,7 @@ impl eframe::App for App { if empty_trash { if let Err(e) = crate::platform::trash::empty_trash() { eprintln!("Empty trash failed: {}", e); + self.state.status_message = Some(format!("Empty Trash failed: {e}")); } } } diff --git a/src/platform/trash.rs b/src/platform/trash.rs index 3e09829..f060ac1 100644 --- a/src/platform/trash.rs +++ b/src/platform/trash.rs @@ -5,19 +5,18 @@ pub fn move_to_trash(path: &Path) -> Result<(), String> { trash::delete(path).map_err(|e| format!("Failed to move to trash: {}", e)) } -/// Empties the user's Trash via Finder. We've already shown our own -/// confirmation, so we use `without warning` to skip Finder's. +/// Empties the user's Trash via Finder in a background thread so the UI +/// frame is not blocked while Finder moves files. pub fn empty_trash() -> Result<(), String> { - let status = Command::new("osascript") + Command::new("osascript") .args([ "-e", "tell application \"Finder\" to empty trash without warning", ]) - .status() - .map_err(|e| format!("osascript failed to start: {}", e))?; - if status.success() { - Ok(()) - } else { - Err(format!("osascript exited with status {}", status)) - } + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map(|_| ()) + .map_err(|e| format!("osascript failed to start: {}", e)) } From 1ec21b62d18b73ac26fd66f3dd4f58e743843331 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:41:20 +0200 Subject: [PATCH 25/35] test: add classify tests for cargo target, non-cargo target, Library/Caches, .gradle Co-Authored-By: Claude Sonnet 4.6 --- src/cleanup.rs | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/cleanup.rs b/src/cleanup.rs index 46451a3..23a1d9e 100644 --- a/src/cleanup.rs +++ b/src/cleanup.rs @@ -150,6 +150,48 @@ pub fn find_candidates(tree: &FileTree, root: NodeId) -> Vec { candidates } +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + #[test] + fn cargo_target_with_cargo_toml_is_classified() { + let dir = std::env::temp_dir().join("mac_dir_stat_test_cargo"); + fs::create_dir_all(&dir).unwrap(); + fs::write(dir.join("Cargo.toml"), "[package]").unwrap(); + let target = dir.join("target"); + let (cat, _) = classify(&target).expect("should classify"); + assert_eq!(cat, "Rust target/"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn target_without_cargo_toml_is_not_classified() { + let dir = std::env::temp_dir().join("mac_dir_stat_test_notcargo"); + fs::create_dir_all(&dir).unwrap(); + // No Cargo.toml in parent + let target = dir.join("target"); + assert!(classify(&target).is_none(), "should not classify without Cargo.toml"); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn library_caches_is_classified() { + let path = std::path::Path::new("/Users/someone/Library/Caches"); + let (cat, _) = classify(path).expect("should classify"); + assert_eq!(cat, "Application Caches"); + } + + #[test] + fn gradle_description_warns_about_credentials() { + let path = std::path::Path::new("/Users/someone/.gradle"); + let (_, desc) = classify(path).expect("should classify"); + assert!(desc.contains("credentials") || desc.contains("gradle.properties"), + "description should warn about credentials"); + } +} + fn walk(tree: &FileTree, id: NodeId, out: &mut Vec) { if !tree.is_alive(id) { return; From 777fe9073557e61af378fb3551c6228e0bcb7c56 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:42:08 +0200 Subject: [PATCH 26/35] test: add no_telemetry_env_var_opts_out test for MACDIRSTAT_NO_TELEMETRY=1 Co-Authored-By: Claude Sonnet 4.6 --- src/flywheel.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/flywheel.rs b/src/flywheel.rs index e72cf29..d2ba46b 100644 --- a/src/flywheel.rs +++ b/src/flywheel.rs @@ -388,6 +388,14 @@ mod tests { assert!(!fw.is_enabled()); } + #[test] + fn no_telemetry_env_var_opts_out() { + // Safe: no other test touches MACDIRSTAT_NO_TELEMETRY. + std::env::set_var("MACDIRSTAT_NO_TELEMETRY", "1"); + assert!(telemetry_opted_out(), "MACDIRSTAT_NO_TELEMETRY=1 should opt out"); + std::env::remove_var("MACDIRSTAT_NO_TELEMETRY"); + } + #[test] fn taxonomy_matches_ts_client() { assert!(TAXONOMY.contains(&"conversion")); From 86bd20789de91d301f9f756734745675bbe77601 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:42:56 +0200 Subject: [PATCH 27/35] fix: only clear feedback_text when telemetry is enabled and send actually happened 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 6595e11..567c24e 100644 --- a/src/ui/help_window.rs +++ b/src/ui/help_window.rs @@ -136,8 +136,8 @@ fn feedback_section(ui: &mut egui::Ui, state: &mut AppState) { ); if state.flywheel.is_enabled() { state.feedback_sent = true; + state.feedback_text.clear(); } - state.feedback_text.clear(); } if !state.flywheel.is_enabled() { ui.label( From fd984abee0f13f66538ff6d0778a9e1686315734 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:47:31 +0200 Subject: [PATCH 28/35] refactor: single iterative descendant-dead walk in FileTree MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit remove_node and clear_descendants each had their own walk marking descendants dead — one recursive with a per-directory children clone, one iterative. Both now share the iterative version (no clone, no recursion depth limit). Also drop a dead `let new_id = …; let _ = new_id;` in graft_under. Co-Authored-By: Claude Sonnet 4.6 --- src/scanner/tree.rs | 31 +++++++++++-------------------- 1 file changed, 11 insertions(+), 20 deletions(-) diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index 280be13..b321224 100644 --- a/src/scanner/tree.rs +++ b/src/scanner/tree.rs @@ -286,16 +286,17 @@ impl FileTree { current = self.nodes[pid].parent; } - self.mark_dead_recursive(id); + self.mark_descendants_dead(id); } - fn mark_dead_recursive(&mut self, id: NodeId) { - if let NodeKind::Directory { ref children, .. } = self.nodes[id].kind { - let child_ids: Vec = children.clone(); - for child in child_ids { - self.nodes[child].alive = false; - self.mark_dead_recursive(child); - } + /// Marks every descendant of `id` dead. The node itself is untouched and + /// children lists are left intact. Iterative — directory trees can be + /// deeper than the stack tolerates. + fn mark_descendants_dead(&mut self, id: NodeId) { + let mut stack: Vec = self.nodes[id].children().to_vec(); + while let Some(child) = stack.pop() { + self.nodes[child].alive = false; + stack.extend_from_slice(self.nodes[child].children()); } } @@ -390,16 +391,7 @@ impl FileTree { /// list. The node itself stays. Used as the first step of grafting a /// freshly-scanned subtree onto an existing tree. pub fn clear_descendants(&mut self, target: NodeId) { - let mut stack: Vec = Vec::new(); - if let NodeKind::Directory { children, .. } = &self.nodes[target].kind { - stack.extend_from_slice(children); - } - while let Some(id) = stack.pop() { - self.nodes[id].alive = false; - if let NodeKind::Directory { children, .. } = &self.nodes[id].kind { - stack.extend_from_slice(children); - } - } + self.mark_descendants_dead(target); if let NodeKind::Directory { children, .. } = &mut self.nodes[target].kind { children.clear(); } @@ -453,7 +445,7 @@ impl FileTree { let ext_owned: Option = extension.map(|r| { String::from_utf8_lossy(source.strings.get(r)).into_owned() }); - let new_id = self.add_file( + self.add_file( dst_parent, &name_bytes, src_node.size, @@ -461,7 +453,6 @@ impl FileTree { src_node.modified, depth, ); - let _ = new_id; } NodeKind::Directory { children, .. } => { let new_id = self.add_dir( From ef9ec8e66a94af95aa9ecebaf64e7a025b4925ca Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:49:05 +0200 Subject: [PATCH 29/35] refactor: one to_screen() for treemap rect conversion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six copies of the same four-line f64-rect → egui::Rect conversion in treemap_view collapse to a single helper. Co-Authored-By: Claude Sonnet 4.6 --- src/ui/treemap_view.rs | 38 ++++++++++++++------------------------ 1 file changed, 14 insertions(+), 24 deletions(-) diff --git a/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index 4dfd99a..70cfc14 100644 --- a/src/ui/treemap_view.rs +++ b/src/ui/treemap_view.rs @@ -288,10 +288,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if dr.depth >= 3 || dr.rect.w < 10.0 || dr.rect.h < 10.0 { continue; } - let rect = Rect::from_min_size( - Pos2::new(dr.rect.x as f32, dr.rect.y as f32), - Vec2::new(dr.rect.w as f32, dr.rect.h as f32), - ); + let rect = to_screen(&dr.rect); let alpha = if dr.depth <= 1 { 40 } else { 20 }; painter.rect_stroke( rect, @@ -308,10 +305,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let mut best_depth = 0u16; for dr in &state.dir_rects { if dr.depth == 0 { continue; } - let rect = Rect::from_min_size( - Pos2::new(dr.rect.x as f32, dr.rect.y as f32), - Vec2::new(dr.rect.w as f32, dr.rect.h as f32), - ); + let rect = to_screen(&dr.rect); if rect.contains(pos) && dr.depth > best_depth { best_depth = dr.depth; state.hovered_dir = Some(dr.node_id); @@ -321,10 +315,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if dr.depth == 0 || dr.depth > 3 { continue; } - let rect = Rect::from_min_size( - Pos2::new(dr.rect.x as f32, dr.rect.y as f32), - Vec2::new(dr.rect.w as f32, dr.rect.h as f32), - ); + let rect = to_screen(&dr.rect); if rect.contains(pos) && rect.width() > 5.0 && rect.height() > 5.0 { let alpha = if dr.depth == 1 { 50 } else { 30 }; painter.rect_stroke( @@ -344,10 +335,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if let Some(hovered_id) = state.hovered_node { for cr in &state.colored_rects { if cr.node_id == hovered_id { - let rect = Rect::from_min_size( - Pos2::new(cr.rect.x as f32, cr.rect.y as f32), - Vec2::new(cr.rect.w as f32, cr.rect.h as f32), - ); + let rect = to_screen(&cr.rect); painter.rect_stroke( rect, 2.0, @@ -371,10 +359,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let mut found = false; for dr in &state.dir_rects { if dr.node_id == selected_id { - let rect = Rect::from_min_size( - Pos2::new(dr.rect.x as f32, dr.rect.y as f32), - Vec2::new(dr.rect.w as f32, dr.rect.h as f32), - ); + let rect = to_screen(&dr.rect); painter.rect_stroke( rect, 0.0, @@ -400,10 +385,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { if !found { for cr in &state.colored_rects { if cr.node_id == selected_id { - let rect = Rect::from_min_size( - Pos2::new(cr.rect.x as f32, cr.rect.y as f32), - Vec2::new(cr.rect.w as f32, cr.rect.h as f32), - ); + let rect = to_screen(&cr.rect); // Inner white + outer accent ring — readable against any // treemap color. painter.rect_stroke( @@ -651,6 +633,14 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { } } +/// Treemap layout rect (f64, absolute screen coords) → egui rect. +fn to_screen(r: &TRect) -> Rect { + Rect::from_min_size( + Pos2::new(r.x as f32, r.y as f32), + Vec2::new(r.w as f32, r.h as f32), + ) +} + fn lerp_color(a: &Color32, b: &Color32, t: f32) -> Color32 { let lerp = |a: u8, b: u8| -> u8 { (a as f32 + (b as f32 - a as f32) * t).clamp(0.0, 255.0) as u8 }; From 253395e18c6fa73977ea88c98038f716ab33e808 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:50:40 +0200 Subject: [PATCH 30/35] refactor: table-drive color-mode shortcuts, derive Default for ScanProgressInfo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cmd+1/2/3 were three copies of the same three-statement body; now one loop over (key, mode). ScanProgressInfo's zeroed literal was written out twice — derive Default and spread it. Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 40 +++++++++++++--------------------------- 1 file changed, 13 insertions(+), 27 deletions(-) diff --git a/src/app.rs b/src/app.rs index a528db9..6232f06 100644 --- a/src/app.rs +++ b/src/app.rs @@ -97,6 +97,7 @@ pub struct AppState { pub status_message: Option, } +#[derive(Default)] pub struct ScanProgressInfo { pub files: usize, pub dirs: usize, @@ -193,14 +194,7 @@ impl App { tree: None, scan_root, scan_receiver: None, - scan_progress: ScanProgressInfo { - files: 0, - dirs: 0, - bytes: 0, - errors: 0, - scanning: false, - current_path: None, - }, + scan_progress: ScanProgressInfo::default(), scan_start: None, scan_duration_secs: 0.0, partial_refresh_receiver: None, @@ -253,12 +247,8 @@ impl App { let (tx, rx) = crossbeam_channel::unbounded(); self.state.scan_receiver = Some(rx); self.state.scan_progress = ScanProgressInfo { - files: 0, - dirs: 0, - bytes: 0, - errors: 0, scanning: true, - current_path: None, + ..Default::default() }; self.state.scan_start = Some(Instant::now()); self.state.freed_this_session = 0; @@ -674,20 +664,16 @@ impl eframe::App for App { } else if ctx.input(|i| i.key_pressed(egui::Key::R) && i.modifiers.command) { self.state.request_rescan = true; } - if ctx.input(|i| i.key_pressed(egui::Key::Num1) && i.modifiers.command) { - self.state.color_mode = ColorMode::Extension; - self.state.treemap_dirty = true; - crate::state::save(&self.state.scan_root, self.state.color_mode); - } - if ctx.input(|i| i.key_pressed(egui::Key::Num2) && i.modifiers.command) { - self.state.color_mode = ColorMode::Depth; - self.state.treemap_dirty = true; - crate::state::save(&self.state.scan_root, self.state.color_mode); - } - if ctx.input(|i| i.key_pressed(egui::Key::Num3) && i.modifiers.command) { - self.state.color_mode = ColorMode::Age; - self.state.treemap_dirty = true; - crate::state::save(&self.state.scan_root, self.state.color_mode); + for (key, mode) in [ + (egui::Key::Num1, ColorMode::Extension), + (egui::Key::Num2, ColorMode::Depth), + (egui::Key::Num3, ColorMode::Age), + ] { + if ctx.input(|i| i.key_pressed(key) && i.modifiers.command) { + self.state.color_mode = mode; + self.state.treemap_dirty = true; + crate::state::save(&self.state.scan_root, self.state.color_mode); + } } if ctx.input(|i| i.key_pressed(egui::Key::O) && i.modifiers.command) { if let Some(path) = crate::platform::dialogs::pick_folder(&self.state.scan_root) { From bdb983602778077dde5f858aa862ca466a13d636 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:56:54 +0200 Subject: [PATCH 31/35] fix: is_alive returns false for out-of-range NodeId Co-Authored-By: Claude Sonnet 4.6 --- src/scanner/tree.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index b321224..6422df2 100644 --- a/src/scanner/tree.rs +++ b/src/scanner/tree.rs @@ -384,6 +384,9 @@ impl FileTree { } pub fn is_alive(&self, id: NodeId) -> bool { + if id >= self.nodes.len() { + return false; + } self.nodes[id].alive } From e5376fa31980afcadae3207be404705bee73d032 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 01:58:09 +0200 Subject: [PATCH 32/35] fix: start_scan clears partial_refresh_receiver and status_message Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/app.rs b/src/app.rs index 6232f06..0231d5b 100644 --- a/src/app.rs +++ b/src/app.rs @@ -265,6 +265,8 @@ impl App { self.state.cleanup_candidates.clear(); self.state.cleanup_selected.clear(); self.state.scroll_dir_tree_to = None; + self.state.partial_refresh_receiver = None; + self.state.status_message = None; crate::scanner::scan(self.state.scan_root.clone(), tx); } From b7cb2ccb184991980c79bf3240c8372a220a6950 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 02:00:08 +0200 Subject: [PATCH 33/35] fix: guard keyboard handlers with is_alive before accessing node data Co-Authored-By: Claude Sonnet 4.6 --- src/app.rs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/src/app.rs b/src/app.rs index 0231d5b..bf53cb9 100644 --- a/src/app.rs +++ b/src/app.rs @@ -275,7 +275,7 @@ impl App { /// isn't a directory. pub fn start_partial_refresh(&mut self, node_id: NodeId) { let Some(tree) = &self.state.tree else { return }; - if !tree.node(node_id).is_dir() { + if !tree.is_alive(node_id) || !tree.node(node_id).is_dir() { return; } let path = tree.full_path(node_id); @@ -653,6 +653,9 @@ impl eframe::App for App { // Refresh just the selected directory (or its parent dir if a file is selected). if let Some(sel) = self.state.selected_node { let target = self.state.tree.as_ref().and_then(|t| { + if !t.is_alive(sel) { + return None; + } if t.node(sel).is_dir() { Some(sel) } else { @@ -692,17 +695,21 @@ impl eframe::App for App { if ctx.input(|i| i.key_pressed(egui::Key::Backspace) && i.modifiers.command) { if let Some(node_id) = self.state.selected_node { if let Some(tree) = &self.state.tree { - let name = tree.name(node_id).to_string(); - let size = tree.node(node_id).size; - self.state.pending_action = Some(PendingAction::ConfirmTrash(node_id, name, size)); + if tree.is_alive(node_id) { + let name = tree.name(node_id).to_string(); + let size = tree.node(node_id).size; + self.state.pending_action = Some(PendingAction::ConfirmTrash(node_id, name, size)); + } } } } if ctx.input(|i| i.key_pressed(egui::Key::Enter)) { if let Some(node_id) = self.state.selected_node { if let Some(tree) = &self.state.tree { - let path = tree.full_path(node_id); - crate::platform::finder::reveal_in_finder(&path); + if tree.is_alive(node_id) { + let path = tree.full_path(node_id); + crate::platform::finder::reveal_in_finder(&path); + } } } } From 908e63154396e917c52a4fe330780d01d71a59f5 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 02:00:44 +0200 Subject: [PATCH 34/35] fix: recompute_sizes_upward skips dead nodes Co-Authored-By: Claude Sonnet 4.6 --- src/scanner/tree.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index 6422df2..7f5476b 100644 --- a/src/scanner/tree.rs +++ b/src/scanner/tree.rs @@ -406,6 +406,9 @@ impl FileTree { pub fn recompute_sizes_upward(&mut self, target: NodeId) { let mut current = Some(target); while let Some(id) = current { + if !self.nodes[id].alive { + break; + } if self.nodes[id].is_dir() { let (size, files, dirs) = self.aggregate_dir(id); self.nodes[id].size = size; From ec834085e97d10b401051bdc43fbda8dba0ed4e1 Mon Sep 17 00:00:00 2001 From: Pavol Dravecky Date: Tue, 22 Sep 2026 03:09:54 +0200 Subject: [PATCH 35/35] refactor: add dir_of(id) helper to eliminate repeated is_alive/is_dir/parent checks Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 ++ src/app.rs | 34 ++-------------------- src/scanner/tree.rs | 64 ++++++++++++++++------------------------- src/ui/dir_tree.rs | 12 ++------ src/ui/treemap_view.rs | 7 +---- src/ui/widgets.rs | 65 +++++++++++++++++++++--------------------- 6 files changed, 65 insertions(+), 119 deletions(-) diff --git a/.gitignore b/.gitignore index 9797e0e..fc5beec 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ /dist .superpowers/ .DS_Store +.omc/ +.wrangler/ diff --git a/src/app.rs b/src/app.rs index bf53cb9..b0756a2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -470,16 +470,7 @@ impl App { if order.is_empty() { return; } - // Current dir = selected dir, or a selected file's parent dir. - let current = self.state.selected_node.and_then(|sel| { - if !tree.is_alive(sel) { - None - } else if tree.node(sel).is_dir() { - Some(sel) - } else { - tree.node(sel).parent - } - }); + let current = self.state.selected_node.and_then(|sel| tree.dir_of(sel)); let next = match current.and_then(|c| order.iter().position(|&n| n == c)) { Some(idx) => { if down { @@ -503,17 +494,7 @@ impl App { fn nav_expand_selected(&mut self, expand: bool) { let Some(sel) = self.state.selected_node else { return }; let Some(tree) = &mut self.state.tree else { return }; - if !tree.is_alive(sel) { - return; - } - let target = if tree.node(sel).is_dir() { - sel - } else { - match tree.node(sel).parent { - Some(p) => p, - None => return, - } - }; + let Some(target) = tree.dir_of(sel) else { return }; if let crate::scanner::tree::NodeKind::Directory { expanded, .. } = &mut tree.node_mut(target).kind { @@ -652,16 +633,7 @@ impl eframe::App for App { }) { // Refresh just the selected directory (or its parent dir if a file is selected). if let Some(sel) = self.state.selected_node { - let target = self.state.tree.as_ref().and_then(|t| { - if !t.is_alive(sel) { - return None; - } - if t.node(sel).is_dir() { - Some(sel) - } else { - t.node(sel).parent - } - }); + let target = self.state.tree.as_ref().and_then(|t| t.dir_of(sel)); if let Some(t) = target { self.start_partial_refresh(t); } diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index 7f5476b..b8ec925 100644 --- a/src/scanner/tree.rs +++ b/src/scanner/tree.rs @@ -83,10 +83,6 @@ impl Node { matches!(self.kind, NodeKind::Directory { .. }) } - pub fn is_file(&self) -> bool { - matches!(self.kind, NodeKind::File { .. }) - } - pub fn children(&self) -> &[NodeId] { match &self.kind { NodeKind::Directory { children, .. } => children, @@ -218,10 +214,17 @@ impl FileTree { id } - /// Aggregate a directory's `size`, `file_count` and `subdir_count` from its + /// Recompute a directory's `size`, `file_count` and `subdir_count` from its /// direct children. Requires the children's own counts to already be /// correct (true in a bottom-up reverse pass and after grafting). - fn aggregate_dir(&self, id: NodeId) -> (u64, u64, u64) { + fn aggregate_dir(&mut self, id: NodeId) { + let (size, files, dirs) = self.child_totals(id); + self.nodes[id].size = size; + self.nodes[id].file_count = files; + self.nodes[id].subdir_count = dirs; + } + + fn child_totals(&self, id: NodeId) -> (u64, u64, u64) { let mut size = 0u64; let mut files = 0u64; let mut dirs = 0u64; @@ -250,10 +253,7 @@ impl FileTree { if !self.nodes[i].alive || !self.nodes[i].is_dir() { continue; } - let (size, files, dirs) = self.aggregate_dir(i); - self.nodes[i].size = size; - self.nodes[i].file_count = files; - self.nodes[i].subdir_count = dirs; + self.aggregate_dir(i); } } @@ -362,27 +362,6 @@ impl FileTree { } } - pub fn collect_files(&self, root: NodeId) -> Vec { - let mut files = vec![]; - self.collect_files_recursive(root, &mut files); - files - } - - fn collect_files_recursive(&self, id: NodeId, files: &mut Vec) { - let node = &self.nodes[id]; - if !node.alive { - return; - } - match &node.kind { - NodeKind::File { .. } => files.push(id), - NodeKind::Directory { children, .. } => { - for &child in children { - self.collect_files_recursive(child, files); - } - } - } - } - pub fn is_alive(&self, id: NodeId) -> bool { if id >= self.nodes.len() { return false; @@ -390,6 +369,19 @@ impl FileTree { self.nodes[id].alive } + /// The directory a node belongs to: the node itself when it's a directory, + /// otherwise its parent. `None` for dead or out-of-range ids. + pub fn dir_of(&self, id: NodeId) -> Option { + if !self.is_alive(id) { + return None; + } + if self.nodes[id].is_dir() { + Some(id) + } else { + self.nodes[id].parent + } + } + /// Marks all descendants of `target` as dead and empties its children /// list. The node itself stays. Used as the first step of grafting a /// freshly-scanned subtree onto an existing tree. @@ -410,10 +402,7 @@ impl FileTree { break; } if self.nodes[id].is_dir() { - let (size, files, dirs) = self.aggregate_dir(id); - self.nodes[id].size = size; - self.nodes[id].file_count = files; - self.nodes[id].subdir_count = dirs; + self.aggregate_dir(id); } current = self.nodes[id].parent; } @@ -479,10 +468,7 @@ impl FileTree { // than their parents), then propagate target + ancestors upward. for i in (first_new..self.nodes.len()).rev() { if self.nodes[i].is_dir() { - let (size, files, dirs) = self.aggregate_dir(i); - self.nodes[i].size = size; - self.nodes[i].file_count = files; - self.nodes[i].subdir_count = dirs; + self.aggregate_dir(i); } } diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index 5cb989e..601747f 100644 --- a/src/ui/dir_tree.rs +++ b/src/ui/dir_tree.rs @@ -86,16 +86,8 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { let root = tree.root(); let root_size = tree.node(root).size; - // Determine which directory to highlight: - // If selected node is a file, highlight its parent directory - let highlighted_dir = state.selected_node.and_then(|sel| { - let tree = state.tree.as_ref()?; - if tree.node(sel).is_dir() { - Some(sel) - } else { - tree.node(sel).parent - } - }); + // A selected file highlights its parent directory row. + let highlighted_dir = state.selected_node.and_then(|sel| tree.dir_of(sel)); egui::ScrollArea::both() .auto_shrink([false, false]) diff --git a/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index 70cfc14..36b9ce7 100644 --- a/src/ui/treemap_view.rs +++ b/src/ui/treemap_view.rs @@ -459,12 +459,7 @@ pub fn show(ui: &mut Ui, state: &mut AppState) { state.selected_extension = None; state.expand_to_node(node_id); if let Some(tree) = &state.tree { - let dir_target = if tree.node(node_id).is_dir() { - Some(node_id) - } else { - tree.node(node_id).parent - }; - state.scroll_dir_tree_to = dir_target; + state.scroll_dir_tree_to = tree.dir_of(node_id); } } } diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index cc0f9d7..6d99c10 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -1,22 +1,28 @@ use crate::ui::theme; use egui::{Color32, CornerRadius, Margin, RichText, Stroke, Ui, Vec2}; -/// Filled accent button — primary call to action. -pub fn primary_button(ui: &mut Ui, label: &str) -> egui::Response { +/// Solid-fill button with a bold white label. `active_stroke` is the +/// pressed-state outline. Shared by `primary_button` and `danger_button`. +fn filled_button( + ui: &mut Ui, + label: &str, + fill: Color32, + hover_fill: Color32, + active_stroke: Stroke, +) -> egui::Response { ui.scope(|ui| { let v = &mut ui.style_mut().visuals.widgets; - v.inactive.bg_fill = theme::ACCENT; - v.inactive.weak_bg_fill = theme::ACCENT; - v.inactive.bg_stroke = Stroke::NONE; - v.inactive.fg_stroke = Stroke::new(1.0, Color32::WHITE); - v.hovered.bg_fill = theme::ACCENT_HOVER; - v.hovered.weak_bg_fill = theme::ACCENT_HOVER; - v.hovered.bg_stroke = Stroke::NONE; - v.hovered.fg_stroke = Stroke::new(1.0, Color32::WHITE); - v.active.bg_fill = theme::ACCENT; - v.active.weak_bg_fill = theme::ACCENT; - v.active.bg_stroke = Stroke::new(1.0, theme::ACCENT_LIGHT); - v.active.fg_stroke = Stroke::new(1.0, Color32::WHITE); + for (state, bg) in [ + (&mut v.inactive, fill), + (&mut v.hovered, hover_fill), + (&mut v.active, fill), + ] { + state.bg_fill = bg; + state.weak_bg_fill = bg; + state.bg_stroke = Stroke::NONE; + state.fg_stroke = Stroke::new(1.0, Color32::WHITE); + } + v.active.bg_stroke = active_stroke; ui.add(egui::Button::new( RichText::new(label).color(Color32::WHITE).strong(), )) @@ -24,6 +30,17 @@ pub fn primary_button(ui: &mut Ui, label: &str) -> egui::Response { .inner } +/// Filled accent button — primary call to action. +pub fn primary_button(ui: &mut Ui, label: &str) -> egui::Response { + filled_button( + ui, + label, + theme::ACCENT, + theme::ACCENT_HOVER, + Stroke::new(1.0, theme::ACCENT_LIGHT), + ) +} + /// Outlined / subtle button — secondary actions. pub fn ghost_button(ui: &mut Ui, label: &str) -> egui::Response { ui.scope(|ui| { @@ -47,25 +64,7 @@ pub fn ghost_button(ui: &mut Ui, label: &str) -> egui::Response { /// Destructive action button — red fill, used in confirm dialogs. pub fn danger_button(ui: &mut Ui, label: &str) -> egui::Response { - ui.scope(|ui| { - let v = &mut ui.style_mut().visuals.widgets; - v.inactive.bg_fill = theme::DANGER; - v.inactive.weak_bg_fill = theme::DANGER; - v.inactive.bg_stroke = Stroke::NONE; - v.inactive.fg_stroke = Stroke::new(1.0, Color32::WHITE); - v.hovered.bg_fill = theme::DANGER_HOVER; - v.hovered.weak_bg_fill = theme::DANGER_HOVER; - v.hovered.bg_stroke = Stroke::NONE; - v.hovered.fg_stroke = Stroke::new(1.0, Color32::WHITE); - v.active.bg_fill = theme::DANGER; - v.active.weak_bg_fill = theme::DANGER; - v.active.bg_stroke = Stroke::NONE; - v.active.fg_stroke = Stroke::new(1.0, Color32::WHITE); - ui.add(egui::Button::new( - RichText::new(label).color(Color32::WHITE).strong(), - )) - }) - .inner + filled_button(ui, label, theme::DANGER, theme::DANGER_HOVER, Stroke::NONE) } /// Centered, chrome-free confirmation modal: heading, one line of detail, and