diskr 0.1.21

Lightweight terminal file explorer and disk/storage manager for macOS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
# diskr Product & Code Audit

Audited at v0.1.20 (2026-06-10). Every module read; findings verified against source.
Line references are accurate as of commit `651aaef`.

**Summary:** codebase is healthy (clippy clean, 77 passing tests, honest README), but the
differentiating features all shipped CLI-only while the TUI — the actual product — stayed a
basic browser. Plus a handful of real bugs. `bulkstat::scan_dir` already supports top-N
collection (`top_file_limit`) but the TUI always passes `0`; the gap between what the engine
can do and what the TUI shows is the central product finding.

---

## Bugs

### 1. Search-mode index corruption

**Root cause:** two coordinate systems exist for `app.selected` — an index into `entries`
normally, but into `search_matches` during an active search (`visible_entry_count` /
`visible_entry_index`, `src/app.rs:413-429`, do the mapping). Two functions bypass the mapping:

- `scan_selected_missing_dir` (`src/app.rs:757`) does `self.entries.get(self.selected)`  during search it reads an unrelated entry, so cursor movement scans the wrong directory
  and sets `scanning` on the wrong row.
- `apply_sort_preserving_selection` (`src/app.rs:316`) does the same, and is reachable
  mid-search via `drain_scan_results` (`src/app.rs:578`): when scan results land while sort
  is `SizeDesc`, the debounced re-sort fires regardless of search mode.

**The worse half:** after that re-sort, `entries` order changes but `search_matches`
(indices into `entries`, built by `update_search`, `src/app.rs:1247`) is never rebuilt —
every filtered row now points at the wrong entry. No panic (lookups use `.get`), just
silently wrong rows.

**Repro:** open a dir with several unsized subdirs while sorted by size, press `/`, type a
query, wait ~1s for scan results to arrive → filtered list shows wrong names/sizes; move
cursor → wrong dir scans.

**Fix:**
- (a) In `scan_selected_missing_dir`, resolve via `self.visible_entry_index(self.selected)`
  first; audit any other direct `entries.get(self.selected)` (`move_cursor` path is fine,
  `selection_status` in ui.rs is only called outside search).
- (b) In `apply_sort_preserving_selection`, capture the selected *path* via `visible_entry`,
  sort, then if search is active call `update_search()` to rebuild match indices, then
  restore selection by searching the *visible* space.
- Bigger alternative: store selection as `Option<PathBuf>` and derive the index at render
  time — kills this bug class permanently.

**Tests:** temp dir with `aaa/`, `bbb/`; enter search "b", `move_cursor(0)`, assert the
`bbb` entry (not `entries[0]`) has `scanning == true`. Second test: populate sizes
mid-search, force `apply_sort_preserving_selection`, assert `search_matches` still maps to
names containing the query.

**Effort:** ~1-2 hours including tests.

**Status:**
- [ ] Completed
- **Changelog:** 

### 2. Frozen spinners during quiet scans

**Root cause:** `src/main.rs:945-952` — `needs_draw` is set only by `drain_scan_results()`
returning true (a message arrived) or by input events. `spinner_char()` (`src/ui.rs:1311`)
and `activity_bar()` (`src/ui.rs:1321`) derive their frame from wall-clock time, so they
only animate if something else causes redraws. One directory scan produces *zero* messages
until it finishes — the spinner freezes for the whole scan. The package pane's 2-second
ping-pong activity bar cannot animate at all without keypresses.

**Fix:** after `event::poll(timeout)` returns `false` (timeout path), add
`if app.has_pending_scan_work() { needs_draw = true; }`. The poll timeout is already 50ms
while work is pending and 1s when idle (`src/main.rs:955-959`), so this yields ~20fps
animation only during scans and zero idle cost. Ratatui's buffer diffing makes redraws cheap.

**Gotcha:** `has_pending_scan_work` (`src/app.rs:587`) returns true while *any* entry has
`scanning == true`. If a scanner thread ever dies without sending results (panic), flags
stay set forever → permanent 50ms redraw loop. Pair with a "clear scanning flags on
AllDone" sweep in `drain_scan_results` as a safety net.

**Effort:** 3 lines + safety sweep. Verify manually: select a large unsized dir
(`~/Library`), watch spinner animate.

**Status:**
- [x] Completed
- **Changelog:** Redrew the terminal interface when event polling times out during pending scan work. Added a safety sweep on scan completion (`AllDone` message) to clear `scanning` flag for all entries and avoid a potential permanent redraw loop.

### 3. Permission failures silently report 0 B

