Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
26 changes: 26 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,33 @@ Release notes for the GitHub Release: [`docs/release-notes/v0.7.0.md`](docs/rele
with an error naming the folder, and can't be approved. Before, only your approval stopped it.
A project's build output inside them (`node_modules`, `target/`, `.next/`, …) may still go.

- **Path bar** under the toolbar: the map's folder as clickable segments (current one bold), Back
and Up buttons, Backspace / ⌘↑ for the enclosing folder and ⌘[ for Back. It is always shown, not
only after zooming. Esc steps up one folder.
- **Pinned parent rows** in the folder list: while you scroll, the ancestors of the top row (up to
four) stay pinned. Clicking one selects it and scrolls the list to it.
- **Colour legends** for Age and Depth: a gradient bar with labelled ends and ticks (today, 1 mo,
1 y, 3 y+; levels 1 to 5+), drawn with the same colour functions as the map, in light and dark.
- **A new app icon** (`assets/icon/icon.svg`, rendered to `AppIcon.icns` by
`scripts/make-icon.sh`), also used as the window icon. The bundle had no icon before.
- `examples/bench.rs` times a scan and the per-frame UI cost on a real folder.

### Fixed
- **The folder list's triangle did nothing.** The row's click area was registered after the triangle
and took the click, so it selected the row and never toggled it.
- Hovering the map picks the block drawn on top, not the first one under the pointer.

### Changed
- **Faster map and list.** The folder list is virtualised (fixed-height rows, only visible ones laid
out) and the map merges blocks smaller than a square point into one, in the colour of the largest
file in them. On `~/Library` (275,000 files; 5,200 folders open) a frame went from 46 ms to 0.8 ms
and map blocks from 219,000 to 17,000.
- The pointer is a hand over rows, triangles, map blocks, buttons, checkboxes and path segments.
- Age colouring is a log scale from today to three years (it ended at one year before), and Depth
counts levels from the folder the map shows and ends at level 5 (lighter steps than before).
- Copy: explanatory sentences removed from Cleanup ("Nothing is ticked until you tick it.", the
subtitle under the title and the column notes), History, Full Disk Access, the Help tips, the
Privacy text and tooltips.
- **New look: the survey register.** Book typography on paper: Source Serif 4 (bundled, SIL Open
Font License) for all text with a real italic for notes, ink on paper, hairline rules, and one
oxblood accent for the main action in each window. Treemap fills are muted earth and slate tints.
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ Treemap directory-size visualizer for macOS. Rust + [egui](https://github.com/em
- **History, as small multiples.** The History… button shows the scanned folder over its last six scans, side by side: one small treemap per scan, blocks in the same order in every panel, each panel as tall as the space used, so you see what grew.
- **Folder list with a ledger**, synced to the treemap. Each folder shows its exact size, its **item count**, and a length bar scaled to the largest row at its level, so you compare sizes by length instead of by area. A folder's own files get one row, so a level's bars add up. Hover a row and the map outlines that folder; hover the map and the row lights up. Zoom into a folder and the list starts there. Cycle the sort between **Size · Name · Items · Recent**, and navigate the whole list from the keyboard.
- **File-type breakdown** showing total bytes, percentage, and **file count** per extension; sort by **Size · Count · Name**; click a row to dim non-matching files.
- **Cleanup, as an inventory.** Two columns: what a tool can rebuild (Xcode DerivedData / Archives / Simulators / DeviceSupport, `node_modules`, `target`, Application Caches, Docker / Cargo / rustup / Gradle / Maven / Next.js / Turbo, …), each with what it takes to get it back and when it last changed; and what only you have (Photos, Documents, Movies, Music, Mail), shown with sizes and never offered for deletion. Nothing is ticked for you. Tick what you trust, confirm once, and a short account says what went to the Trash, how much of it was rebuildable, and that the Trash is not emptied yet.
- **Cleanup, as an inventory.** Two columns: what a tool can rebuild (Xcode DerivedData / Archives / Simulators / DeviceSupport, `node_modules`, `target`, Application Caches, Docker / Cargo / rustup / Gradle / Maven / Next.js / Turbo, …), each with what it takes to get it back and when it last changed; and what only you have (Photos, Documents, Movies, Music, Mail), shown with sizes and never offered for deletion. Tick what you trust, confirm once, and a short account says what went to the Trash, how much of it was rebuildable, and that the Trash is not emptied yet.
- **Refresh subtree**: right-click a folder (or `⇧⌘R` on a selected one) to re-scan only that subtree without losing your current view.
- **Right-click menu** on every node — **Open**, Reveal in Finder, **Open Terminal Here**, **Get Info**, Copy Path, Refresh, Zoom Into, Move to Trash.
- **Free & unknown space** — scanning any volume root (`/` or a mounted volume under `/Volumes`) adds `<Free Space>` and `<Hidden / Skipped>` blocks so the treemap accounts for the whole disk.
Expand Down
Binary file added assets/icon/AppIcon.icns
Binary file not shown.
Binary file added assets/icon/icon-256.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
44 changes: 44 additions & 0 deletions assets/icon/icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
22 changes: 21 additions & 1 deletion docs/release-notes/v0.7.0.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ The Cleanup window has two columns.

- **Can be rebuilt.** Caches, build output, `node_modules`, simulators (their apps and data are not rebuilt) and the like, grouped by
rule. Each rule says what it takes to get it back (`npm install`, the next Xcode build, the next
`cargo build`) and when anything inside last changed. Nothing is ticked for you.
`cargo build`) and when anything inside last changed.
- **Only you have these.** Your Photos library, Documents, Desktop, Pictures, Movies, Music, Mail
and Messages, with their sizes. They have no tick boxes. MacDirStat never offers them for
deletion; they are there so you know where the space is.
Expand All @@ -46,6 +46,26 @@ no file names or lists, and it never sends anything anywhere. The
summaries live in `~/Library/Application Support/mac-dir-stat/history.json`; delete that file to
forget them.

## Getting around

- **Path bar.** Under the toolbar: the folder the map shows, as clickable segments (the current one
in bold), with Back and Up buttons. Backspace or ⌘↑ goes up a folder; ⌘[ goes back to where you were.
- **Pinned parents.** Scroll deep into the folder list and the folders above stay pinned at the top
(up to four). Click one to jump to it; its triangle closes it.
- **The triangle works.** Clicking ▶ in the folder list opens a folder and ▼ closes it. Before, the
click selected the row instead.
- **The pointer changes** to a hand over anything you can click, and to a resize arrow on the
dividers between the three panels.
- **Colour legends.** With Age or Depth selected, a bar at the right of the path bar shows what the
colours mean: new to old (today, 1 mo, 1 y, 3 y or older) or shallow to deep (levels 1 to 5+,
counted from the folder shown). Age now runs on a three-year log scale instead of saturating at one
year.
- **Faster.** The folder list draws only the rows in view, and the map merges blocks smaller than a
pixel. On `~/Library` (275,000 files, 5,200 folders open) a frame went from 46 ms to 0.8 ms.
- **Fewer words.** Explanatory sentences are gone from the Cleanup, History and Full Disk Access
windows and the tooltips.
- **A new icon.** A treemap of nested blocks on paper, as a macOS icon.

## A new look

Serif type (Source Serif 4, bundled), ink on paper, thin rules, and one dark-red accent for the
Expand Down
89 changes: 89 additions & 0 deletions examples/bench.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
//! Measures scan time and per-frame UI cost on a real folder.
//! cargo run --release --example bench -- ~/Library
use egui::{Pos2, ThemePreference};
use mac_dir_stat::app::{App, AppState};
use mac_dir_stat::scanner::{tree::{NodeId, NodeKind}, ScanProgress};
use mac_dir_stat::treemap::color::ColorMode;
use std::path::PathBuf;
use std::time::Instant;

fn main() {
let root = PathBuf::from(std::env::args().nth(1).expect("path"));
let (tx, rx) = crossbeam_channel::unbounded();
let t0 = Instant::now();
mac_dir_stat::scanner::scan(root.clone(), tx);
let tree = loop {
match rx.recv().unwrap() {
ScanProgress::Done(t) => break t,
ScanProgress::Error(e) => panic!("{e}"),
_ => {}
}
};
let scan = t0.elapsed();
let r = tree.root();
println!("scan: {:.2}s files={} dirs={}", scan.as_secs_f64(), tree.node(r).file_count, tree.node(r).subdir_count);

let mut s = AppState::new(root, true, ColorMode::Extension, mac_dir_stat::flywheel::disabled().clone());
s.request_rescan = false;
s.extension_stats = tree.collect_extensions(r);
s.view_root = Some(r);
s.zoom_stack = vec![r];
// expand every directory: worst case for the list
s.tree = Some(tree);
let n_expand = expand_all(&mut s, 3);
println!("expanded dirs to depth 3: {n_expand}");
{
let t = s.tree.as_ref().unwrap();
let r = t.root();
let t0 = Instant::now();
let rows = mac_dir_stat::ui::dir_tree::flatten(t, r, s.dir_sort);
println!("flatten: {:.2}ms ({} rows)", t0.elapsed().as_secs_f64() * 1000.0, rows.len());
let b = mac_dir_stat::treemap::squarify::Rect { x: 0.0, y: 0.0, w: 700.0, h: 700.0 };
let t0 = Instant::now();
let l = mac_dir_stat::treemap::compute_treemap(t, r, b, ColorMode::Extension);
println!("treemap layout: {:.2}ms ({} blocks)", t0.elapsed().as_secs_f64() * 1000.0, l.file_rects.len());
}
let mut app = App::from_state(s);
let ctx = egui::Context::default();
ctx.set_theme(ThemePreference::Light);
mac_dir_stat::ui::theme::apply_theme(&ctx);
let _ = ctx.run(Default::default(), |_| {});
let mut frame = eframe::Frame::_new_kittest();
let mut run = |label: &str, n: usize, ptr: Option<Pos2>, app: &mut App| {
let mut times = vec![];
for i in 0..n {
let mut input = egui::RawInput {
screen_rect: Some(egui::Rect::from_min_size(Pos2::ZERO, egui::vec2(1280.0, 800.0))),
time: Some(i as f64 * 0.016),
..Default::default()
};
if let Some(p) = ptr {
input.events.push(egui::Event::PointerMoved(Pos2::new(p.x + (i % 7) as f32, p.y)));
}
let t = Instant::now();
let out = ctx.run(input, |c| eframe::App::update(app, c, &mut frame));
let _ = ctx.tessellate(out.shapes, out.pixels_per_point);
times.push(t.elapsed().as_secs_f64() * 1000.0);
}
let first = times[0];
let mut rest: Vec<f64> = times[1..].to_vec();
rest.sort_by(|a, b| a.partial_cmp(b).unwrap());
println!("{label}: first {first:.1}ms median {:.2}ms p95 {:.2}ms", rest[rest.len() / 2], rest[rest.len() * 95 / 100]);
};
run("idle frame", 60, None, &mut app);
run("hover list", 60, Some(Pos2::new(120.0, 200.0)), &mut app);
run("hover map", 60, Some(Pos2::new(700.0, 400.0)), &mut app);
}

fn expand_all(s: &mut AppState, max_depth: u16) -> usize {
let t = s.tree.as_mut().unwrap();
let mut n = 0;
let mut stack = vec![t.root()];
while let Some(id) = stack.pop() {
if t.node(id).depth > max_depth { continue; }
let kids: Vec<NodeId> = t.node(id).children().to_vec();
if let NodeKind::Directory { expanded, .. } = &mut t.node_mut(id).kind { *expanded = true; n += 1; }
stack.extend(kids.into_iter().filter(|&k| t.node(k).is_dir()));
}
n
}
116 changes: 113 additions & 3 deletions examples/screenshots.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,23 @@ fn shots() -> Vec<Shot> {
Shot { name: "ledger", dark: false, setup: ledger, pointer: Some(Pos2::new(120.0, 120.0)) },
// Pointer on the map, over a Docker block: the list marks Containers.
Shot { name: "ledger-dark", dark: true, setup: ledger, pointer: Some(Pos2::new(780.0, 300.0)) },
// Scrolled deep into the list: the ancestors stay pinned at the top.
Shot { name: "sticky-light", dark: false, setup: sticky, pointer: None },
Shot { name: "sticky-dark", dark: true, setup: sticky, pointer: None },
// Colour by age and by depth, with their legends; the map zoomed so
// the path bar shows a path.
Shot { name: "age-light", dark: false, setup: age, pointer: None },
Shot { name: "age-dark", dark: true, setup: age, pointer: None },
Shot { name: "depth-light", dark: false, setup: depth, pointer: None },
Shot { name: "depth-dark", dark: true, setup: depth, pointer: None },
]
}

fn main() {
let only: Vec<String> = std::env::args().skip(1).collect();
let out = Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots");
let out = std::env::var_os("SHOTS_OUT")
.map(PathBuf::from)
.unwrap_or_else(|| Path::new(env!("CARGO_MANIFEST_DIR")).join("docs/screenshots"));
std::fs::create_dir_all(&out).unwrap();
for shot in shots() {
if !only.is_empty() && !only.iter().any(|o| o == shot.name) {
Expand Down Expand Up @@ -182,6 +193,44 @@ fn inventory(s: &mut AppState) {
s.cleanup_selected.extend(ids);
}

fn expand_all(s: &mut AppState) {
let ids: Vec<NodeId> = {
let t = s.tree.as_ref().unwrap();
let mut out = vec![];
let mut stack = vec![t.root()];
while let Some(id) = stack.pop() {
if t.node(id).is_dir() {
out.push(id);
stack.extend(t.node(id).children().iter().copied());
}
}
out
};
for id in ids {
expand(s, id);
}
}

fn sticky(s: &mut AppState) {
expand_all(s);
let t = s.tree.as_ref().unwrap();
let devices = t.find_path(Path::new("/Users/pavol/Library/Developer/CoreSimulator/Devices")).unwrap();
s.scroll_dir_tree_top = Some(devices);
}

fn age(s: &mut AppState) {
expand_all(s);
s.color_mode = ColorMode::Age;
let x = s.tree.as_ref().unwrap().find_path(Path::new("/Users/pavol/Library/Developer")).unwrap();
mac_dir_stat::nav::navigate_to(s, x);
}

fn depth(s: &mut AppState) {
s.color_mode = ColorMode::Depth;
let x = s.tree.as_ref().unwrap().find_path(Path::new("/Users/pavol/Library")).unwrap();
mac_dir_stat::nav::navigate_to(s, x);
}

fn ledger(s: &mut AppState) {
// Expand Library so the list shows a second level with its loose files.
let lib = s.tree.as_ref().and_then(|t| t.find_path(Path::new("/Users/pavol/Library")));
Expand Down Expand Up @@ -362,7 +411,7 @@ fn write_png(img: &[[f32; 4]], w: usize, h: usize, path: &Path) {
}
}
let tmp = path.with_extension("ppm");
std::fs::write(&tmp, ppm).unwrap();
std::fs::write(&tmp, &ppm).unwrap();
// Downscale to 1x points so the PNGs stay small in the repo.
let ok = std::process::Command::new("convert")
.arg(&tmp)
Expand All @@ -372,5 +421,66 @@ fn write_png(img: &[[f32; 4]], w: usize, h: usize, path: &Path) {
.map(|s| s.success())
.unwrap_or(false);
let _ = std::fs::remove_file(&tmp);
assert!(ok, "ImageMagick `convert` is needed to write PNGs");
if !ok {
// No ImageMagick: write an uncompressed PNG, then let macOS `sips`
// downscale and compress it.
std::fs::write(path, raw_png(&ppm[ppm.len() - w * h * 3..], w, h)).unwrap();
let ok = std::process::Command::new("sips")
.args(["-Z", &((w / 2).to_string())])
.arg(path)
.stdout(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
assert!(ok, "needs ImageMagick `convert` or macOS `sips` to write PNGs");
}
}

/// A valid PNG using stored (uncompressed) deflate blocks.
fn raw_png(rgb: &[u8], w: usize, h: usize) -> Vec<u8> {
fn crc(data: &[u8]) -> u32 {
let mut c = !0u32;
for &b in data {
c ^= b as u32;
for _ in 0..8 {
c = if c & 1 != 0 { (c >> 1) ^ 0xEDB8_8320 } else { c >> 1 };
}
}
!c
}
fn chunk(out: &mut Vec<u8>, kind: &[u8; 4], data: &[u8]) {
out.extend((data.len() as u32).to_be_bytes());
let mut body = kind.to_vec();
body.extend(data);
out.extend(&body);
out.extend(crc(&body).to_be_bytes());
}
let mut raw = Vec::with_capacity((w * 3 + 1) * h);
for row in rgb.chunks(w * 3) {
raw.push(0);
raw.extend(row);
}
let mut z = vec![0x78, 0x01];
let mut chunks = raw.chunks(65_535).peekable();
while let Some(c) = chunks.next() {
z.push(chunks.peek().is_none() as u8);
z.extend((c.len() as u16).to_le_bytes());
z.extend((!(c.len() as u16)).to_le_bytes());
z.extend(c);
}
let (mut a, mut b) = (1u32, 0u32);
for &x in &raw {
a = (a + x as u32) % 65_521;
b = (b + a) % 65_521;
}
z.extend(((b << 16) | a).to_be_bytes());
let mut out = b"\x89PNG\r\n\x1a\n".to_vec();
let mut ihdr = Vec::new();
ihdr.extend((w as u32).to_be_bytes());
ihdr.extend((h as u32).to_be_bytes());
ihdr.extend([8, 2, 0, 0, 0]);
chunk(&mut out, b"IHDR", &ihdr);
chunk(&mut out, b"IDAT", &z);
chunk(&mut out, b"IEND", &[]);
out
}
1 change: 1 addition & 0 deletions macos/Info.plist.in
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
<key>CFBundleIdentifier</key><string>sk.dravecky.mac-dir-stat</string>
<key>CFBundleVersion</key><string>@VERSION@</string>
<key>CFBundleShortVersionString</key><string>@VERSION@</string>
<key>CFBundleIconFile</key><string>AppIcon</string>
<key>CFBundleExecutable</key><string>mac-dir-stat</string>
<key>CFBundlePackageType</key><string>APPL</string>
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
Expand Down
1 change: 1 addition & 0 deletions scripts/bundle.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ lipo -create -output "$APP/Contents/MacOS/$BIN_NAME" \
"$ROOT/target/x86_64-apple-darwin/release/$BIN_NAME" \
"$ROOT/target/aarch64-apple-darwin/release/$BIN_NAME"

cp "$ROOT/assets/icon/AppIcon.icns" "$APP/Contents/Resources/AppIcon.icns"
sed "s/@VERSION@/$VERSION/g" "$ROOT/macos/Info.plist.in" > "$APP/Contents/Info.plist"

# Code-sign with a Developer ID + hardened runtime when an identity is provided
Expand Down
23 changes: 23 additions & 0 deletions scripts/make-icon.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Renders assets/icon/icon.svg to assets/icon/AppIcon.icns and the 256px window icon.
# macOS only: qlmanage draws the SVG, sips resizes, iconutil builds the .icns.
# The outputs are committed; run this only after editing icon.svg.
set -euo pipefail

DIR="$(cd "$(dirname "$0")/../assets/icon" && pwd)"
WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT

qlmanage -t -s 1024 -o "$WORK" "$DIR/icon.svg" >/dev/null 2>&1
BASE="$WORK/icon.svg.png"
[ -f "$BASE" ] || { echo "qlmanage did not render icon.svg" >&2; exit 1; }

SET="$WORK/AppIcon.iconset"
mkdir "$SET"
for s in 16 32 128 256 512; do
sips -z $s $s "$BASE" --out "$SET/icon_${s}x${s}.png" >/dev/null
sips -z $((s * 2)) $((s * 2)) "$BASE" --out "$SET/icon_${s}x${s}@2x.png" >/dev/null
done
iconutil -c icns "$SET" -o "$DIR/AppIcon.icns"
sips -z 256 256 "$BASE" --out "$DIR/icon-256.png" >/dev/null
echo "Wrote $DIR/AppIcon.icns and $DIR/icon-256.png"
12 changes: 6 additions & 6 deletions site/favicon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading