Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f53ad79
fix: char-safe label truncation for non-ASCII filenames
Chartres Oct 11, 2026
5a6bb27
fix: clear partial_refresh_receiver on new full scan
Chartres Oct 11, 2026
a176225
fix: deduplicate hard links to prevent size overcounting
Chartres Oct 11, 2026
cc9180a
fix: guard against double-remove and stale ConfirmTrash on dead nodes
Chartres Oct 11, 2026
dcf67b3
fix: selecting \"(none)\" extension now lights extension-less files
Chartres Oct 11, 2026
7e1691c
fix: cleanup window category headers now appear exactly once per group
Chartres Oct 11, 2026
3204a49
fix: restrict \"target\" cleanup rule to actual Rust build directories
Chartres Oct 11, 2026
1dde365
fix: use cached file_count instead of walking tree on every frame
Chartres Oct 11, 2026
066e9cd
fix: reset treemap view when trashing the zoomed-into directory
Chartres Oct 11, 2026
e34e154
fix: remove dead code in tree.rs (StrRef::EMPTY, is_file, node_count,…
Chartres Oct 11, 2026
0962166
fix: remove unused BAR_FILL constant and unused ColorMode import
Chartres Oct 11, 2026
3726b23
fix: reap curl children to prevent zombie accumulation
Chartres Oct 11, 2026
771a188
refactor: drop never-constructed PendingAction variants (RevealInFind…
Chartres Oct 11, 2026
bafe7b8
refactor: remove unused FileTree::collect_files and empty StrRef impl
Chartres Oct 11, 2026
23e7dda
refactor: aggregate_dir writes counts in place instead of three copy-…
Chartres Oct 11, 2026
3602ced
fix: clear pending_action in start_scan to prevent stale NodeId confi…
Chartres Oct 11, 2026
3a62ea2
fix: reset hovered_dir in start_scan and scan Done handler to prevent…
Chartres Oct 11, 2026
25d0234
fix: document logical-size overcounting for sparse/clone/compressed f…
Chartres Oct 11, 2026
221171d
fix: register stub dir node when directory metadata fails to preserve…
Chartres Oct 11, 2026
7dbc9f7
fix: exclude .Trash/.Trashes from cleanup candidates
Chartres Oct 11, 2026
de7ef55
fix: surface batch-trash failures in the status bar
Chartres Oct 11, 2026
8ebe49e
fix: suppress Move to Trash for scan root and synthetic nodes in cont…
Chartres Oct 11, 2026
dd6426d
fix: show full path in Confirm Delete dialog instead of truncated fil…
Chartres Oct 11, 2026
80fe661
test: add cleanup::classify and find_candidates tests
Chartres Oct 11, 2026
4f6c8d3
test: add tempdir-based walk_directory test
Chartres Oct 11, 2026
a615f76
test: add remove_node double-dead guard test
Chartres Oct 11, 2026
53c3fee
refactor: extract confirm_dialog helper for the three trash confirmat…
Chartres Oct 11, 2026
572215f
refactor: dedupe dead-view-root reset into AppState::reset_dead_view_…
Chartres Oct 11, 2026
6849d0c
refactor: share iterative mark_descendants_dead between remove_node a…
Chartres Oct 11, 2026
c9e4375
fix: enforce path-component boundary in suffix cleanup rules
Chartres Oct 11, 2026
f9679e4
fix: replace vacuous Trash category assertion with path check
Chartres Oct 11, 2026
18c35de
fix: skip root node in find_candidates
Chartres Oct 11, 2026
c16aa73
fix: warn about credentials in .gradle/.m2 and named volumes in Docker
Chartres Oct 11, 2026
2a77c1c
fix: apply root/synthetic guard to Cmd+Backspace trash shortcut
Chartres Oct 11, 2026
c099ff4
fix: handle Disconnected in poll_scan to end spinner on thread panic
Chartres Oct 11, 2026
2c06956
fix: keep batch_error_msg visible until next scan instead of one frame
Chartres Oct 11, 2026
f1b7436
fix: surface single-trash and empty-trash errors in the status bar
Chartres Oct 11, 2026
3745568
fix: reset selected_extension when starting a new scan
Chartres Oct 11, 2026
9746e4f
fix: remove duplicate size sort inside squarify layout
Chartres Oct 11, 2026
ad51d98
refactor: share post-trash state cleanup between single and batch delete
Chartres Oct 11, 2026
4680583
refactor: derive Default for ScanProgressInfo instead of two zeroed l…
Chartres Oct 11, 2026
54c07f4
refactor: table-drive the Cmd+1/2/3 color-mode shortcuts
Chartres Oct 11, 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
579 changes: 267 additions & 312 deletions src/app.rs

Large diffs are not rendered by default.

41 changes: 28 additions & 13 deletions src/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> {
(
"Library/Containers/com.docker.docker",
"Docker Desktop Data",
"Docker images, volumes, build cache. Prefer `docker system prune -a` for finer-grained cleanup.",
"Docker images, build cache, and named volumes. Named volumes may contain database data that cannot be regenerated. Prefer `docker system prune` for finer-grained cleanup.",
),
(
"Library/Containers/com.apple.iCloud.iCloudDrive",
Expand All @@ -90,7 +90,13 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> {
];
for &(suffix, label, desc) in SUFFIX_RULES {
if s.ends_with(suffix) {
return Some((label, desc));
// Require a component boundary: the char before the suffix must be
// '/' (or the suffix covers the whole string). Without this check,
// "/x/MyLibrary/Caches" would match the "Library/Caches" rule.
let pos = s.len() - suffix.len();
if pos == 0 || s.as_bytes()[pos - 1] == b'/' {
return Some((label, desc));
}
}
}

Expand All @@ -111,20 +117,22 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> {
)),
".gradle" => Some((
"Gradle caches",
"Gradle redownloads dependencies on next build.",
"Gradle build cache and local dependency store. May contain gradle.properties with credentials — review before deleting.",
)),
".m2" => Some((
"Maven local repo",
"Maven local artifacts cache; rebuilt on next dependency resolve.",
"Maven local artifact cache. May contain settings.xml with credentials — review before deleting.",
)),
"DerivedData" => Some((
"DerivedData (loose)",
"Xcode build artifacts found outside the standard location.",
)),
"target" => Some((
"Rust target/",
"`cargo build` recreates these. Often the largest single dir on a Rust dev machine.",
)),
"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.",
))
}
".next" => Some((
".next/",
"Next.js build output. Regenerated on `next build`.",
Expand All @@ -137,10 +145,9 @@ 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.",
)),
// .Trash / .Trashes are intentionally excluded: moving them to the
// Trash via the cleanup button would incorrectly nest the system
// Trash inside itself. Use the dedicated "Empty Trash…" button.
_ => None,
};
}
Expand All @@ -150,9 +157,17 @@ pub fn classify(path: &Path) -> Option<(&'static str, &'static str)> {

/// Walks the tree once and returns all directories matching cleanup rules,
/// sorted by size descending. Doesn't recurse into matched directories.
/// The root node itself is never a candidate — classifying the scan root
/// (e.g. ~/Library) would be catastrophic.
pub fn find_candidates(tree: &FileTree, root: NodeId) -> Vec<CleanupCandidate> {
let mut candidates = Vec::new();
walk(tree, root, &mut candidates);
if tree.is_alive(root) {
// Skip root itself; walk children directly.
let children: Vec<NodeId> = tree.node(root).children().to_vec();
for child in children {
walk(tree, child, &mut candidates);
}
}
candidates.sort_by(|a, b| b.size.cmp(&a.size));
candidates
}
Expand Down
6 changes: 5 additions & 1 deletion src/flywheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -278,7 +278,7 @@ impl Flywheel {
/// so a slow or unreachable network can't stall a UI frame.
fn post(&self, table: &str, body: &str) {
let endpoint = format!("{}/rest/v1/{}", self.url, table);
let _ = Command::new("curl")
let child = Command::new("curl")
.args([
"-s",
"-m",
Expand All @@ -301,6 +301,10 @@ impl Flywheel {
.stdout(Stdio::null())
.stderr(Stdio::null())
.spawn();
// Reap the child on a background thread so it doesn't become a zombie.
if let Ok(mut child) = child {
std::thread::spawn(move || { let _ = child.wait(); });
}
}
}

Expand Down
95 changes: 21 additions & 74 deletions src/scanner/tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +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.
#[derive(Debug, Default)]
Expand Down Expand Up @@ -86,10 +82,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 +213,10 @@ 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).
fn aggregate_dir(&self, id: NodeId) -> (u64, u64, u64) {
fn aggregate_dir(&mut self, id: NodeId) {
let mut size = 0u64;
let mut files = 0u64;
let mut dirs = 0u64;
Expand All @@ -247,7 +235,10 @@ impl FileTree {
}
}
}
(size, files, dirs)
let node = &mut self.nodes[id];
node.size = size;
node.file_count = files;
node.subdir_count = dirs;
}

pub fn compute_sizes(&mut self) {
Expand All @@ -257,14 +248,14 @@ 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);
}
}