**Root cause:** `src/bulkstat.rs:178-180` — `open()` failure returns
`DirectoryScan::default()` (zero contribution, no subdirs, no error recorded).
TCC-protected dirs (`~/Library/Mail`, `Messages`, `Safari`, parts of `Containers`) fail
with `EPERM` unless the terminal has Full Disk Access. Result: confident-looking
undercounts with no indication anything was skipped. Trust issue: users will believe wrong
numbers.

**Fix design:**
1. Capture errno on failure (`std::io::Error::last_os_error()`); count `EACCES`/`EPERM` as
   "inaccessible", ignore `ENOENT` (deletion race — normal during scans).
2. Add `inaccessible: u32` to `DirectoryScan``ScanAggregate``DirScan` → thread through
   `ScanMsg::DirSize` → store on `Entry`.
3. UI: render sizes with a marker when `inaccessible > 0` — e.g. `≥ 1.2 GiB` or a yellow
   `*`, with the selected-entry status line explaining "N directories unreadable (no Full
   Disk Access?)".
4. FDA detection at startup: probe `std::fs::read_dir` on 2-3 known TCC paths
   (`~/Library/Mail`, `~/Library/Safari`); if the dir exists but errors `EPERM`, show a
   one-time status hint: "grant Full Disk Access in System Settings → Privacy & Security
   for complete scans". Once per launch, only when relevant.

**Gotchas:** CLI reports (`--top`, `--reclaim`, JSON) should also expose the count
(`"inaccessible": n`). `SizeInfo` is `Copy` and used widely; put the counter on
`DirScan`/`Entry`, not on `SizeInfo`.

**Tests:** temp dir, `chmod 0o000` a subdir, assert `inaccessible == 1` and size still sums
the readable part; restore permissions via a guard so a failing assert doesn't leave an
undeletable dir.

**Effort:** ~half a day.

**Status:**
- [ ] Completed
- **Changelog:** 

### 4. Scan results discarded + stale scans uncancellable

**Root cause:** every `start_scan` (`src/app.rs:769`) bumps `active_scan_id`;
`drain_scan_results` (`src/app.rs:542-576`) matches `scan_id == self.active_scan_id` and
the `_ => {}` arm drops everything else — completed work (possibly a 30s `~/Library` walk)
is discarded, not even cached. The superseded scan keeps running to completion: scoped
threads + the shared global rayon pool have no abort signal, so it competes with the scan
the user actually wants. Cursor-surfing across unsized dirs triggers this constantly
(`move_cursor` → `scan_selected_missing_dir` → `start_scan` on every landing).

**Fix — three tiers:**

- **A. Salvage (do first, ~30 lines):** accept `DirSize` from *any* scan into `size_cache`
  and matching entries; gate only progress counters and `AllDone` on the active ID. Add
  `min_valid_scan_id: ScanId` to `App`, bumped only by data-invalidating events
  (`force_rescan`, `confirm_delete`) — drop messages below the floor so a stale pre-delete
  result cannot repopulate the cache. Navigation-triggered scans never bump the floor.
- **B. Cancellation:** pass an `Arc<AtomicU64>` (current generation) into
  `scan_all`/`scan_dir`; check once per directory in `scan_one_dir` (one relaxed load per
  `open` — cheap). On mismatch, bail. Requires a signature change to `bulkstat::scan_dir`;
  CLI paths and `packages.rs`/`reclaim.rs`/`history.rs` callers pass a never-changing token.
- **C. Structural (absorbs B and finding 22):** replace spawn-per-scan with one long-lived
  scanner worker owning a priority deque: selected-dir requests push front (LIFO =
  responsive), auto-scan batches push back; a pending `HashSet<PathBuf>` dedupes; results
  always flow to cache. `start_scan` becomes enqueue; "AllDone" becomes "queue drained".
  Deletes `worker_count` and the 8-thread outer layer.

**Tests:** start scan, call `start_scan` again (new ID), inject a stale `DirSize` through
the channel, assert `size_cache` contains it (tier A); assert a post-`force_rescan` stale
message is rejected.

**Effort:** A: hours. B: ~1 day. C: 2-3 days, best done together with findings 5, 18, 22.

**Status:**
- [ ] Completed
- **Changelog:** 

### 5. `r` (refresh) only rescans 4 directories

