Understand dirgo by driving it A hands-on walkthrough: a working replica of the TUI, a step-through of the message loop, a live simulation of the concurrent scanner, and a clickable map of the codebase. Everything on this page runs locally — no network, no dependencies.
Each section pairs a short explanation with something you can actually operate. Start at the terminal replica, then follow a single keypress through the runtime, then watch the scanner fan out across goroutines. When you want the precise details, every widget links into the reference guide.
01What dirgo is
dirgo is a terminal disk-usage explorer. You point it at a directory; it lists every
child sorted by size, with proportional bars, recursive totals for folders, and single-key navigation
into the tree. Think du and ls merged into something you can actually browse.
| The problem | How dirgo answers it |
|---|---|
| "Which folder is eating my disk?" | Every row shows a recursive size and a colour-coded bar scaled to its share of the parent. The offender is the red bar at the top. |
| "Walking a big tree takes seconds." | The walk happens on background goroutines, fanned out across cores. The UI stays interactive and shows live counters while it runs. |
"I have to re-run du every time I move." | Results are cached per directory. Going back up a level is instant, flagged ⚡cached. |
| "I need to inspect, not just measure." | Line counts for text files, hex dump for binaries, Quick Look previews, open in the file manager, and move-to-trash — all one key each. |
Under the hood it is a Go program built on Bubble Tea, which implements
The Elm Architecture: one immutable model, one Update function, one View
function, and side effects expressed as commands that run off the UI thread. That single choice is
what makes the rest of the design fall into place — and it is what the next three widgets make visible.
02Drive it yourself
Below is a faithful replica of dirgo's interface running against a synthetic home directory. The layout arithmetic, colour thresholds, fuzzy search, sort order, size formatting, cache behaviour and async line counting are all ported directly from the Go source — so what you see here is what the real tool does.
Click the terminal to focus it, then use the keys. The event log underneath shows what the runtime is doing: which message was delivered, which command was dispatched, and on which goroutine.
Things worth trying
| Try this | What it demonstrates |
|---|---|
→ into dev, then ← back | The first entry triggers a scan (spinner + live counters). Coming back is instant and the header shows ⚡cached — the LRU cache at work. The cursor also lands back on dev, because dirgo remembers the entry name per path. |
Move onto a .go file | A line count appears in the right-hand column a moment later. That is countLinesCmd running on its own goroutine and returning a lineCountMsg — the row is patched, not re-scanned. |
Press / then type mgo | Fuzzy subsequence matching, not substring: mgo finds model.go. The filter runs on every keystroke and reuses its backing array. |
| Press h, then f a few times | The filter chain: hidden toggle → type filter (all → dirs → files) → search → top-10 clamp. Badges in the header reflect the active modes. |
| Press s | Batch line counting — one message carrying a whole map[name]int, produced by a bounded worker pool. |
| Press t | Top-10 view: the filtered slice is simply clamped to ten rows. |
| Press ? | The help overlay, which View() returns instead of the list when helpMode is set. |
At this narrow width the header renders on two lines. That is not a hack — it is
headerLineCount() measuring the stats segment and deciding there is not enough room for a
useful path, exactly as the Go code does. Widen your window and the same logic in real dirgo collapses
it to one line.
03One keypress, end to end
Everything in dirgo is a message. Nothing mutates state except Update, and nothing blocks
the UI except Update and View themselves — both of which are fast by construction.
Step through a complete session below: launching the binary, the first scan finishing, and then a single ↓ keypress causing a line count to appear. The lane badge tells you which goroutine is executing, and the model panel highlights fields that changed on that step.
Follow the lane badges. Every step is either UI goroutine (fast, serial, owns the model) or command goroutine (slow, parallel, owns nothing). They never share memory — they exchange values. That is why there is not a single mutex protecting application state in the entire program.
04Inside a scan
This is where dirgo actually spends its time. When you enter a directory, scanDirectory
reads it in one syscall, splits children into files and subdirectories, and then spawns one
goroutine per subdirectory to compute its recursive size. Those goroutines are gated by a counting
semaphore built from a buffered channel:
sem := make(chan struct{}, minInt(runtime.NumCPU(), 16))
for ri, di := range dirEntryIndices {
wg.Add(1)
go func(resultIdx int, info dirInfo2) {
defer wg.Done()
sem <- struct{}{} // acquire — parks here when all slots are taken
defer func() { <-sem }() // release — wakes the next parked goroutine
size, files, dirs := dirSizeRecursive(filepath.Join(absPath, info.name), prog)
results[resultIdx] = dirResult{index: info.index, size: size,
childFiles: files, childDirs: dirs}
}(ri, di)
}
wg.Wait()
Drive the simulator: change the core count, the number of subdirectories, and how evenly the work is distributed. Watch the semaphore slots fill, goroutines park and wake, and the atomic counters climb.
What the simulator is showing you
| Observation | Meaning in the real code |
|---|---|
Goroutines sit in queued before they run | All N goroutines are created immediately, but each blocks on sem <- struct{}{} until a slot frees. A parked goroutine costs ~2 KB of stack and zero CPU, which is why dirgo can afford "unbounded goroutines, bounded work" instead of a job-queue worker pool. |
| Slots never exceed 16, however high you push NumCPU | minInt(runtime.NumCPU(), 16). Filesystems do not scale linearly with concurrent readers — past a point extra goroutines just add seek contention and page-cache thrash. |
| The UI sample trails the live atomics | Many writers calling Add(1) at leaf granularity; one reader sampling on each spinner tick. The three counters are read independently, so a snapshot can be slightly inconsistent — invisible in a progress indicator, and far cheaper than a lock. |
| "One giant subtree" barely speeds up at all | The known load-balancing limitation. Work is partitioned by top-level subdirectory, and dirSizeRecursive deliberately does not spawn goroutines for nested directories — so a lone node_modules defines the critical path and no amount of parallelism helps. |
| Nothing writes to a shared structure | Each goroutine writes only results[resultIdx]. Distinct slice elements are distinct memory, so concurrent writes to different indices are not a data race — and wg.Wait() supplies the happens-before edge that makes the later sequential read legal. |
The full analysis — including the race-safety audit table, why atomic.Int64 rather than the
free functions, and the exact tuning thresholds — lives in
§8 of the reference guide.
05Codebase map
Nine files, one main package, no internal directories. Click any file to see its role, its
key symbols, what it connects to, and what you would change there.
06Where to change what
Practical entry points for common modifications.
| I want to… | File | Start at | Notes |
|---|---|---|---|
| Add or rebind a key | keys.go | DefaultKeyMap() | Add the field to KeyMap, then a key.Matches case in the Update switch. Help text lives on the binding, so the overlay updates itself. |
| Add a new column to each row | render.go | renderRow() | Adjust fixedNonBar to account for the new width or the elastic name column will overflow. |
| Change colours or the theme | styles.go | colorX vars, barColor() | Keep styles at package level — constructing a Lipgloss style per row is a measurable regression. |
| Add a sort mode (name, mtime, lines) | entry.go + model.go | SortBySize(), applyFilter() | Add a sort-mode field to Model, apply it in applyFilter, and add a keybinding. No architectural change needed. |
| Make scans cancellable | scanner.go + model.go | scanDirectory() | Thread a context.Context through the command closure and check it in dirSizeRecursive's loop; store the cancel func on the model and call it on navigation. Also fixes the stale-result problem in §17. |
| Change scan concurrency | scanner.go | sem := make(chan …) | Two independent semaphores exist — directory sizing (min(NumCPU,16)) and file stat (NumCPU). Also revisit the > 20 files parallel-stat threshold. |
| Report true on-disk size | scanner.go | dirSizeRecursive() | Use st_blocks from syscall.Stat_t instead of Info().Size(). Needs platform-specific files. |
| Persist the cache to disk | cache.go | lruCache | Deliberately not implemented — see the README rationale. scanResultMsg is already gob-friendly if you add it. |
| Support a new platform action | model.go | openPath(), trashCmd() | Both already switch on runtime.GOOS. Mind the escaping rules in §15 before interpolating a path into any shell or script string. |
| Profile a slow scan | main.go | --profile flag | Wraps the whole interactive session in pprof.StartCPUProfile, then go tool pprof -http=:8080 cpu.prof. |
Getting the code running
git clone https://github.com/mohsinkaleem/dirgo.git && cd dirgo
make build # → ./dirgo
make test # go test -race -v ./... ← race detector is always on
make bench # allocation + throughput benchmarks
./dirgo ~ # try it against your own home directory
Run make test first and confirm it is green. Every concurrency change should be
re-validated under -race; the scanner tests exercise the fan-out paths, so a broken
synchronisation assumption fails loudly rather than becoming a heisenbug in the field.
07Where to go next
| If you want… | Go to |
|---|---|
| The precise concurrency semantics, primitive by primitive | Reference §8 — Concurrency deep dive |
Why the scanner avoids filepath.WalkDir | Reference §7 — Scanning pipeline |
| How the LRU and modtime-gated refresh interact | Reference §9 — Cache & smart refresh |
| The allocation-avoidance techniques in the render path | Reference §11 — Render pipeline |
| Command-injection hardening and trash semantics | Reference §15 — Safety & hardening |
| An honest list of what the design gives up | Reference §17 — Trade-offs & caveats |