Disk usage as a treemap, in the shape of WinDirStat — a directory outline, an extension list, and a cushioned treemap that share a selection. Written in Zig, talking to AppKit through the Objective-C runtime directly: no Swift, no Xcode project, no dependencies.
If MacDirStat is useful to you, you can sponsor the work.
zig build run
curl -fsSL https://raw.githubusercontent.com/jaenster/MacDirStat/main/tools/install.sh | bash
That fetches the latest release, checks it against the SHA-256 published beside
it, installs MacDirStat.app into /Applications, and clears the quarantine
flag. PREFIX=~/Applications installs somewhere else; passing a version
(| bash -s v0.1.0) installs that one instead of the newest.
Or by hand: download the zip from Releases, unzip, move the app to your Applications folder, and clear the quarantine flag macOS puts on anything downloaded:
xattr -dr com.apple.quarantine /Applications/MacDirStat.app
Without that macOS reports the app as damaged. The app is ad-hoc signed, not notarized, and notarizing instead of asking for that one command needs a paid Apple Developer account.
Apple Silicon, macOS 12 or later. There is no Intel build: macOS 26 is the last
release to run on Intel at all, so a universal binary would be weight for a
machine on its way out — zig build -Dtarget=x86_64-macos still produces one.
Building it yourself needs a Mac. Zig ships only libSystem for Darwin, and AppKit, Foundation and CoreGraphics come from Apple's SDK, which cannot be handed to a Linux runner; that is why CI builds on macOS despite Zig otherwise cross-compiling happily.
zig build produces a complete, ad-hoc signed .app: icon, Info.plist, and
the standard application, Edit and Window menus. The signature matters beyond
tidiness — without a stable one macOS treats every rebuild as a different
program, so the Full Disk Access grant this needs to measure ~/Library would
have to be given again each time.
Builds ReleaseSafe by default. The scanner does a great deal of index
arithmetic over a hand-managed node array, and on this workload the bounds
checks are free — the walk is bound by syscalls, not arithmetic:
| build | /Applications, 540k files |
|---|---|
| Debug | 1948 ms |
| ReleaseSafe (default) | 1814 ms |
| ReleaseFast | 1837 ms |
File ▸ Open Folder… picks something to measure, or pass a path:
zig build run -- ~/Projects
Click a rectangle and the outline expands to that file; select a row and the treemap outlines it. Hovering reads the full path and both sizes into the status bar. Click a column header to sort by it, again to reverse. Right-click anything, in either pane, for Reveal in Finder, Copy Path, Zoom Into, and Move to Trash.
| ⌘O / ⌘H | open a folder / measure the home folder |
| ⌘R | measure again |
| ⌘] ⌘[ | zoom the map into, or out of, the selection |
| ⌘B | look inside .app and other packages |
| ⌘P | size by logical instead of on-disk |
| ⌘E / ⌘⌫ | reveal in Finder / move to Trash |
Long scans are drawn as they run: the outline fills in, the map builds up, and the counts climb. Nothing waits for the walk to finish.
The screenshot above is a stand-in home folder, and the .docker row is the
reason this program defaults to on-disk size:
.docker 760 MiB on disk 512 GiB logical
That is one sparse disk image. It reserves an enormous range and occupies a fraction of it. Sorted by logical size it dwarfs everything else and points you at the wrong thing to delete; the whole folder reads as 637 GiB when 6 GiB is what the volume is actually holding.
So sizes, sorting and the map are all driven by allocated blocks — the number
that matches du, and the number worth acting on. Logical stays as a column,
and ⌘P switches to it when you want to know what copying elsewhere would cost.
One honest limit: APFS clones share blocks between files, and each clone
reports the shared blocks as its own. Two clones of a 1 GiB file therefore
report a gigabyte each. du says the same, and seeing through it needs
APFS-specific APIs this does not use.
- Hard links count once, at the first name reached. The other names appear in the tree at zero, so a total answers "space recovered if this went away" rather than counting one inode several times. Keyed on device and inode, since inode numbers are only unique within a volume.
- Bundles (
.app,.photoslibrary,.xcodeproj, …) count once, at full size, and are drawn as one cell. ⌘B descends into them instead. Counting both a bundle and its contents was a real bug here, and made extension percentages exceed 100%. - Symlinks count as their own few bytes and are never followed, so a link loop cannot inflate a total or hang the walk.
- Mount points are not crossed. Scanning
/therefore stops at/System/Volumes/Data, which on modern macOS is where your files actually live — measure that volume, or your home folder, rather than/. - Directories contribute no size of their own. On APFS they report zero
allocated blocks, which is why totals still match
duexactly.
Folders the process cannot open are marked (no access) and contribute zero.
Most of ~/Library needs Full Disk Access before it reports anything:
System Settings ▸ Privacy & Security ▸ Full Disk Access. Without it the scan
still completes, and the status line says how many folders were skipped.
macdirstat --scan ~/Projects --tree
macdirstat --scan ~/Projects --render map.png --width 1600 --height 600
--scan prints totals and the ranked extensions; --render writes the treemap
to a PNG without opening a window.
| file | what it does |
|---|---|
objc.zig |
objc_msgSend bridge, selector cache, runtime class building |
model.zig |
the node tree: one flat array, indices rather than pointers |
scan.zig |
parallel getattrlistbulk walk |
treemap.zig |
squarified layout and cushion shading, into an RGBA buffer |
extensions.zig |
per-extension totals and colour ranking |
ui.zig |
AppKit windows, views, and the delegate trampolines |
png.zig, icon.zig |
PNG writer; the app icon, drawn by the renderer itself |
Four decisions carry most of the design.
One flat node array. A directory's children occupy a contiguous run, because
getattrlistbulk returns every entry of a directory before anything descends
into one. Nodes address each other by index, so the whole tree is two mappings
and no per-node allocation. Sorting lives in a parallel order array, since
permuting the nodes themselves would invalidate every parent index.
getattrlistbulk over readdir. It returns a batch of names and their
sizes per syscall, where readdir plus fstatat costs one syscall per file. On
a large home folder that difference is most of the runtime. Entries are not
fixed-size — a directory carries no file-size attributes and its record is
shorter — so the parser walks the returned-attributes bitmap rather than
assuming a layout.
Totals accumulate during the walk. Each directory folds its entries into itself and every ancestor as it is read, so every node holds a valid running total and the window can draw a scan in progress. Ancestor chains are short and the adds are atomic, which measures as free against the syscall cost. Child runs are published with release ordering once written, so a reader sees either no children or complete ones.
The map is drawn, not composed of views. The treemap is one RGBA buffer the
renderer fills, wrapped as a CGImage. A parallel buffer records which node owns
each pixel, so hover and click are an array lookup rather than a search.
Totals are checked against the system's own tools, not against themselves:
tools/make-sample-tree.sh /tmp/jaenster
tools/verify-against-du.sh zig-out/MacDirStat.app/Contents/MacOS/macdirstat /tmp/jaenster
On-disk bytes must equal du -sk, and file counts must equal
find -type f -o -type l. This runs in CI, because the interesting failures in
a disk-usage tool are not crashes but plausible wrong numbers — both the bundle
double-count and the hard-link double-count shipped here and were caught exactly
this way.
zig build test
covers the scanner against real temporary directories (including a hard-link case), the treemap's layout and hit testing, size formatting, and extension ranking.
The interface is checked by having the app photograph itself:
macdirstat --gui /tmp/jaenster --snapshot shot.png
macdirstat --gui /tmp/jaenster --snapshot shot.png --snapshot-delay 3
which renders the window to a PNG and quits — no screen-recording permission
involved, since a process may always draw its own views. --snapshot-delay
captures mid-scan rather than waiting for the walk to finish.
Documentation images come from tools/make-sample-tree.sh, never from a real
home folder. It mimics the shape of one — the same folders, the same lopsided
proportions, a sparse disk image, packages, a hard link and a symlink — scaled
down by about two orders of magnitude. The root takes the name of whatever
directory you point it at.
macdirstat --make-icon MacDirStat.iconset
iconutil -c icns MacDirStat.iconset -o resources/MacDirStat.icns
It is a treemap of a small made-up tree, drawn by the same renderer the window uses, so it cannot drift from what the program actually produces.
This is a reimplementation of somebody else's good idea, and the idea is the hard part.
WinDirStat by Bernhard Seifert and Oliver Schneider, now maintained by Chris Rimmer and contributors, is what this copies: the three linked panes, the extension list driving the colours, the cushioned map along the bottom. It is free software, and worth using if you are on Windows.
WinDirStat is itself a port of KDirStat by Stefan Hundhammer, which introduced the layout in 1999.
The two algorithms behind the picture are published work:
- Squarified treemaps — Mark Bruls, Kees Huizing and Jarke J. van Wijk, Squarified Treemaps, 2000. Rows chosen to keep rectangles near-square, which is what makes areas comparable by eye.
- Cushion shading — Jarke J. van Wijk and Huub van de Wetering, Cushion Treemaps: Visualization of Hierarchical Information, 1999. The nesting you can see is a shaded surface, not borders.
On macOS, GrandPerspective and DaisyDisk got there long before this did.
/Applications, 540k files, 1.8 s — about 300k files/s warm. A home folder of
3.2M files across 455k folders takes about 19 s. Rendering 20k visible cells
takes under 4 ms, so resizing and zooming stay immediate.
Memory, for that 3.2M-file tree:
| nodes | 167 MiB (48 bytes each) |
| names | 65 MiB (19 bytes per node) |
| display order | 14 MiB |
| tree | 247 MiB |
| overhead | 27 MiB |
| resident | 273 MiB |
Names are interned while walking, which cuts them from 103 MiB — filesystem
names repeat enormously, since every bundle has a Contents and every package a
package.json. The table is sharded by hash so sixteen workers rarely contend,
and it is thrown away once the walk ends; it costs no measurable time.
The intern table is page-backed rather than taken from the general-purpose allocator: it is large, short-lived and freed in one go, so its pages should return to the OS instead of sitting in a free list for the rest of the session. That alone cut overhead from 58 MiB to 27 MiB.
The status bar reports current resident size rather than ru_maxrss, which is
a high-water mark that never falls and would keep reporting a large scan's peak
long after the memory went back.