**Root cause:** `force_rescan` (`src/app.rs:505`) funnels into
`scan_candidates(AUTO_SCAN_LIMIT, …)` with `AUTO_SCAN_LIMIT = 4` (`src/app.rs:17`). The
limit exists to protect `auto_scan` (navigating into `/` shouldn't walk everything), but an
explicit refresh keypress is a statement of intent. The status line apologizes: "move or r
to scan more".

**Fix:** in `force_rescan`, pass `missing.len()` as the limit (scan all visible dirs); keep
`AUTO_SCAN_LIMIT` for `auto_scan` only. `scan_candidates` already orders
selected-first-then-wrap, which becomes the queue priority. Status already supports "x/y"
progress.

**Gotchas:** without finding 4's cancellation, pressing `r` at `/` starts a massive scan
you can't stop — sequence after 4B/4C, or accept it (work is at least cached if 4A is in).
The test `initial_scan_is_bounded_for_broad_directories` covers `auto_scan` and stays
valid; update `force_rescan_refreshes_entries_and_preserves_selection` to assert *all* dirs
get `scanning == true`.

**Effort:** one line + test updates (plus dependency on 4).

**Status:**
- [ ] Completed
- **Changelog:** 

### 6. Double-counting: hard links and firmlinks

**(a) Hard links** — a file with `st_nlink > 1` is counted once per directory entry (`du`
dedups by `(dev, inode)`). Fix: request `ATTR_FILE_LINKCOUNT` (fileattr bit `0x00000001`)
and `ATTR_CMN_FILEID` (commonattr bit `0x02000000`); only when linkcount > 1, check/insert
the fileid into a per-scan `Mutex<HashSet<u64>>` (rare path — contention negligible) and
skip already-seen ids.

**Parsing-order trap:** the attribute buffer packs fields in canonical bit order *within
each group*, except `ATTR_CMN_ERROR` which always follows `returned_attrs`. New layout per
entry: error → name-ref → objtype (`0x8`) → **fileid (`0x02000000`)** → then fileattrs:
**linkcount (`0x1`)** → totalsize (`0x2`) → allocsize (`0x4`). The parser at
`src/bulkstat.rs:234-281` walks `field` sequentially — insert the new reads at exactly
those points and gate on the returned-attrs bits (with `FSOPT_PACK_INVAL_ATTRS`, check
before consuming). Get this wrong and every later field misparses silently. Test: create a
hard link (`std::fs::hard_link`), assert the file counts once.

**(b) Firmlinks/volume boundaries** — scanning `/` walks `/Users` (firmlink → data volume)
*and* `/System/Volumes/Data/Users`: everything counts twice. Naive `du -x` semantics (skip
when `ATTR_CMN_DEVID` ≠ root's) is **wrong on macOS**: `/` is the sealed system volume, so
`-x` from `/` would skip all firmlinks and show ~10 GB of nothing.

**Pragmatic policy:**
1. When the scan root is `/` or an ancestor of `/System/Volumes/Data`, skip the
   `/System/Volumes/Data` subtree itself — the firmlinked views (enumerated in
   `/usr/share/firmlinks`) already cover its contents.
2. Request `ATTR_CMN_DEVID` (commonattr `0x2`, packed right after the name-ref) and skip
   descending when devid differs *and* the path is under `/Volumes` — stops accidental
   walks into external/network mounts. Surface skipped mounts in status rather than silence.

**Effort:** (a) ~1 day with parser care; (b) ~half day. Test (b) manually against `/` and
compare with Finder's numbers.

**Status:**
- [ ] Completed
- **Changelog:** 

### 7. Mouse capture with zero mouse support

**Root cause:** `TerminalGuard::enter` (`src/main.rs:918`) executes `EnableMouseCapture`,
but the event loop matches only `Event::Resize` and `Event::Key` — `Event::Mouse` falls
into `_ => {}`. Cost today: terminals route mouse to the app, so users lose click-drag text
selection and scroll-wheel while diskr runs, and get nothing back.

**Fix options:**
- **Remove (2 lines, zero risk):** delete `EnableMouseCapture`/`DisableMouseCapture` from
  `TerminalGuard`.
- **Implement (better for a file manager):** `MouseEventKind::ScrollDown/ScrollUp`  `move_cursor(±3)` routed by which pane contains `(event.column, event.row)`;
  `MouseEventKind::Down(MouseButton::Left)` in the files pane →
  `selected = clicked_row - area.y - 1 + file_list_offset` (pane rect already stored each
  frame at `src/ui.rs:98` as `app.files_area`; `file_list_offset` is on `App`); double-click
  (track last click time/position, <400ms) → `enter()`. Disks/packages panes need their
  rects stored the same way (`disks_area`, `packages_area` fields — not currently captured).

**Note:** even with mouse support, capture means no native text selection — most TUIs
accept this; some offer a "release mouse" toggle. Decide explicitly.

**Effort:** remove: minutes. Implement: ~1 day including pane hit-testing.

**Status:**
- [ ] Completed
- **Changelog:** 

### 8. Brew cask sizes are wrong

**Root cause:** `scan_brew_casks` (`src/packages.rs:420`) sizes
`$(brew --prefix)/Caskroom/<name>` — but most casks move the real `.app` to
`/Applications`, leaving a stub (sometimes just metadata). A 3 GiB app reads as 2 MiB.

**Fix:** one bulk call — `brew info --cask --json=v2 --installed` (background thread;
~1-3s) returns every installed cask with an `artifacts` array; entries like
`{"app": ["Firefox.app"]}` give the bundle name. For each,
`bulkstat::scan_dir("/Applications/<App>.app", 0)` and add to the Caskroom size. Parse with
the existing `serde_json`. Handle: artifacts of type `pkg`/`installer` (unsized — keep stub
size, annotate "installer-based"), user-moved/renamed apps (path missing → fall back to
stub size), `/Applications` vs `~/Applications` (check both).

**Related bug to fix together:** pressing `d` on a cask trashes only the Caskroom stub
(`src/app.rs:611-629` uses `package.path`) — the actual app stays installed and brew now
thinks it's broken. Either route casks' `d` to the same `brew uninstall --cask` flow as
`x`, or extend the delete target to include artifact paths with a confirm listing both.

**Tests:** pure parse test on a canned `--json=v2` fixture. **Effort:** ~half a day.

**Status:**
- [ ] Completed
- **Changelog:** 

### 9. pip sizes miss many packages (and pip3 may not exist)

**Root cause:** `src/packages.rs:591-607` guesses `site-packages/<name>` or the underscore
variant. Fails whenever import name ≠ distribution name (`PyYAML`→`yaml`, `Pillow`→`PIL`,
`beautifulsoup4`→`bs4`), and ignores `.dist-info`, compiled `.so` files outside the package
dir, and scripts.

**Fix — use the installer's own manifest:** every pip-installed package has
`site-packages/<Name>-<ver>.dist-info/RECORD` listing every installed file (relative path,
hash, size). Procedure: PEP 503-normalize the name (lowercase; collapse `-_.` runs to `-`),
find the matching `*.dist-info` dir (normalize its prefix the same way), parse RECORD
(CSV: `path,hash,size`) — the third column gives logical size for free; `lstat` each path
relative to site-packages only for allocated blocks. Set `path` from `top_level.txt` when
present (better `f`/`O`/`d` target).

**Availability bug:** `Manager::Pip.command()` is `"pip3"` and `command_exists("pip3")`
gates the whole section (`src/packages.rs:380-388`) — many setups have `python3` but no
`pip3` shim. Fallback: probe `python3 -m pip --version` and shell out via `python3 -m pip`.
Scope note: only `site.getsitepackages()[0]` is scanned — pyenv/venv/conda are out of scope
(reasonable v1 decision; say it in a UI footnote rather than showing `?`).

**Pattern shared with finding 8:** stop *guessing* installation footprints from naming
conventions; ask the package manager for its manifest (brew `--json=v2` artifacts, pip
RECORD). Authoritative, already on disk or one command away.

**Tests:** RECORD parser unit test with a fixture; name-normalization table test
(`PyYAML`, `ruff`, `typing_extensions`). **Effort:** ~1 day.

**Related (cargo):** `scan_cargo` (`src/packages.rs:618-665`) counts only the binary
matching the package name in `~/.cargo/bin`. A package can install multiple binaries —
`cargo install --list` lists them as indented lines, which the parser currently skips
(`src/packages.rs:629-631`). Parse the indented bin names and sum all of them. Registry/git
cache attribution is shared across packages and not meaningful per-package; label the size
"binaries" in the UI.

**Status:**
- [ ] Completed
- **Changelog:** 

---

## Incomplete

### 10. Dead features: clipboard copy and shell-here

**State:** `copy_path_to_clipboard` (`src/app.rs:1261`) and `open_shell`
(`src/app.rs:1273`) are complete, `#[allow(dead_code)]`, and unbound.

**Clipboard:** ready to ship — works for all three panes via `selected_path()`. Bind `y`
(free in normal mode; `y`/`n` are only consumed inside confirm modals), add to `draw_help`
and README keys.

**Shell:** the current implementation is a trap — it spawns `$SHELL` *while the TUI holds
raw mode and the alternate screen*, so the shell lands on a hijacked terminal. Two correct
designs: **(a) suspend/resume** — leave alt screen + disable raw mode (reuse
`TerminalGuard` logic), spawn the shell with `.status()` (blocking wait), re-enter raw mode
and force a full redraw on return (classic `ranger`/`lf` UX). **(b) macOS-native:**
`open -a Terminal <cwd>` — three lines, no terminal state juggling, opens a new window.
Recommend (b) now, (a) if users ask. Bind `s`.

If neither gets wired this cycle, delete both functions and their `#[allow]`s — dead code
with subtle bugs (the shell one) is worse than no code.

**Effort:** clipboard: 30 min. Shell (b): 30 min. Shell (a): ~half day.

**Status:**
- [ ] Completed
- **Changelog:** 

### 11. Sort by mtime, but mtime is never displayed

**State:** `Entry.modified` is populated (`src/app.rs:273`), `SortMode::Modified` sorts by
it, no UI renders it anywhere — you sort by an invisible column.

**Fix:** (a) status line — always append modified time for the selected entry in
`selection_status` (cheap, do regardless); (b) list column — when `sort == Modified`, swap
the size column for a date column, or add a third column when `inner_width` allows (~50+
cols): extend `file_columns` (`src/ui.rs:1203`) to return an optional date width, mirroring
how the size column already collapses on narrow widths (keep column tests in sync). Format
relative for recency ("3h", "2d", "Mar 12", "2024-06-01") — `format_elapsed`
(`src/main.rs:571`) is most of this; move it somewhere shared (`app.rs` next to `human()`).

**Effort:** (a) 15 min; (b) ~2-3 hours with width tests.

**Status:**
- [ ] Completed
- **Changelog:** 

### 12. Help is one unwrapped, truncating line

**State:** `draw_help` (`src/ui.rs:648`) renders 19 hints in a single `Line` in a height-1
area — anything past the terminal width is cut off (no wrap configured, and one row
couldn't wrap anyway). Narrow terminals lose `d trash`, `q quit`, everything to the right.

**Fix:** add a `?`-key modal: `app.show_help: bool`, a `draw_help_overlay` using the
existing `centered_rect` + `Clear` pattern from `draw_pkg_detail` (`src/ui.rs:771`),
content grouped into sections (Navigate / Act on selection / Panes / Search / Packages),
closes on `?`/`Esc`/`q`. Key handling slots in `run()` before the search-mode branch, same
shape as the `pkg_detail` block (`src/main.rs:1004`). Shrink the bottom line to the ~8
most-used hints ending with `? help`. Structure overlay content as data
(`&[(&str, &[(&str, &str)])]`) so the bottom line can later become context-sensitive from
the same source.

**Effort:** ~2-3 hours.

**Status:**
- [ ] Completed
- **Changelog:** 

### 13. Dep graph covers only brew and pip

**State:** `scan_dep_graph` (`src/packages.rs:198`) builds edges from
`brew deps --installed --for-each` and `pip3 show`; everything else gets
`DepInfo::default()` → `Untracked` → rendered `?` and excluded from the `u`
(dependency-leaves) filter. The filter is useless for cargo/npm/bun/cask users.

**Key realization that makes this cheap:** globally-installed cargo binaries, npm `-g`
packages, and bun `-g` packages are *by definition* leaves — nothing else depends on a
global CLI install. They don't need a graph query; mark them
`DepInfo::tracked(vec![], vec![])` (evidence: ManagerGraph, no dependents) in the
`match report.manager` at `src/packages.rs:230-235`. That instantly makes the leaf filter
meaningful across all managers. Casks: almost always user-requested leaves, but a few
formulae depend on casks; marking them leaves is ~99% right — or run
`brew uses --installed --cask` for rigor.

**Gotcha:** the detail popup's wording ("not dependency-tracked by this package manager")
should change for these to "globally installed — nothing depends on it".

**Effort:** ~1 hour. Unit test: build a report with a cargo package, assert
`use_status == DependencyLeaf`.

**Status:**
- [ ] Completed
- **Changelog:** 

### 14. Project-deps rescan on every pane visit

**State:** `p` or Tab into packages → `load_packages` (`src/app.rs:881`) → if already
loaded, `reload_project_deps()` → full `find_project_deps(&cwd, 5)` re-walk, re-sizing
every `node_modules`/`target`/`.venv` under cwd, every single time. From `~` that's seconds
of redundant `scan_dir` work per visit.

**Fix:** record `project_deps_cwd: Option<PathBuf>` when results land; in `load_packages`,
skip the reload when `project_deps_cwd == Some(self.cwd)` — refresh only on explicit `r`
(already calls `refresh_packages`) or after a project-dep deletion (`confirm_delete`
already calls `reload_project_deps` — keep it, it updates the marker). Optionally route the
deps-dir `scan_dir` calls through `size_cache` so file-pane and package-pane scans share
results — they size the same `node_modules` dirs twice today.

**Effort:** ~1 hour for the cwd marker; shared-cache option rides on finding 4's rework.

**Status:**
- [ ] Completed
- **Changelog:** 

---

## Missing

### 15. TUI surfaces for the existing intelligence (the big one)

Four sub-projects, ordered by value:

**(a) Reclaim pane — the killer feature.** Add a fourth pane (Tab cycle: Files → Disks →
Packages → Reclaim) or `R` overlay. On first focus, run `reclaim::report(home)` on a
background thread (same channel pattern as `pkg_scan_rx`: `Option<Receiver<ReclaimMsg>>` +
loading spinner — the walk takes seconds). Render findings sorted by size with class
color-coding (safe=green, regenerable=yellow, risky=red — `Reclaimability::label()`
exists), `Enter` expands a finding's `paths`, `d` trashes a path through the existing
confirm-modal flow (`DeleteTarget` needs a `ReclaimPath` variant or reuse `FileEntry`),
then re-scan just that finding. The "explain-first cleanup" guardrail from ROADMAP.md is
the design spec: show size + class + note before any action. **Effort: 2-3 days.**

**(b) Top-files view.** `t` on a selected dir (or cwd) → background
`bulkstat::scan_dir(path, 50)` (the heap collection already exists and is tested; the TUI
just always passes `0` today) → modal with a *selectable* list (needs its own `selected`
index + scroll state, unlike current static modals): `Enter`/`f` reveal, `d` trash, `Esc`
close. Note: a size-only scan of the same dir may already be cached but top-files needs a
fresh walk (names aren't retained) — accept the re-scan, it's explicit.
**Effort: 1-2 days.**

**(c) Disks pane enrichment.** `i` on a disk → modal with `space::report_for_path(mount)`
data: container free, snapshots count + names, free-vs-available gap ("X free but not
user-available — likely purgeable/snapshots"). **Warning:** `report_for_path` shells out to
`tmutil`/`diskutil` synchronously (100ms-2s) — must run on a background thread with the
same rx pattern, never on the UI thread. Snapshot *thinning* from the TUI: defer; it's
destructive-ish and the CLI dry-run flow is the right home until the confirm UX is
designed. **Effort: ~1 day.**

**(d) Diff awareness.** Load `~/Library/Application Support/diskr/history.json` once at
startup (the `history` module has the loaders); when cwd matches a baseline, render a
header chip: `+2.3 GiB since Jun 3`. Add `B` to save a baseline for cwd from the TUI.
**Effort: ~half day.**

**Status:**
- [ ] Completed
- **Changelog:** 

### 16. File operations

**Scope recommendation first:** rename + mkdir + multi-select + batch-trash covers ~90% of
cleanup workflows; full copy/move across directories wants a dual-pane or yank/paste model
— design separately, don't block on it. Alternatively, sharpen the product line ("disk
manager, not file manager") in README/description and skip copy/move deliberately.

- **Text-input infrastructure (prerequisite):** rename and mkdir need a line-input mode.
  The search implementation is the template — a `mode` enum beats more bools
  (`search_mode`, `pkg_search_mode`, and any new input mode are mutually exclusive; today
  that invariant is by-convention). Input state: prompt label, buffer, on-commit action.
- **Rename (`c`):** prefill buffer with current name; commit → `std::fs::rename` within the
  same dir (same-volume by construction), `invalidate_cache_for` both old path and parent,
  reload preserving selection on the new name. Reject `/` in input, empty names, existing
  targets (no overwrite — this is a cleanup tool).
- **mkdir (`n`):** same input flow → `std::fs::create_dir`, reload, select the new dir.
- **Multi-select (`v` mark, `a` mark-all-visible, `` prefix):** `marked: HashSet<PathBuf>`
  on App, cleared on cwd change. `d` with marks → batch confirm modal: "Trash 3 items
  (4.2 GiB)?" — sum sizes from entries (unsized dirs make the total a lower bound; display
  ``). Loop `delete_to_trash`, collect per-item failures into status, invalidate each.
- **Empty Trash:** surface inside the reclaim pane's Trash finding. Implementation:
  `osascript -e 'tell application "Finder" to empty trash'` — canonical and safe (Finder
  handles locked items), but triggers a one-time TCC *Automation* prompt ("diskr wants to
  control Finder"). The alternative (`rm -rf ~/.Trash/*`) is permanent deletion with no
  Finder integration — don't. Show reclaimable size in the confirm.

**Effort:** input mode + rename + mkdir: ~1 day. Multi-select + batch trash: ~1 day.
Empty trash: ~2 hours.

**Status:**
- [ ] Completed
- **Changelog:** 

### 17. Size-bar visualization

**Design:** per-row bar showing each entry's share, dust/gdu-style. Denominator choice:
*max visible entry size* makes the biggest row full-width (best for comparison); *sum of
entries* shows proportion-of-this-dir. gdu uses max; recommend max with
percentage-of-sum as the number: `▏node_modules ████████░░ 62% 1.2G`.

**Implementation:** extend `file_columns` (`src/ui.rs:1203`) with a bar segment (8-14
chars) that, like the size column, collapses below a width threshold (~55 cols) — update
the column tests. Compute `max_size` once per frame over visible entries. Render with `█`
(filled) / `░` or dim background (empty); `size == None` gets an empty bar, scanning
entries keep their spinner. Color by share (>50% red-ish, >25% yellow, else default).

**Gotcha:** until sizes arrive, bars pop in as scans complete — the existing sort-debounce
keeps rows from jumping and re-barring at once.

**Effort:** ~half day including width tests.

**Status:**
- [ ] Completed
- **Changelog:** 

### 18. Full-subtree scan mode

**Design:** `S` = "scan everything in this directory" — every missing dir in cwd, not 4.
With finding 5 fixed, `r` already rescans-all-visible (invalidating cache); `S` is the
non-invalidating variant (fill in what's missing). Once 4C's queue exists, both collapse
into "enqueue all visible, selected-first" with different cache-invalidation flags —
implement as one function with an `invalidate: bool`.

**Progress UX:** `scan_total`/`scan_completed` already render "x/y"; with a queue, also
show the currently-scanning dir name (truncated) in status. **Cancel:** `Esc` while a bulk
scan runs → drain the queue (needs 4B/4C). This is the piece that makes diskr feel like
ncdu for "I'm cleaning this disk *now*" sessions, while keeping the lazy default for
browsing.

**Effort:** trivial after 4+5; ~half day standalone.

**Status:**
- [ ] Completed
- **Changelog:** 

### 19. Persistent size cache

**Phase 1 — trust but mark stale:**
- File: `~/Library/Application Support/diskr/size-cache.json` (same dir as history.json;
  `state_dir()` in `src/history.rs:212` is reusable — extract to a shared module). Schema:
  `{version: 1, entries: [{path, logical, allocated, scanned_at}]}`, serde_json (already a
  dep).
- Load at startup into `size_cache` plus a parallel `cache_age: HashMap<PathBuf, u64>`;
  render cached-but-not-rescanned sizes dimmed or with `~` prefix so stale data is visibly
  provisional; any fresh scan result overwrites and un-dims.
- Save on quit (TerminalGuard drop is too late for App access — do it in `run()`'s exit
  paths) and every ~60s during scans. Prune to most-recent ~50k entries (LRU by
  `scanned_at`) to bound the file.
- `invalidate_cache_for` (deletes) must also remove from the persistent layer — it already
  walks ancestors; let the save serialize the post-invalidation map.

**Why not validate with dir mtime:** a directory's mtime changes only when its *direct*
children change — a deep descendant growing 10 GiB leaves every ancestor mtime untouched,
so mtime validation gives false confidence. Hence "mark stale, refresh on demand".

**Phase 2 (separate project):** FSEvents — persist the last `FSEventStreamEventId`; on
startup, replay events since then and invalidate touched subtrees. Correct incremental
rescans, but it's CoreServices C FFI with a callback runloop thread, and FSEvents can drop
events (must handle `kFSEventStreamEventFlagMustScanSubDirs`). Don't gate phase 1 on it.

**Effort:** phase 1: ~1 day. Phase 2: ~3+ days.

**Status:**
- [ ] Completed
- **Changelog:** 

### 20. File info popup

**Design:** `i` in Files focus (key is free there — currently packages-only) → modal via
the `draw_pkg_detail` pattern: full path (truncate-start), type, logical vs allocated with
a note when they diverge ("APFS clone/sparse/compressed — allocated < apparent"),
created/modified/accessed (`symlink_metadata` + `MetadataExt`: `ctime`/`mtime`/`atime`),
owner/group (`libc::getpwuid_r`/`getgrgid_r` — libc already a dep; fall back to numeric),
permissions (octal + `rwxr-xr-x` string), hard-link count (`nlink` — ties into finding 6a),
xattr count via `libc::listxattr` (flag `com.apple.quarantine` specially — users recognize
it). For dirs: cached recursive size + item count if known. All from one `lstat` + one
`listxattr`; no background thread needed.

**Effort:** ~1 day. Pure-function tests for the perm-string and date formatting.

**Status:**
- [ ] Completed
- **Changelog:** 

---

## Have but don't need

### 21. `EnableMouseCapture`

Same issue as finding 7; the cut option is the 2-line removal there. Decide
remove-vs-implement once; don't leave the current state.

**Status:**
- [ ] Completed
- **Changelog:** 

### 22. Outer scanner thread layer

**State:** `scan_all` (`src/scanner.rs:34-66`) spawns a coordinator thread plus up to 8
scoped workers (`worker_count`, `src/scanner.rs:69`) that pull dirs via `AtomicUsize` — but
each worker just blocks in `bulkstat::scan_dir`, whose `rayon::scope` schedules all real
work on the *global* rayon pool anyway. The outer threads add no parallelism; they only
keep multiple scopes in flight.

**Fix:** replace the body with one spawned thread doing
`dirs.into_par_iter().for_each(|dir| { let size = bulkstat::scan_dir(&dir, 0).size; let _ = tx.send(...); })`
then `AllDone`. Nested rayon (par_iter → scope) is safe — blocked scope-holders
work-steal. Deletes `worker_count`, its test, the `Arc`/`AtomicUsize` choreography;
behavior (streaming per-dir results) is identical.

**Caveat:** if doing finding 4C, this file gets rewritten as the queue worker anyway — fold
this in there rather than doing it twice. **Effort:** ~1 hour standalone.

**Status:**
- [ ] Completed
- **Changelog:** 

### 23. Exact-pinned `ratatui-core =0.1.0` / `ratatui-widgets =0.3.0`

**State:** `Cargo.toml:16-17` pins the split sub-crates at their earliest versions, with a
hand-rolled backend in `src/terminal_backend.rs` to bridge crossterm. The pins mean no
bugfixes ever arrive, and the split crates' APIs are still settling — this combination will
rot.

**Fix:** migrate to mainline `ratatui` (0.30+) with its built-in `CrosstermBackend` — this
*deletes* `terminal_backend.rs` entirely and likely needs only import-path changes in ui.rs
(same widget API lineage). The original motive was presumably binary size/dependency count;
measure it: build both, compare stripped binary size (expect a modest increase). If size
wins, at least relax to caret ranges (`ratatui-core = "0.1"`) so patch fixes flow.

**Effort:** ~half day including a visual regression pass over every pane/modal.

**Status:**
- [ ] Completed
- **Changelog:** 

### 24. Esc quits the app from the files pane

**State:** `src/main.rs:1127-1133` — Esc in Files focus returns `Ok(())` (quit); in other
panes it focuses Files. The dangerous sequence is reflexive: Esc to leave search, Esc again
out of habit → app gone, all scan state lost (no persistent cache yet — finding 19 raises
the stakes).

**Fix:** make Esc in Files a no-op (or clear status/search remnants); `q` remains quit.
Optional middle ground: double-Esc within 500ms quits, or Esc shows "press q to quit" in
status. Update the help line, README keys table, and the `--help` text (`src/main.rs:269`
documents "q, Esc — Quit"). Mention in release notes — it's a muscle-memory change.

**Effort:** 15 minutes; the decision is the work.

**Status:**
- [ ] Completed
- **Changelog:** 

---

## Suggested sequencing

| Phase | Items | Theme |
|-------|-------|-------|
| Quick wins (a day) | 2, 1, 13, 14, 24, 10-clipboard | Small fixes, immediate feel |
| Scan correctness (2-4 days) | 4A → 4C (+22), 5, 18, 3 | Trustworthy, cancellable scanning |
| Accuracy (2-3 days) | 6a, 8, 9, 6b | Numbers users can believe |
| Product leap (1-2 weeks) | 15a → 17 → 15b → 16 → 19 → 15c/d, 20, 12, 11 | The TUI becomes the product |
| Housekeeping | 7, 23, 10-shell | Decide and clear |

Dependency to respect: 4 (scan queue + cancellation) unlocks 5 and 18 and absorbs 22 — do
those as one arc. 15a (reclaim pane) is the single highest-value item and only depends on
UI patterns that already exist.