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 7fe0453..b0756a2 100644 --- a/src/app.rs +++ b/src/app.rs @@ -92,8 +92,12 @@ 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, } +#[derive(Default)] pub struct ScanProgressInfo { pub files: usize, pub dirs: usize, @@ -157,8 +161,6 @@ impl ExtSortMode { } pub enum PendingAction { - RevealInFinder(PathBuf), - MoveToTrash(NodeId), ConfirmTrash(NodeId, String, u64), ConfirmBatchTrash(Vec, u64), ConfirmEmptyTrash, @@ -173,7 +175,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") }), ); @@ -192,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, @@ -235,6 +230,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. @@ -251,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; @@ -266,8 +258,15 @@ 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; + self.state.partial_refresh_receiver = None; + self.state.status_message = None; crate::scanner::scan(self.state.scan_root.clone(), tx); } @@ -276,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); @@ -338,17 +337,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; @@ -377,7 +369,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, @@ -398,6 +390,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, @@ -474,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 { @@ -507,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 { @@ -570,6 +547,7 @@ impl App { } Err(e) => { eprintln!("Delete failed: {}", e); + self.state.status_message = Some(format!("Delete failed: {e}")); } } } @@ -655,13 +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.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); } @@ -669,20 +641,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) { @@ -699,17 +667,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); + } } } } @@ -779,7 +751,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 {}", @@ -818,6 +790,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 @@ -903,191 +883,89 @@ 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); - } - }, - ); - }); - }); - } - PendingAction::RefreshSubtree(node_id) => { - let node_id = *node_id; - self.state.pending_action = None; - self.start_partial_refresh(node_id); + 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 => { - 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); - } - }, - ); - }); - }); + 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 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); - } - }, - ); - }); - }); + 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()); + } + } } - _ => { - // RevealInFinder / MoveToTrash variants are unused — - // context menu performs those actions inline. + PendingAction::RefreshSubtree(node_id) => { + let node_id = *node_id; self.state.pending_action = None; + self.start_partial_refresh(node_id); } } } - 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); + self.state.status_message = Some(format!("Empty Trash failed: {e}")); } } } diff --git a/src/cleanup.rs b/src/cleanup.rs index c673b78..23a1d9e 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", @@ -89,7 +84,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)); } } @@ -111,17 +108,17 @@ 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)", "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.", )), @@ -137,10 +134,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, }; } @@ -157,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; diff --git a/src/flywheel.rs b/src/flywheel.rs index 7dc8ef4..d2ba46b 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. @@ -85,10 +86,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. @@ -385,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")); 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)) } diff --git a/src/scanner/tree.rs b/src/scanner/tree.rs index 330fc7a..b8ec925 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. @@ -86,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, @@ -221,14 +214,17 @@ 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 + /// 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; @@ -257,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); } } @@ -293,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()); } } @@ -368,52 +362,31 @@ impl FileTree { } } - pub fn collect_files(&self, root: NodeId) -> Vec { - let mut files = vec![]; - self.collect_files_recursive(root, &mut files); - files + pub fn is_alive(&self, id: NodeId) -> bool { + if id >= self.nodes.len() { + return false; + } + self.nodes[id].alive } - fn collect_files_recursive(&self, id: NodeId, files: &mut Vec) { - let node = &self.nodes[id]; - if !node.alive { - return; + /// 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; } - match &node.kind { - NodeKind::File { .. } => files.push(id), - NodeKind::Directory { children, .. } => { - for &child in children { - self.collect_files_recursive(child, files); - } - } + if self.nodes[id].is_dir() { + Some(id) + } else { + self.nodes[id].parent } } - 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 - } - /// 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. 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(); } @@ -425,11 +398,11 @@ 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; - self.nodes[id].file_count = files; - self.nodes[id].subdir_count = dirs; + self.aggregate_dir(id); } current = self.nodes[id].parent; } @@ -467,7 +440,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, @@ -475,7 +448,6 @@ impl FileTree { src_node.modified, depth, ); - let _ = new_id; } NodeKind::Directory { children, .. } => { let new_id = self.add_dir( @@ -496,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/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/cleanup_window.rs b/src/ui/cleanup_window.rs index fd7718c..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 @@ -222,12 +227,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 diff --git a/src/ui/dir_tree.rs b/src/ui/dir_tree.rs index bc2ef2f..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]) @@ -187,8 +179,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() }; 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/help_window.rs b/src/ui/help_window.rs index 46dfb36..567c24e 100644 --- a/src/ui/help_window.rs +++ b/src/ui/help_window.rs @@ -134,8 +134,10 @@ 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_text.clear(); + if state.flywheel.is_enabled() { + state.feedback_sent = true; + state.feedback_text.clear(); + } } if !state.flywheel.is_enabled() { ui.label( 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/src/ui/treemap_view.rs b/src/ui/treemap_view.rs index 84c57c8..36b9ce7 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 @@ -287,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, @@ -307,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); @@ -320,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( @@ -343,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, @@ -370,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, @@ -399,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( @@ -432,11 +415,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() }; @@ -473,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); } } } @@ -647,6 +628,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 }; diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index 5d8b95e..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,55 @@ 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 +/// 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. 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]