Interactive Tour

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.

How to use this page

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 problemHow 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.

dirgo — interactive replica normal mode
click to focus
RUNTIME EVENT LOG — ui goroutine · command goroutine · message delivered

Things worth trying

Try thisWhat it demonstrates
→ into dev, then ← backThe 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 fileA 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 mgoFuzzy 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 timesThe filter chain: hidden toggle → type filter (all → dirs → files) → search → top-10 clamp. Badges in the header reflect the active modes.
Press sBatch line counting — one message carrying a whole map[name]int, produced by a bounded worker pool.
Press tTop-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.
Notice the header

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.

Message-flow stepper
lane

—

—
MODEL STATE
IN FLIGHT
The key insight

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:

scanner.go — the fan-out being simulated below
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.

Concurrent scan simulator
speed
Parameters
runtime.NumCPU() 8
subdirectories 10
work distribution
semaphore capacity = min(NumCPU, 16) = 8
Semaphore · chan struct{}
parked on send: 0
running: 0
completed: 0
Goroutines — one per immediate subdirectory
ScanProgress (atomics)
.Files0
.Dirs0
.Size0 B
UI sample · every 100 ms
files0
dirs0
size0 B
the sample lags the atomics — harmless for a progress display, and the reason no lock is needed
Occupancy timeline
0 ms
wall clock
0 ms
if sequential
—
speedup
0 ms
critical path
—
core efficiency
Press run to start the scan.

What the simulator is showing you

ObservationMeaning in the real code
Goroutines sit in queued before they runAll 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 NumCPUminInt(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 atomicsMany 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 allThe 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 structureEach 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.

Component explorer click a file · purple = closely related

06Where to change what

Practical entry points for common modifications.

I want to…FileStart atNotes
Add or rebind a keykeys.goDefaultKeyMap()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 rowrender.gorenderRow()Adjust fixedNonBar to account for the new width or the elastic name column will overflow.
Change colours or the themestyles.gocolorX 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.goSortBySize(), applyFilter()Add a sort-mode field to Model, apply it in applyFilter, and add a keybinding. No architectural change needed.
Make scans cancellablescanner.go + model.goscanDirectory()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 concurrencyscanner.gosem := 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 sizescanner.godirSizeRecursive()Use st_blocks from syscall.Stat_t instead of Info().Size(). Needs platform-specific files.
Persist the cache to diskcache.golruCacheDeliberately not implemented — see the README rationale. scanResultMsg is already gob-friendly if you add it.
Support a new platform actionmodel.goopenPath(), 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 scanmain.go--profile flagWraps the whole interactive session in pprof.StartCPUProfile, then go tool pprof -http=:8080 cpu.prof.

Getting the code running

shell
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
Before you touch the scanner

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 primitiveReference §8 — Concurrency deep dive
Why the scanner avoids filepath.WalkDirReference §7 — Scanning pipeline
How the LRU and modtime-gated refresh interactReference §9 — Cache & smart refresh
The allocation-avoidance techniques in the render pathReference §11 — Render pipeline
Command-injection hardening and trash semanticsReference §15 — Safety & hardening
An honest list of what the design gives upReference §17 — Trade-offs & caveats