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