Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
3 changes: 3 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 1 addition & 2 deletions examples/bench.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
26 changes: 9 additions & 17 deletions src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand All @@ -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);
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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,
Expand All @@ -446,7 +433,7 @@ impl App {
}
}
}
if disconnected {
if disconnected || done {
self.state.scan_receiver = None;
}
}
Expand Down Expand Up @@ -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]);
}
}
Expand Down
10 changes: 9 additions & 1 deletion src/cleanup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -423,6 +427,10 @@ fn walk(tree: &FileTree, id: NodeId, out: &mut Vec<CleanupCandidate>) {
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.
Expand Down
16 changes: 8 additions & 8 deletions src/flywheel.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -31,6 +32,8 @@ pub const TAXONOMY: &[&str] = &[
"key_action",
"feedback_given",
"error",
"app_open",
"scan_completed",
];

#[derive(Clone)]
Expand All @@ -43,11 +46,6 @@ pub struct Flywheel {
session_id: String,
}

fn state_dir() -> Option<PathBuf> {
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)> {
Expand Down Expand Up @@ -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);
}
}
4 changes: 1 addition & 3 deletions src/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -177,9 +177,7 @@ pub fn from_json(text: &str) -> Vec<ScanSummary> {
}

fn file() -> Option<PathBuf> {
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<ScanSummary> {
Expand Down
2 changes: 0 additions & 2 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]

#[global_allocator]
static GLOBAL: mimalloc::MiMalloc = mimalloc::MiMalloc;

Expand Down
4 changes: 1 addition & 3 deletions src/plan.rs
Original file line number Diff line number Diff line change
Expand Up @@ -239,9 +239,7 @@ pub struct PlanStore {
impl PlanStore {
pub fn create(&mut self, ops: Vec<OpState>, 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(),
Expand Down
6 changes: 1 addition & 5 deletions src/platform/fda.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<PathBuf> {
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")
Expand Down
12 changes: 12 additions & 0 deletions src/relocate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ pub fn protected_reason(path: &Path, home: Option<&Path>) -> Option<String> {
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/<name>/.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
}
Expand Down Expand Up @@ -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/<name> in wrong case.
"/USERS/Shared".to_string(),
"/VOLUMES/SomeDisk".to_string(),
] {
let n = normalize(Path::new(&p)).unwrap();
assert!(
Expand Down
1 change: 0 additions & 1 deletion src/scanner/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,6 @@ pub enum ScanProgress {
current_path: Option<String>,
},
Done(FileTree),
Error(String),
}

pub fn scan(root: PathBuf, tx: Sender<ScanProgress>) {
Expand Down
4 changes: 0 additions & 4 deletions src/server/api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,6 @@ pub fn start_scan(sh: &Arc<Shared>, root: PathBuf) {
s.scanning = false;
s.finished = Some(now());
}
ScanProgress::Error(_) => {
s.scanning = false;
s.finished = Some(now());
}
}
}
});
Expand Down
3 changes: 2 additions & 1 deletion src/state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ pub struct PersistedState {
pub color_mode: Option<ColorMode>,
}

fn state_dir() -> Option<PathBuf> {
/// `~/Library/Application Support/mac-dir-stat` — shared by every module that persists files.
pub fn state_dir() -> Option<PathBuf> {
std::env::var_os("HOME").map(|h| {
PathBuf::from(h).join("Library/Application Support/mac-dir-stat")
})
Expand Down
67 changes: 37 additions & 30 deletions src/ui/dir_tree.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<NodeId> {
fn rec(tree: &FileTree, id: NodeId, sort: crate::app::DirSortMode, out: &mut Vec<NodeId>) {
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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 {
Expand All @@ -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");
}
}
2 changes: 1 addition & 1 deletion src/ui/help_window.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
1 change: 0 additions & 1 deletion tests/walk_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 { .. } => {}
}
}
Expand Down
Loading