diskr 0.1.44

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
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
# diskr Product & Code Audit

Audited at v0.1.20 (2026-06-10), then rechecked through v0.1.44 (2026-06-12).
Findings and completion statuses below are verified against the current source.

**Summary:** codebase is healthy (clippy clean, 109 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.

2026-06-12 recheck: `rustup run 1.88.0 cargo test --locked` passes all 109 tests, but the
newer TUI surfaces added after the first audit have several state, safety, and
documentation gaps that are not covered by those tests. New findings start at 25.

---

## 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:**

- [x] Completed
- **Changelog:**
- Fixed both search-mode selection-coordinate failures: `scan_selected_missing_dir` now resolves directory scans through `visible_entry_index`, and `apply_sort_preserving_selection` now captures the visible selection path, rebuilds `search_matches` during active search via `update_search()`, and restores selection in visible space after sorting. Added regression tests in `src/app.rs`: `scan_selected_missing_dir_uses_visible_mapping_during_search` and `apply_sort_preserving_selection_rebuilds_search_matches`.

### 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:**

- [x] Completed
- **Changelog:**
  - Added `inaccessible` counters to `DirScan`, internal directory aggregates, scanner messages, `Entry`, and reclaim findings/reports.
  - `bulkstat::scan_dir` now counts `EACCES`/`EPERM` directory-open failures while continuing to sum readable content; deletion races still contribute zero silently.
  - Directory rows with unreadable descendants render with a lower-bound marker (`≥`) and the selected-entry status line explains the unreadable-directory count.
  - `--top --json` and `--reclaim --json` now include `inaccessible`; text reports print a lower-bound warning when unreadable directories were skipped.
  - Added regression coverage for unreadable subdirectories while preserving the readable-size sum.

### 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:**

- [x] Completed
- **Changelog:** Split both top-files and reclaim-path modals into dedicated list and footer areas so the action hints stay visible, made their `PageUp`/`PageDown` movement use the actual visible modal height, and tightened modal offset tracking to follow the same window-bounds rules as the main files pane. Added regression coverage for modal page-step sizing and offset clamping.

### 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:**

- [x] Completed
- **Changelog:** In `force_rescan`, changed the candidates scan limit from `AUTO_SCAN_LIMIT` (4) to `missing.len()` to scan all visible directories. Updated the unit test `force_rescan_refreshes_entries_and_preserves_selection` to verify that all directories are marked for scanning upon a forced rescan.

### 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:**

- [x] Completed
- **Changelog:** Updated `scan_brew_casks` to fetch package metadata using `brew info --cask --json=v2 --installed`. Extracted `.app` bundle names and scanned them under `/Applications` and `~/Applications` to compute accurate disk footprint. Annotated installer-based packages (`pkg`/`installer` artifacts) as `(installer-based)` and kept their stub sizes. Routed cask deletion requests (`d` key) directly to the uninstallation flow (`brew uninstall --cask`) to prevent broken metadata. Added a unit test validating parsing logic against a mock JSON payload.

### 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:**

- [x] Completed
- **Changelog:** Reworked pip package sizing to read each package’s `*.dist-info/RECORD` manifest from `site-packages` and sum manifest sizes plus file-block sizes from `symlink_metadata`; resolved package path using `top_level.txt` when present before falling back to directory-name heuristics. Added pip backend detection that prefers `pip3` and falls back to `python3 -m pip` when only the shim exists. Implemented `PEP 503` normalization for dist-info matching and added parser tests.
- **Changelog (related cargo):** Updated `scan_cargo()` to parse all indented binaries from `cargo install --list` and accumulate all matching installed binaries under `~/.cargo/bin`, not just the package-name binary.

---

## Completed

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

**State:** `copy_path_to_clipboard` (`src/app.rs:2146`) and `open_shell`
(`src/app.rs:2158`) are now wired in the main keymap and documented in UI/help.

**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:**

- [x] Completed
- **Changelog:**
  - Bound `y` to `copy_path_to_clipboard()` in the main keymap (all panes), removed `#[allow(dead_code)]`, and documented it in both the inline help bar and `README.md`.
  - Replaced `open_shell()` with a Terminal.app launch that opens the selected directory path in a new shell window via `open -a Terminal`, avoiding the raw-mode alt-screen hijack.

### 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:**

- [x] Completed
- **Changelog:**
- Updated `src/app.rs` to expose shared timestamp formatting helpers:
  - moved `format_elapsed` from `src/main.rs` to `src/app.rs` next to `human`
  - added `format_modified_time` to render entry `modified` timestamps as recency (`3h`, `2d`) or absolute dates (`Mar 12`, `2024-06-01`) depending on age
- Updated status and file-list rendering (`src/ui.rs`) so the selected entry always appends modified time in `selection_status`, and file mode now shows mtime in the list when `SortMode::Modified` is active. For wide layouts (`file_columns(..., true)`), size and mtime columns are shown together; for tighter widths, size is replaced by the date column.
- Issue is complete with no remaining functional gap between sorting by mtime and displaying that timestamp.

### 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:**

- [x] Completed
- **Changelog:** `scan_dep_graph` now treats globally installed casks, npm packages, cargo-installed tools, and bun packages as tracked dependency leaves with no package-manager dependents, so the `u` dependency-leaves filter works across all package managers instead of only brew formulae and pip. Package details now describe these rows as global installs with no package-manager dependents rather than unsupported dependency tracking. Added regression coverage for the graph classification and the package leaf filter.

### 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:**

- [x] Completed
- **Changelog:** Added a `project_deps_cwd: Option<PathBuf>` marker to `App` and updated `load_packages()` to only call `reload_project_deps()` when `project_deps_cwd != Some(self.cwd)`. Set `project_deps_cwd` to `Some(self.cwd)` when package scan results land in `drain_package_results()` so revisiting the packages pane no longer recomputes project dependencies unless the working directory changed or an explicit refresh is requested.

---

## 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:**

- [x] Completed
- **Changelog:**
  - Implemented the new Reclaim pane as a fourth focus target in the `Files -> Disks -> Packages -> Reclaim` cycle (via `Tab`/`BackTab`) with lazy background scanning (`ReclaimMsg`/receiver plumbing already present in `App`), plus a loading state and class-colored findings list.
  - Added explain-first reclaim actions: selection details now show finding size/class/note, `Enter` opens path lists, `d` follows existing confirm/trash flow through a reclaim-path `DeleteTarget`, and `Esc` closes the paths modal.
  - Added top-files modal workflow (`t` in files pane) that scans `bulkstat::scan_dir(path, 50)` in background, keeps its own selected row + selection movement, supports `reveal/open/delete`, and closes on `Esc`.
  - Added disk details modal (`i` on a selected disk) using background `space::report_for_path(mount)` scans and rendering `total/used/free`, `free-vs-available` gap, APFS container free space, and snapshot summary.
  - Wired history baseline diff awareness into the header: baseline status and signed delta are rendered when `history` state is available; added `B` to persist the current directory baseline from the TUI.
  - Hardened the issue-15 TUI surfaces by canonicalizing TUI/report roots, tying package and reclaim worker results to their originating cwd, dropping stale async results after navigation, resetting cwd-scoped pane state on directory changes, adding modal paging for long reclaim/top-files lists, fixing reverse navigation into Reclaim, and cleaning up baseline header text.

### 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
- **Current recheck:** v0.1.35 implementation is partial; see findings 27-29 for the
  remaining user-facing bugs. Rename/mkdir exist, but rename does not reselect the renamed
  entry. Multi-select exists in `App` state but is invisible in the file list, is not
  cleared on cwd changes, and batch confirmation does not show the actual item list/size.
  Empty Trash is wired to `E`, but it executes immediately and synchronously instead of
  using an explain-first confirmation.
- **Changelog:**
  - Added `InputMode` enum and input state fields (`input_mode`, `input_prompt`, `input_buffer`, `input_on_commit`) to `App` for text-input infrastructure.
  - Implemented `request_rename` (`c` key in files pane): prefill with current name, commit via `std::fs::rename` within same directory, invalidate cache for old path and parent, reload preserving selection on new name. Rejects `/`, empty names, existing targets.
  - Implemented `request_mkdir` (`n` key in files pane): creates new directory via `std::fs::create_dir`, reloads, selects new directory.
  - Added `marked: HashSet<PathBuf>` for multi-select: `v` toggles mark on selected item, `a` marks all visible items. Marked items show `✓` prefix in file list.
  - Implemented batch trash via `d` with marks: confirms "Trash N items (size)?" modal, loops `delete_to_trash`, collects failures, invalidates cache for each.
  - Added `empty_trash` in `fs_ops.rs` using `osascript -e 'tell application "Finder" to empty trash'`; exposed via `E` key in reclaim pane.
  - Added input overlay rendering in `ui.rs` (`draw_input_overlay`) and key handling in `main.rs` for `Esc` (cancel), `Enter` (commit), `Backspace`, and character input.
  - Updated help text in `print_help()` and status line.

### 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:**

- [x] Completed
- **Changelog:**
  - Implemented `file_columns` in `src/ui.rs` to allocate a width-aware bar segment (`8`–`14` cols) that is shown only when pane width is sufficient.
  - Updated `draw_files` to compute frame-level `max_visible_size` and `total_visible_size` from visible rows and render per-row bars with percentages.
  - Added `file_size_bar` styling by share (`>50%` red, `>25%` yellow, otherwise default), and unknown/zero-size graceful handling (`--%`, blank bars).
  - Added tests in `src/ui.rs` covering:
    - bar visibility at wide widths,
    - bar width/column layout math,
    - and bar rendering/percent formatting (including unknown-size rows).

### 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:**

- [x] Completed
- **Changelog:**
  - Added `S` in the TUI as a non-invalidating full scan for every visible directory whose size is still unknown. It reuses the same selected-first scan ordering as lazy scans and `r`, but preserves existing cached/known sizes.
  - Added scanner progress messages so bulk scans can show the directory name currently being processed instead of only a generic count.
  - Documented `S` in `--help`, README, and the bottom help strip; added regression coverage that `S` scans all missing visible directories without invalidating known cached rows.
  - Released as `0.1.38`.

### 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:**

- [x] Completed
- **Changelog:**
  - Added shared state-path helpers and persisted TUI directory sizes to `~/Library/Application Support/diskr/size-cache.json` with schema version 1.
  - Startup loads cached sizes as visibly stale (`~` prefix and dim status text with cache age); fresh scan results refresh timestamps and clear the stale marker.
  - Saves dirty cache state on TUI exit and approximately every 60 seconds during scan result draining, pruning to the most recent 50k entries by `scanned_at`.
  - Cache invalidation now removes persistent metadata for changed paths and ancestors, including delete/rename/mkdir flows, and preserves unreadable-directory counters in the cache file.
  - Added regression coverage for stale cache projection, cache invalidation metadata cleanup, and size-cache schema round trips.

### 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:**

- [x] Completed
- **Changelog:**
  - Replaced the extra `Arc<AtomicUsize>` scoped-worker layer in `Scanner::scan_all` with one background thread that dispatches roots through `rayon::into_par_iter`, preserving the same per-directory `DirSize` messages and final `AllDone`.
  - Removed the obsolete `worker_count` helper/test and added a scanner contract test that verifies each requested directory emits a size result before completion.
  - Released as `0.1.37`.

### 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:**

- [x] Completed
- **Changelog:**
  - Updated normal-mode key handling in `src/main.rs` so `Esc` no longer quits when Files has focus; `q` remains the quit key.
  - In Files focus, `Esc` now does nothing while other panes still return to Files, aligning with modal/search cancel behavior.
  - Updated help text in `print_help` and `README.md` to document `Esc` as a focus/close action instead of quit.

---

## Additional v0.1.35 Recheck Findings

### 25. Relative start paths break parent navigation and scoped reports

**Root cause:** `run_app` validates the incoming `PathBuf` but never canonicalizes it
(`src/main.rs:55-64`), and `App::new` stores that path directly as `cwd`. Rust reports the
parent of `"."` and `"Downloads"` as `Some("")`, so `go_up` can set `cwd` to the empty path
(`src/app.rs:470-477`) and the next `read_dir` fails. The same relative-root problem leaks
into report modes: `print_reclaim` passes the raw path to `reclaim::report`, and
`fixed_findings` filters HOME-relative cache paths with `candidate.starts_with(root)`. From
`$HOME`, `diskr --reclaim .` therefore misses fixed cache categories like
`~/Library/Caches` because `/Users/.../Library/Caches` does not start with `"."`.

**Repro:**

- Run `diskr .`, press `Backspace`: the app tries to navigate to an empty cwd instead of
  the real parent directory.
- From `$HOME`, compare `diskr --reclaim .` with `diskr --reclaim "$HOME"`; the relative
  form can omit fixed HOME cache findings.

**Fix:** canonicalize start/report roots immediately after validation (`start.canonicalize()`
in `run_app`, and before passing roots into `reclaim::report`/`packages::find_project_deps`).
Keep display truncation as-is; users do not benefit from preserving a relative internal cwd
when path-scoped features assume absolute paths.

**Tests:** `App::new(tempdir.join("."))` should normalize to the tempdir and `go_up` should
select the parent. Add a reclaim test that calls `report_with_home(Path::new("."), Some(home))`
from inside the home fixture only if the implementation intentionally canonicalizes first.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 26. Background package/reclaim results can belong to the previous directory

**Root cause:** async scans are keyed only by scan id, not by the cwd they scanned. For
packages, `request_package_scan` captures `cwd` in the worker (`src/app.rs:1488-1505`), but
`PkgScanMsg` does not include it; `drain_package_results` writes `project_deps` and then sets
`project_deps_cwd = Some(self.cwd.clone())` (`src/app.rs:1535-1536`). If the user starts a
package scan in directory A, navigates to directory B before it completes, and then opens the
package pane, A's project dependencies are displayed and marked as if they were scanned for
B. Reclaim has the same class of bug: `request_reclaim_scan` captures the old cwd
(`src/app.rs:1028-1038`), `drain_reclaim_results` accepts by scan id only
(`src/app.rs:842-855`), and `open_reclaim_for_focus` refuses to rescan whenever any old
`reclaim_report` exists (`src/app.rs:1011-1015`).

**Repro:** create two temp roots with different `package.json`/`node_modules` or reclaimable
artifacts. Trigger `p` or focus Reclaim in root A, immediately enter root B, wait for the
worker. The pane can show A's rows under B's cwd; with reclaim paths this can point delete
actions at the wrong tree.

**Fix:** include `cwd: PathBuf`/`root: PathBuf` in `PkgScanMsg` and `ReclaimMsg`. On drain,
discard or retain-as-stale any message whose root does not equal `self.cwd`. Track
`reclaim_cwd: Option<PathBuf>` the same way `project_deps_cwd` was intended to work, clear
or rescan reclaim state on cwd changes, and set `project_deps_cwd` from `msg.cwd`, not
`self.cwd`.

**Tests:** unit-test stale package and reclaim messages by constructing an app at A,
changing `app.cwd` to B before drain, and asserting no A rows are surfaced/marked as B.

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

**Status:**

- [x] Completed
- **Changelog:** Added `cwd`/`root` fields to package and reclaim scan messages, discarded stale results when the app has navigated away from the scanned directory, tracked reclaim reports by cwd, cleared cwd-scoped pane state on directory changes, and set `project_deps_cwd` from the worker message rather than the current UI cwd. Added stale-result regression coverage for both package and reclaim scans.

### 27. Multi-select is invisible, sticky across directories, and can trash old-path marks

**Root cause:** `marked: HashSet<PathBuf>` is updated by `toggle_mark` and
`mark_all_visible` (`src/app.rs:2090-2118`), but `draw_files` never consults `marked`
(`src/ui.rs:176-248`), so there is no checkmark/prefix despite the issue-16 changelog.
Marks are also not cleared in `enter`, `go_up`, `toggle_hidden`, `reload`, or cwd changes.
`request_delete` only checks `focus == Files && !marked.is_empty()` (`src/app.rs:1202-1208`);
it does not require those marks to be in the current directory or visible view.

**Repro:** mark a file in directory A, enter directory B, press `d`, confirm. The batch
delete can move the old A path(s) to Trash while the UI is showing B, and the file list gives
no visual warning that anything is still marked.

**Fix:** expose mark state to the UI (`App::is_marked(&Path)` or include it in visible row
data) and render a stable checkmark column/prefix. Clear marks on any cwd change, hide/show
toggle, full reload, and successful batch delete; or scope marks by cwd and refuse batch
delete unless every marked path is under the current cwd. The confirmation modal should list
the first few paths and the count so stale marks are obvious before confirmation.

**Tests:** mark in A, navigate to B, assert `marked` is empty or `request_delete` refuses.
Render-level unit coverage should assert marked rows include the prefix.

**Effort:** ~half day.

**Status:**

- [x] Completed
- **Changelog:** Marked rows now render with a checkmark prefix, marks are cleared when entering another directory, going up, or toggling hidden files, and the batch-delete confirmation lists a sorted preview of the first marked names. Added `marks_clear_when_changing_directory_or_visibility` and `request_delete_batches_marked_items_with_summary` regression coverage.

### 28. Rename reloads the old selection instead of the renamed entry

**Root cause:** after a successful rename, `input_commit` invalidates old/new cache entries
and calls `self.reload()?` (`src/app.rs:2004-2014`). `reload()` captures the currently
selected old path from `self.entries` and tries to restore it; that path no longer exists, so
selection falls back to the previous index. The issue-16 design explicitly said rename should
reload preserving selection on the new name.

**Repro:** sort by name, select `z.txt`, rename it to `a.txt`. The selected row remains the
old index after sorting/reload instead of following `a.txt`, so the next action can apply to
a neighbor.

**Fix:** after `std::fs::rename`, call `reload_with_selection(Some(new_path), previous_index)`
or add a public helper that reloads and selects an explicit path. Keep the status after
reload as the delete path does.

**Tests:** temp dir with three files, rename the last-sorted file to the first-sorted name,
assert `entries[selected].path == new_path`.

**Effort:** ~30 minutes.

**Status:**

- [x] Completed
- **Changelog:** Rename success now reloads the file list with the renamed path as the preferred selection instead of trying to restore the removed old path. Added `rename_reload_selects_new_path_after_sorting` to cover renaming `z.txt` to `a.txt` under name sorting and verify the selection follows the new entry.

### 29. Empty Trash is immediate, blocking, and bypasses the cleanup guardrail

**Root cause:** `E` in the reclaim pane calls `app.request_empty_trash()` directly
(`src/main.rs:1407-1410`). That method synchronously runs `fs_ops::empty_trash()`
(`src/app.rs:1125-1140`), which shells out to Finder via `osascript`
(`src/fs_ops.rs:181-194`). There is no confirmation modal, no display of the Trash finding's
size/path, and the event loop is blocked while Finder/Automation permission prompts run.

**Repro:** focus Reclaim and press `E`. The Trash can be emptied permanently without the
same `y/n` confirmation used for ordinary Trash moves, and the TUI can sit in raw
alternate-screen mode while macOS shows an Automation prompt.

**Fix:** add a `ConfirmAction::EmptyTrash`/`PendingAction` path instead of piggybacking on
`DeleteTarget`. Show the Trash finding size and note ("emptying is permanent"), require `y`,
then run `empty_trash` on a background worker with a loading state. After success, refresh
disks and rescan reclaim for the current cwd.

**Tests:** unit-test that pressing/requesting empty trash enters confirmation state and does
not call `empty_trash` until confirm. Keep the actual osascript call behind a trait/function
boundary so tests do not touch the user's Trash.

**Effort:** ~half day.

**Status:**

- [x] Completed
- **Changelog:** `E` now arms an Empty Trash confirmation instead of running immediately, shows the reclaim-pane Trash size when known, handles `y`/`n`/`Esc` through the modal path, runs the Finder `empty trash` command on a background worker, and refreshes disks/reclaim results after success. Added `empty_trash_requires_confirmation_before_running` so tests do not touch the user's Trash.

### 30. History baseline refreshes run full scans on the UI thread

**Root cause:** `refresh_history_state` calls `history::diff(&self.cwd)` synchronously when
a baseline exists (`src/app.rs:329-335`). `history::diff` calls `scan_record`, which sizes
every immediate child recursively (`src/history.rs:94-105`, `src/history.rs:166-195`).
This path runs inside app startup and navigation/delete/rename handlers (`App::new`,
`enter`, `go_up`, `confirm_delete`, `input_commit`, `save_history_baseline`). The `B` key is
also synchronous (`src/main.rs:1461-1463` -> `src/app.rs:789-794`).

**Repro:** save a baseline for a broad directory such as `$HOME`, then navigate back into
that directory in the TUI. The interface can freeze while the diff rescans the whole root,
even though other expensive features use background channels and spinners.

**Fix:** make history status lazy/backgrounded: load the saved record cheaply, render
"baseline available", and start a `HistoryMsg` worker for diff/save operations. Drop stale
messages by cwd like finding 26. `B` should show "saving baseline..." and remain cancellable
by navigation rather than blocking the event loop.

**Tests:** abstract the history worker so `enter`/`go_up` can be tested without running
`scan_record`; assert navigation schedules work rather than calling diff inline.

**Effort:** ~1 day.

**Status:**

- [ ] Completed
- **Changelog:**

### 31. History baselines and diffs still hide unreadable directories

**Root cause:** `history::scan_record` calls `bulkstat::scan_dir(&entry.path(), 0).size`
and discards `DirScan::inaccessible` (`src/history.rs:183-185`). The saved JSON schema has
no inaccessible field for children, and `--save`/`--diff` JSON/text output has no warning.
Finding 3 fixed the TUI rows plus `--top`/`--reclaim`, but history remains confidently
wrong when TCC or permissions block a subtree.

**Repro:** save a baseline for a directory with an unreadable child. `diskr --save --json`
stores only the readable lower-bound size. Later `--diff` can report shrink/growth against
that lower bound without saying the baseline/current scan skipped directories.

**Fix:** add `inaccessible: u32` to `ChildSize`, `ScanRecord`, JSON serialization, and
`DiffReport` totals. Text output should warn when either side has unreadable descendants;
JSON should expose baseline/current inaccessible counts per child and in totals.

**Tests:** mirror the permission-denied `bulkstat` test at the history layer and assert the
counter survives save/load/diff.

**Effort:** ~half day plus schema compatibility.

**Status:**

- [ ] Completed
- **Changelog:**

### 32. Package list rendering is O(n^2) while allocating on every visible row

**Root cause:** `pkg_item_count` and `pkg_visible_index` rebuild `base_pkg_indices()` every
call (`src/app.rs:1776-1798`). `draw_packages` calls `pkg_item_count()`, then loops
`0..item_count` and calls `pkg_visible_index(visible_i)` for each row. That means rendering
N packages repeatedly allocates/filter-scans an N-sized vector, giving O(n^2) behavior per
frame before the row text work even starts. Search updates also allocate lowercased package
names repeatedly (`src/app.rs:1832-1855`).

**Repro:** install enough global packages or use a large Homebrew tree, then toggle the
packages pane or dependency-leaf filter. The TUI can spend more time rebuilding visible
indices than rendering.

**Fix:** cache visible package indices on state changes (reports loaded, view toggled,
unused filter toggled, search query changed) or at least build `let indices =
app.visible_pkg_indices()` once in `draw_packages`. Store lowercase package/search text in
`Package` or a lightweight UI row cache if search remains sluggish.

**Tests:** add a unit test that counts/filter-caches indices across view/filter/search
transitions. A micro-benchmark is optional; the code shape is enough to prevent O(n^2)
regression.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 33. `diskr --packages` silently accepts nonexistent project paths

**Root cause:** every other path-scoped report validates its path before scanning, but
`print_packages` does not (`src/main.rs:673-756`). It scans global package managers and then
calls `packages::find_project_deps(&path, 5)`, whose first `read_dir` failure simply returns
no project rows (`src/packages.rs:888-890`).

**Repro:** run `diskr --packages /definitely/not/here`. The command exits successfully with
global package information and no indication that the requested project root was invalid.

**Fix:** add the same `exists`/`is_dir` checks used by `print_top` and `print_reclaim`, and
include the canonical project root in JSON output so automation can tell what was scanned.

**Tests:** parser-level or command helper test that invalid package paths return an error.

**Effort:** 15 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

### 34. Project dependency rows double-count one dependency directory when multiple manifests exist

**Root cause:** `find_project_deps_parallel` collects every matching manifest in a directory
and maps each one independently (`src/packages.rs:893-946`). A common Python project has both
`pyproject.toml` and `requirements.txt`; both map to `.venv`, so the packages pane shows two
rows for the same project dependency directory and `total_project_deps_size` sums the same
bytes twice.

**Repro:** create `pyproject.toml`, `requirements.txt`, and `.venv/` in one directory. The
project-deps pane reports two Python rows with identical `.venv` sizes.

**Fix:** group findings by `(project path, deps_dir)` and merge manifests/dep counts into
one row, or choose a precedence order (`pyproject.toml` over `requirements.txt`) when the
dependency directory is the same. The UI label can show `pyproject.toml + requirements.txt`
without double-counting.

**Tests:** fixture with both Python manifests and one `.venv`; assert one project-deps row
and one size contribution.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 35. npm global sizing can use the wrong Node installation under nvm/fnm

**Root cause:** `scan_npm_global` asks the active `npm` for the package list
(`src/packages.rs:568-580`), but `find_npm_global_root` prefers the lexicographically latest
directory under `NVM_DIR`/fnm before falling back to `npm root -g` (`src/packages.rs:613-652`).
If the active shell is using Node 20 while a Node 22 directory exists, package names come
from Node 20 and sizes/paths are looked up under Node 22. Rows then show `?` sizes or wrong
paths, and `f`/`O` can open a package from a different Node version.

**Repro:** install two Node versions with different global packages, select the older one,
and run `diskr --packages`. The package list follows active `npm`, but size lookup can point
at the newer version's `lib/node_modules`.

**Fix:** make `npm root -g` the primary source because it is scoped to the active npm. Use
nvm/fnm directory probing only if that command fails, and record a warning/unknown path
rather than guessing across versions.

**Tests:** unit-test root selection behind an injectable command runner: when `npm root -g`
returns a path, nvm/fnm candidates must be ignored.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 36. Brew cask rows size real apps but still act on the Caskroom stub

**Root cause:** the cask scanner now finds `.app` artifacts and adds their sizes
(`src/packages.rs:479-510`), but the stored `Package.path` remains the Caskroom token
directory (`src/packages.rs:514-519`). `d` was special-cased to call `brew uninstall --cask`,
but detail, Finder reveal, and Open still use `Package.path` via `selected_action_target`.

**Repro:** scan packages with an installed cask such as Firefox. The displayed size includes
`/Applications/Firefox.app`, but `f` reveals `/opt/homebrew/Caskroom/firefox` and `O` opens
the metadata directory rather than the app the user recognizes.

**Fix:** either store a primary artifact path separately (`display_path`/`action_path`) or
set `Package.path` to the app bundle when a concrete `.app` artifact exists while retaining
the Caskroom path for diagnostics. The detail modal should list both when they differ.

**Tests:** extend the cask JSON fixture to create a fake app bundle path and assert the
package action path prefers it while uninstall still uses the token.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 37. Top-files and reclaim-path modals have broken paging/footers for long lists

**Root cause:** `top_files_offset` and `reclaim_path_list_offset` exist but are not used in
rendering. `move_top_files` and `move_reclaim_paths` always move by one row
(`src/app.rs:961-984`), while `PageDown`/`PageUp` in the modal handlers also pass `1`/`-1`
(`src/main.rs:1048-1060`, `src/main.rs:1112-1124`). `draw_top_files` renders the list over
the full modal, then draws a two-line footer into a height-1 area (`src/ui.rs:726-737`), so
the action hint line is clipped.

**Repro:** open top files on a subtree with >20 large files. PageDown advances by one item,
long lists rely on widget-internal state instead of app state, and the footer only shows the
total, not the advertised `f/enter`, `d`, `esc` commands.

**Fix:** split modal layout into list + footer areas, store/maintain offsets with the same
`file_window_bounds` pattern as the files pane, and make PageUp/PageDown move by visible page
height. Do the same for reclaim paths.

**Tests:** pure tests for modal window bounds and page movement; snapshot/render tests are
optional but useful for footer visibility.

**Effort:** ~half day.

**Status:**

- [ ] Completed
- **Changelog:**

### 38. Baseline header text is malformed

**Root cause:** `history_baseline_status` appends "ago" to `format_elapsed(age)`, but
`format_elapsed` already returns strings like `"3m ago"` (`src/app.rs:338-347`). Then
`draw_header` pushes the baseline span without a separator after the hidden-state span
(`src/ui.rs:104-121`). The header can render as `hidden offbaseline saved 3m ago ago`.

**Repro:** save a baseline with `B` and return to a cwd with a baseline. The top header has
no delimiter before the baseline chip and duplicates "ago" for non-zero ages.

**Fix:** change `history_baseline_status` to `baseline saved {format_elapsed(age)}` and
render it as `Span::styled(format!(" · {baseline}"), ...)`.

**Tests:** unit-test `history_baseline_status` formatting and a small header span test if UI
helpers are exposed.

**Effort:** 15 minutes.

**Status:**

- [x] Completed
- **Changelog:** Fixed `history_baseline_status` so it reuses `format_elapsed` without appending a second `ago`, rendered the baseline chip with a leading separator, and added formatting coverage for the saved-baseline status.

### 39. Reverse pane navigation skips Reclaim from Files

**Root cause:** the forward cycle is Files -> Disks -> Packages -> Reclaim -> Files, but
`focus_previous` maps Files -> Packages (`src/main.rs:1535-1542`). Shift-Tab/Left from Files
therefore skips Reclaim and lands on Packages.

**Repro:** press `Tab` until focus returns to Files, then press `BackTab` or `h`. Expected
reverse of the cycle is Reclaim; actual focus is Packages.

**Fix:** change `Focus::Files => Focus::Reclaim` in `focus_previous`, and add a unit test
covering the full forward and backward cycles.

**Effort:** 10 minutes.

**Status:**

- [x] Completed
- **Changelog:** Corrected reverse focus navigation so `BackTab`/left from Files lands on Reclaim, matching the inverse of the forward pane cycle, and added regression coverage for the Files -> Reclaim reverse step.

### 40. README and help text lag the actual TUI

**Root cause:** README's key table still describes the pre-file-ops/pre-reclaim surface
(`README.md:64-82`) and does not mention `c`, `n`, `v`, `a`, `R`, `t`, `B`, `E`, `i`, `u`,
or `x`. `print_help` has more of the new keys (`src/main.rs:292-315`), but it still says
`q, Esc Quit` despite finding 24 and does not explain that `E` empties Trash permanently or
that Reclaim is now part of the Tab cycle. The bottom TUI help remains the one-line
truncating strip from finding 12.

**Impact:** users who install from crates.io see a README that undersells the product and
omits destructive/package-management commands. In-app, the most space-management-specific
features are discoverability traps unless the user reads source or the audit.

**Fix:** update README, `print_help`, and the future `?` overlay (finding 12) from one
shared keymap table. Mark destructive actions separately: Trash move, package uninstall,
Empty Trash, and snapshot thinning should never be presented as ordinary navigation keys.

**Tests:** a lightweight assertion that README/`print_help` contain every key in the shared
keymap table once that table exists.

**Effort:** ~1-2 hours after key behavior decisions in findings 24 and 29.

**Status:**

- [ ] Completed
- **Changelog:**

### 41. Size-sorted scan results can be written to the wrong row

**Root cause:** `entry_index` maps each path to its current `entries` index so
`drain_scan_results` can route a `ScanMsg::DirSize` back to the visible row. The map was
built while reading the directory, before `apply_sort()` reordered `entries`. Later
size-sorted scans can also resort the list mid-scan when a large directory result arrives.
Because `apply_sort()` did not rebuild `entry_index`, the next directory result could be
written to whichever row now occupied the old index.

**User-visible symptom:** a small file such as `todo.py` can display a huge directory size
(for example ~508 MiB) even though its file metadata reports only a few KiB. The file itself
was not being measured wrong; a late directory result was being assigned to the wrong entry
after a sort.

**Fix:** rebuild the path-to-index map inside `apply_sort()` so every sort mode keeps scan
result routing in sync with the current row order.

**Tests:** `dir_size_arriving_after_mid_scan_resort_lands_on_its_own_entry` constructs a
controlled stale-index scenario where the old code would write a 508 MiB `dir_b` scan result
onto `todo.py`; the fixed code leaves `todo.py` at its own 2 KiB logical size and updates
`dir_b`.

**Effort:** small targeted fix plus regression coverage.

**Status:**

- [x] Completed
- **Changelog:** `apply_sort()` now rebuilds `entry_index` after every reorder, preventing mid-scan resorting from routing directory-size results onto unrelated files. Added deterministic regression coverage for the `todo.py` false-size class.

---

## Suggested sequencing

| Phase                       | Items                                        | Theme                             |
| --------------------------- | -------------------------------------------- | --------------------------------- |
| Quick wins (a day)          | 2, 1, 13, 14, 24, 25, 28, 33, 38, 39, 10-clipboard | Small fixes, immediate feel       |
| State safety                | 26, 27, 29, 30, 31                           | Prevent stale/destructive surprises |
| Scan correctness (2-4 days) | 4A → 4C (+22), 5, 18, 3                      | Trustworthy, cancellable scanning |
| Accuracy (2-3 days)         | 6a, 8, 9, 6b, 34, 35, 36                     | Numbers and action targets users can believe |
| Product leap (1-2 weeks)    | 15a → 17 → 15b → 16 → 19 → 15c/d, 20, 12, 11, 37, 40 | The TUI becomes the product       |
| Housekeeping                | 7, 23, 10-shell, 32                          | Decide, simplify, and keep UI fast |

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.