pub fn remove_node(&mut self, id: NodeId) {
if !self.nodes[id].alive {
return;
}
let size = self.nodes[id].size;
let parent = self.nodes[id].parent;

Expand Down Expand Up @@ -293,16 +284,16 @@ 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 node beneath `id` dead (not `id` itself). Iterative so a
/// pathologically deep tree can't overflow the stack.
fn mark_descendants_dead(&mut self, id: NodeId) {
let mut stack: Vec<NodeId> = self.nodes[id].children().to_vec();
while let Some(id) = stack.pop() {
self.nodes[id].alive = false;
stack.extend_from_slice(self.nodes[id].children());
}
}

Expand Down Expand Up @@ -368,34 +359,6 @@ impl FileTree {
}
}

pub fn collect_files(&self, root: NodeId) -> Vec<NodeId> {
let mut files = vec![];
self.collect_files_recursive(root, &mut files);
files
}

fn collect_files_recursive(&self, id: NodeId, files: &mut Vec<NodeId>) {
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 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
}
Expand All @@ -404,16 +367,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<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 @@ -426,10 +380,7 @@ impl FileTree {
let mut current = Some(target);
while let Some(id) = current {
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 +418,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 +446,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
48 changes: 46 additions & 2 deletions src/scanner/walk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@ use super::ScanProgress;
use crate::scanner::tree::FileTree;
use crossbeam_channel::Sender;
use jwalk::WalkDir;
use std::collections::HashMap;
use std::collections::{HashMap, HashSet};
use std::os::unix::ffi::OsStrExt;
use std::os::unix::fs::MetadataExt;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::SystemTime;
Expand Down Expand Up @@ -134,6 +135,9 @@ pub fn walk_directory(root: PathBuf, tx: Sender<ScanProgress>) {
let mut byte_count: u64 = 0;
let mut error_count: usize = 0;
let mut progress_counter: usize = 0;
// Track (dev, ino) to skip hard-linked duplicates; avoids double-counting
// files whose nlink > 1 that appear under multiple directories.
let mut seen_inodes: HashSet<(u64, u64)> = HashSet::new();

let skip_paths = Arc::new(build_skip_paths());
let walker_skip_paths = Arc::clone(&skip_paths);
Expand Down Expand Up @@ -193,6 +197,26 @@ pub fn walk_directory(root: PathBuf, tx: Sender<ScanProgress>) {
Ok(m) => m,
Err(_) => {
error_count += 1;
// For a directory whose metadata is unreadable, still register a
// stub node so that any children walkdir yields for it are
// attached at the correct path rather than being reparented to
// find_closest_ancestor, which would give them a wrong full_path
// and break Trash/Reveal operations.
if entry.file_type().is_dir() {
let d = path
.components()
.count()
.saturating_sub(root.components().count()) as u16;
let stub_id = tree.add_dir(
parent_id,
file_name_bytes,
d < 2,
SystemTime::UNIX_EPOCH,
d,
);
dir_map.insert(path.to_path_buf(), stub_id);
dir_count += 1;
}
continue;
}
};
Expand All @@ -211,7 +235,27 @@ pub fn walk_directory(root: PathBuf, tx: Sender<ScanProgress>) {
dir_map.insert(path.to_path_buf(), node_id);
dir_count += 1;
} else {
let size = metadata.len();
// De-duplicate hard links: only count size for the first path we
// see for a given (dev, ino) pair. The file still appears in the
// tree at its canonical position; it just contributes 0 bytes for
// subsequent hard links so totals don't overcount.
//
// NOTE: We use metadata.len() (logical EOF size), not
// stat.st_blocks * 512 (allocated disk blocks). This means:
// • Sparse files (e.g. Docker.raw) are OVERCOUNTED — they appear
// larger than the disk space they actually consume.
// • APFS clones and transparently-compressed files may also be
// overcounted because their physical footprint is smaller.
// Upgrade path: replace `metadata.len()` with
// `(metadata.blocks() * 512).max(metadata.len())` guarded by
// `metadata.blocks() > 0` to handle special files where st_blocks
// is zero (FIFOs, sockets, device nodes).
let size = if metadata.nlink() > 1 {
let key = (metadata.dev(), metadata.ino());
if seen_inodes.insert(key) { metadata.len() } else { 0 }
} else {
metadata.len()
};
let extension_owned = path
.extension()
.map(|e| e.to_string_lossy().to_lowercase());
Expand Down
4 changes: 2 additions & 2 deletions src/treemap/squarify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ pub fn layout(items: &[LayoutItem], bounds: &Rect) -> Vec<PlacedRect> {
if total_size <= 0.0 {
return vec![];
}
let mut sorted: Vec<LayoutItem> = items.to_vec();
sorted.sort_by(|a, b| b.size.partial_cmp(&a.size).unwrap());
// Callers (treemap/mod.rs) pre-sort by size descending; no re-sort needed.
let sorted: Vec<LayoutItem> = items.to_vec();
let mut result = Vec::with_capacity(sorted.len());
let mut remaining = *bounds;
let area = bounds.w * bounds.h;
Expand Down
Loading
Loading