Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
3fd0b9c
fix: char-safe truncation in dir_tree to avoid UTF-8 byte-slice panic
Chartres Sep 21, 2026
2611a65
fix: char-safe truncation in treemap_view to avoid UTF-8 byte-slice p…
Chartres Sep 21, 2026
089192e
fix: use taxonomy event name 'page_view' instead of 'app_open'
Chartres Sep 21, 2026
1bcea23
fix: use taxonomy event name 'key_action' instead of 'scan_completed'
Chartres Sep 21, 2026
d21b056
fix: use node.file_count instead of collect_files().len() in status bar
Chartres Sep 21, 2026
9f03ead
fix: require path-component boundary in cleanup suffix matching
Chartres Sep 21, 2026
cb8f3ab
fix: surface move_to_trash failure as status bar error message
Chartres Sep 21, 2026
5ba9f8e
fix: only set feedback_sent when telemetry is enabled
Chartres Sep 21, 2026
28f0fca
fix: path truncation in cleanup window (20+ellipsis+20 chars)
Chartres Sep 21, 2026
81337c0
fix: remove dead code children_sorted, node_count, StrRef::EMPTY
Chartres Sep 21, 2026
97f9558
fix: remove unused BAR_FILL, simplify ext_list branch, drop unused Co…
Chartres Sep 21, 2026
07a299a
refactor: drop never-constructed PendingAction variants
Chartres Sep 21, 2026
54dfbc0
refactor: use stdlib where code was hand-rolling it
Chartres Sep 21, 2026
7d885ae
refactor: one confirm_dialog widget for all three confirmations
Chartres Sep 21, 2026
9b5444c
fix: clear pending_action/hovered_dir/cleanup/scroll_dir_tree_to in s…
Chartres Sep 21, 2026
53c669e
fix: clear pending_action/hovered_dir/scroll/selected_extension in sc…
Chartres Sep 21, 2026
ea06fa9
fix: remove .Trash/.Trashes from cleanup candidates (nonsensical + ro…
Chartres Sep 21, 2026
3862bb4
fix: remove iCloud Drive evictable cache rule (data loss risk for non…
Chartres Sep 21, 2026
f6fab58
fix: warn about credential files in .gradle and .m2 cleanup descriptions
Chartres Sep 21, 2026
0320158
fix: only classify 'target' as Cargo output when Cargo.toml exists in…
Chartres Sep 21, 2026
921802c
fix: surface write error in set_telemetry_opt_out via eprintln
Chartres Sep 21, 2026
c0c255a
fix: default telemetry to opted-out when telemetry.txt is absent
Chartres Sep 21, 2026
84f57b4
fix: sort cleanup candidates by category then size for contiguous gro…
Chartres Sep 21, 2026
77e1b69
fix: empty_trash uses spawn() to avoid blocking UI thread; surface er…
Chartres Sep 21, 2026
1ec21b6
test: add classify tests for cargo target, non-cargo target, Library/…
Chartres Sep 21, 2026
777fe90
test: add no_telemetry_env_var_opts_out test for MACDIRSTAT_NO_TELEME…
Chartres Sep 21, 2026
86bd207
fix: only clear feedback_text when telemetry is enabled and send actu…
Chartres Sep 21, 2026
fd984ab
refactor: single iterative descendant-dead walk in FileTree
Chartres Sep 21, 2026
ef9ec8e
refactor: one to_screen() for treemap rect conversion
Chartres Sep 21, 2026
253395e
refactor: table-drive color-mode shortcuts, derive Default for ScanPr…
Chartres Sep 21, 2026
bdb9836
fix: is_alive returns false for out-of-range NodeId
Chartres Sep 21, 2026
e5376fa
fix: start_scan clears partial_refresh_receiver and status_message
Chartres Sep 21, 2026
b7cb2cc
fix: guard keyboard handlers with is_alive before accessing node data
Chartres Sep 22, 2026
908e631
fix: recompute_sizes_upward skips dead nodes
Chartres Sep 22, 2026
ec83408
refactor: add dir_of(id) helper to eliminate repeated is_alive/is_dir…
Chartres Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@
/dist
.superpowers/
.DS_Store
.omc/
.wrangler/
362 changes: 120 additions & 242 deletions src/app.rs

Large diffs are not rendered by default.

61 changes: 48 additions & 13 deletions src/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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 /<suffix>, preventing "MyLibrary/Caches" matching "Library/Caches".
if s == suffix || s.ends_with(&format!("/{suffix}")) {
return Some((label, desc));
}
}
Expand All @@ -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.",
)),
Expand All @@ -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,
};
}
Expand All @@ -157,6 +150,48 @@ pub fn find_candidates(tree: &FileTree, root: NodeId) -> Vec<CleanupCandidate> {
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<CleanupCandidate>) {
if !tree.is_alive(id) {
return;
Expand Down
19 changes: 15 additions & 4 deletions src/flywheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.
Expand Down Expand Up @@ -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"));
Expand Down
19 changes: 9 additions & 10 deletions src/platform/trash.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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))
}
111 changes: 40 additions & 71 deletions src/scanner/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<u8>` instead of millions of small heap allocs.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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<NodeId> = 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<NodeId> = 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());
}
}

Expand Down Expand Up @@ -368,52 +362,31 @@ impl FileTree {
}
}

pub fn collect_files(&self, root: NodeId) -> Vec<NodeId> {
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<NodeId>) {
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<NodeId> {
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<NodeId> {
let mut children: Vec<NodeId> = 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<NodeId> = 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();
}
Expand All @@ -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;
}
Expand Down Expand Up @@ -467,15 +440,14 @@ impl FileTree {
let ext_owned: Option<String> = 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,
ext_owned.as_deref(),
src_node.modified,
depth,
);
let _ = new_id;
}
NodeKind::Directory { children, .. } => {
let new_id = self.add_dir(
Expand All @@ -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);
}
}

Expand Down
2 changes: 1 addition & 1 deletion src/scanner/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ fn build_skip_paths() -> Vec<PathBuf> {

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 {
Expand Down
Loading
Loading