diskr 0.1.49

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
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
1487
1488
1489
1490
1491
1492
1493
1494
1495
1496
1497
1498
1499
1500
1501
1502
1503
1504
1505
1506
1507
1508
1509
1510
1511
1512
1513
1514
1515
1516
1517
1518
1519
1520
1521
1522
1523
1524
1525
1526
1527
1528
1529
1530
1531
1532
1533
1534
1535
1536
1537
1538
1539
1540
1541
1542
1543
1544
1545
1546
1547
1548
1549
1550
1551
1552
1553
1554
1555
1556
1557
1558
1559
1560
1561
1562
1563
1564
1565
1566
1567
1568
1569
1570
1571
1572
1573
1574
1575
1576
1577
1578
1579
1580
1581
1582
1583
1584
1585
1586
1587
1588
1589
1590
1591
1592
1593
1594
1595
1596
1597
1598
1599
1600
1601
1602
1603
1604
1605
1606
1607
1608
1609
1610
1611
1612
1613
1614
1615
1616
1617
1618
1619
1620
1621
1622
1623
1624
1625
1626
1627
1628
1629
1630
1631
1632
1633
1634
1635
1636
1637
1638
1639
1640
1641
1642
1643
1644
1645
1646
1647
1648
1649
1650
1651
1652
1653
1654
1655
1656
1657
1658
1659
1660
1661
1662
1663
1664
1665
1666
1667
1668
1669
1670
1671
1672
1673
1674
1675
1676
1677
1678
1679
1680
1681
1682
1683
1684
1685
1686
1687
1688
1689
1690
1691
1692
1693
1694
1695
1696
1697
1698
1699
1700
1701
1702
1703
1704
1705
1706
1707
1708
1709
1710
1711
1712
1713
1714
1715
1716
1717
1718
1719
1720
1721
1722
1723
1724
1725
1726
1727
1728
1729
1730
1731
1732
1733
1734
1735
1736
1737
1738
1739
1740
1741
1742
1743
1744
1745
1746
1747
1748
1749
1750
1751
1752
1753
1754
1755
1756
1757
1758
1759
1760
1761
1762
1763
1764
1765
1766
1767
1768
1769
1770
1771
1772
1773
1774
1775
1776
1777
1778
1779
1780
1781
1782
1783
1784
1785
1786
1787
1788
1789
1790
1791
1792
1793
1794
1795
1796
1797
1798
1799
1800
1801
1802
1803
1804
1805
1806
1807
1808
1809
1810
1811
1812
1813
1814
1815
1816
1817
1818
1819
1820
1821
1822
1823
1824
1825
1826
1827
1828
1829
1830
1831
1832
1833
1834
1835
1836
1837
1838
1839
1840
1841
1842
1843
1844
1845
1846
1847
1848
1849
1850
1851
1852
1853
1854
1855
1856
1857
1858
1859
1860
1861
1862
1863
1864
1865
1866
1867
1868
1869
1870
1871
1872
1873
1874
1875
1876
1877
1878
1879
1880
1881
1882
1883
1884
1885
1886
1887
1888
1889
1890
1891
1892
1893
1894
1895
1896
1897
1898
1899
1900
1901
1902
1903
1904
1905
1906
1907
1908
1909
1910
1911
1912
1913
1914
1915
1916
1917
1918
1919
1920
1921
1922
1923
1924
1925
1926
1927
1928
1929
1930
1931
1932
1933
1934
1935
1936
1937
1938
1939
1940
1941
1942
1943
1944
1945
1946
1947
1948
1949
1950
1951
1952
1953
1954
1955
1956
1957
1958
1959
1960
1961
1962
1963
1964
1965
1966
1967
1968
1969
1970
1971
1972
1973
1974
1975
1976
1977
1978
1979
1980
1981
1982
1983
1984
1985
1986
1987
1988
1989
1990
1991
1992
1993
1994
1995
1996
1997
1998
1999
2000
2001
2002
2003
2004
2005
2006
2007
2008
2009
2010
2011
2012
2013
2014
2015
2016
2017
2018
2019
2020
2021
2022
2023
2024
2025
2026
2027
2028
2029
2030
2031
2032
2033
2034
2035
2036
2037
2038
2039
2040
2041
2042
2043
2044
2045
2046
2047
2048
2049
2050
2051
2052
2053
2054
2055
2056
2057
2058
2059
2060
2061
2062
2063
2064
2065
2066
2067
2068
2069
2070
2071
2072
2073
2074
2075
2076
2077
2078
2079
2080
2081
2082
2083
2084
2085
2086
2087
2088
2089
2090
2091
2092
2093
2094
2095
2096
2097
2098
2099
2100
2101
2102
2103
2104
2105
2106
2107
2108
2109
2110
2111
2112
2113
2114
2115
2116
2117
2118
2119
2120
2121
2122
2123
2124
2125
2126
2127
2128
2129
2130
2131
2132
2133
2134
2135
2136
2137
2138
2139
2140
2141
2142
2143
2144
2145
2146
2147
2148
2149
2150
2151
2152
2153
2154
2155
2156
2157
2158
2159
2160
2161
2162
2163
2164
2165
2166
2167
2168
2169
2170
2171
2172
2173
2174
2175
2176
2177
2178
2179
2180
2181
2182
2183
2184
2185
2186
2187
2188
2189
2190
2191
2192
2193
2194
2195
2196
2197
2198
2199
2200
2201
2202
2203
2204
2205
2206
2207
2208
2209
2210
2211
2212
2213
2214
2215
2216
2217
2218
2219
2220
2221
2222
2223
2224
2225
2226
2227
2228
2229
2230
2231
2232
2233
2234
2235
2236
2237
2238
2239
2240
2241
2242
2243
2244
2245
2246
2247
2248
2249
2250
# diskr Product & Code Audit (frozen archive)

> **This file is a historical archive — do not update it.** It was the issue
> tracker until 2026-06-12 and its status checkboxes are no longer reliable
> in both directions (findings 37/42 are fixed despite being unchecked;
> findings 12/28 are checked despite the work being absent — see findings
> 55/56). Open findings were migrated to [docs/ISSUES.md](ISSUES.md), which is
> the only live tracker; shipped fixes are recorded in
> [CHANGELOG.md](../CHANGELOG.md). Keep using this file as the detailed
> reference for findings #1-#70 (root cause, repro, fix design, test plans).

Audited at v0.1.20 (2026-06-10), then rechecked through v0.1.44 (2026-06-12).
Findings and completion statuses below were verified against the source at
recheck time, but are frozen as of 2026-06-12.

**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:** Added scan-result salvage and cancellation for interactive directory scans. `DirSize` messages from superseded but still-valid scans now update `size_cache` and matching visible entries instead of being discarded, while cache-invalidating operations advance `min_valid_scan_id`, clear scan UI state, and reject older results so pre-delete/pre-refresh data cannot repopulate the cache. The scanner cancels superseded generations via an `Arc<AtomicU64>` token threaded into the cancellable `bulkstat` scan path, and regression tests cover salvaged stale results, invalidated stale-result rejection, and cancellation.

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

- [x] Completed
- **Changelog:** Added hard-link deduplication to the macOS bulk scanner by requesting `ATTR_CMN_FILEID` and `ATTR_FILE_LINKCOUNT`, then counting multi-link regular files once per `(dev, fileid)` scan identity. The packed `getattrlistbulk` parser now also reads `ATTR_CMN_DEVID`, skips `/System/Volumes/Data` when scanning from an ancestor covered by firmlinks, and avoids descending into different-device mounts under `/Volumes`. Skipped mounted volumes are surfaced through scan results, TUI status/detail text, top-file JSON/text output, and reclaim JSON/text output. Added regression coverage for hard links and the firmlink/mount skip policy while preserving caller-facing top-file paths.

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

- [x] Completed
- **Changelog:** Removed terminal mouse capture from `TerminalGuard` so diskr no longer intercepts mouse input it does not handle. Terminal-native text selection, click-and-drag copy, and scroll-wheel behavior now keep working while the TUI is open, without changing any keyboard navigation or pane state.

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

- [x] Completed
- **Changelog:** Added a cask metadata path alongside the primary package action path. Cask rows now prefer a concrete `.app` bundle for details, Finder reveal, Open, and JSON `path` output when Homebrew reports one, while the detail modal and JSON also expose the Caskroom metadata path. `brew uninstall --cask` still uses the cask token/name, and the cask JSON fixture now asserts the app bundle is preferred without changing uninstall identity.

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

- [x] Completed
- **Changelog:** Added `FileInfo` struct and `collect_file_info` helper that gathers metadata from a single `lstat` + `listxattr` call: file type, logical/allocated size with APFS divergence note, created/modified/accessed timestamps via `MetadataExt`, owner/group names via `getpwuid_r`/`getgrgid_r` with numeric fallback, octal+symbolic permissions (including setuid/setgid/sticky bits), hard-link count, xattr count with `com.apple.quarantine` flagging, and direct child count for directories. Wired `i` in Files focus to `open_file_info` which opens a Cyan-bordered modal (`draw_file_info` in ui.rs) with action keys for Quick Look, Finder reveal, Open, and Trash. Removed duplicate key-match arm for `i` in Files focus. Added regression tests for `permission_string` (special bits), `parse_xattrs` (quarantine detection), and `open_file_info` (metadata collection from selected entry).

---

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

- [x] Completed
- **Changelog:** Cask scans now keep the Homebrew token/name for uninstall while preferring a concrete `.app` artifact as the package action path when Homebrew reports one and the bundle exists. The package detail modal and JSON output expose the Caskroom metadata path separately when it differs from the primary app path, so Finder reveal/Open follow the recognizable app bundle without losing diagnostics. Added cask fixture coverage for app-bundle action targeting and metadata-path retention.

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

- [x] Completed
- **Changelog:**
  - Added cached package visibility slices in `App` so package-pane filtering and selection stop rebuilding index vectors on every row render.
  - Switched package rendering to iterate a single `pkg_visible_indices()` slice per frame instead of repeatedly calling `pkg_visible_index(...)`.
  - Cached lowercase system-package and project-dependency search text, then reused it across search updates to avoid per-keystroke string rebuilding.
  - Added a regression test covering visible-index caching across unused-only filtering, package-view switching, and package search transitions.

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

- [x] Completed
- **Changelog:** npm global package sizing and action paths now resolve from the active `npm root -g` first, so the package list and package filesystem root stay aligned under `nvm`/`fnm`. Version-directory probing is only used as a fallback when the active npm root cannot be read, and ambiguous multi-version `nvm` layouts now leave the package path unknown instead of guessing across Node installs. Added regression tests covering both the active-root precedence and the no-guess behavior for multi-version `nvm` directories.

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

- [x] Completed
- **Changelog:** Cask scans now keep the Homebrew token/name for uninstall while preferring a concrete `.app` artifact as the package action path when Homebrew reports one and the bundle exists. The package detail modal and JSON output expose the Caskroom metadata path separately when it differs from the primary app path, so Finder reveal/Open follow the recognizable app bundle without losing diagnostics. Added cask fixture coverage for app-bundle action targeting and metadata-path retention.

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

### 42. Current tree does not compile after overlapping partial merges

**Root cause:** the current dirty tree has multiple partial implementations of the file-info
work and scan-invalidation work in `src/app.rs`. Active duplicate definitions remain for
`XattrSummary`, `collect_file_info`, `system_time_from_unix`, `permission_string`,
`list_xattrs`, and `parse_xattrs` (`src/app.rs:191`, `src/app.rs:345`,
`src/app.rs:681`, `src/app.rs:686`). The call sites also disagree on the `list_xattrs`
contract: some expect `Option<XattrSummary>` while others destructure it as
`(Option<usize>, bool)` (`src/app.rs:372`, `src/app.rs:713`). Separately,
`invalidate_pending_scan_results` is currently behind `#[cfg(any())]` at
`src/app.rs:2412-2414`, so calls at `src/app.rs:1344` and `src/app.rs:2404` have no active
method. There is also a Darwin libc type mismatch in `file_kind_label` where a `u32` mode is
matched directly against `libc::S_IF*` `u16` constants (`src/app.rs:3625-3632`).

**Repro:** `cargo check --locked` fails with `E0428` duplicate definitions, `E0308`
`list_xattrs`/libc type mismatches, and `E0599` missing
`invalidate_pending_scan_results`. `cargo test --locked` and
`cargo clippy --locked --all-targets --all-features -- -D warnings` fail for the same
reason, so no current validation suite can run.

**Fix:** first coordinate with any agent currently working on findings 20/30/4-style state
work, then collapse `src/app.rs` to one file-info implementation and one xattr helper
contract. Keep a single active `invalidate_pending_scan_results` method, remove
`#[cfg(any())]` dead blocks, and either cast libc file-type constants to `u32` or derive the
kind from `Metadata::file_type()`. After cleanup, run `cargo fmt -- --check`,
`cargo check --locked`, `cargo clippy --locked --all-targets --all-features -- -D warnings`,
and `cargo test --locked`.

**Tests:** the build itself is the regression test here. Add a focused file-info test only
after the duplicate helper set is reduced to one active implementation.

**Effort:** ~1-2 hours if no one else is editing the same partial merge.

**Status:**

- [ ] Completed
- **Changelog:**

### 43. Empty package filters can still act on hidden packages

**Root cause:** `cached_pkg_visible_indices.is_empty()` is used as both "the visibility cache
has not been built" and "the current package filter has zero visible rows." When the
dependency-leaf filter produces no matches, `draw_packages` renders an empty list because it
iterates `pkg_visible_indices()`, but `pkg_item_count` and `pkg_visible_index` fall back to
the full unfiltered package list (`src/app.rs:2821-2843`). Actions that consult
`pkg_item_count`/`pkg_visible_index` can therefore target a package that is not visible.

**Repro:** load package reports where the dependency graph marks every system package as
required or untracked, press `u` to show dependency leaves only, then press `Enter`, `x`, or
`d`. The list can show no visible package rows while detail/uninstall/trash actions resolve
`selected_pkg == 0` against the hidden full package list.

**Fix:** separate cache validity from cache contents. Use `Option<Vec<usize>>`, a
`pkg_visible_cache_valid` bool, or rebuild visible indices eagerly so an empty vector means
"zero visible rows" and never falls back to all packages after packages are loaded. Then make
`pkg_item_count`, `pkg_visible_index`, `pkg_visible_indices`, package detail, uninstall, and
delete share that same source of truth.

**Tests:** build a package report plus dependency graph with zero dependency leaves, toggle
the unused filter, and assert `pkg_item_count() == 0`, `pkg_visible_index(0) == None`, and
`open_pkg_detail`/`request_uninstall` do not arm actions.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 44. `cargo test` can execute the real Empty Trash command

**Root cause:** `fs_ops::empty_trash_runs` calls `empty_trash()` directly
(`src/fs_ops.rs:187-192`). `empty_trash()` shells out to
`osascript -e 'tell application "Finder" to empty trash'` (`src/fs_ops.rs:159-164`), so a
normal test run can permanently empty the user's actual Trash once the compile break in
finding 42 is fixed. The doc comment also says "reversible via Finder" even though emptying
Trash is the permanent step.

**Repro:** after restoring compilation, run `cargo test --locked` on a machine with items in
Trash and Automation permission available. The unit test invokes Finder's real empty-trash
operation; whether it succeeds or fails depends on desktop state, not test fixtures.

**Fix:** remove this test, mark it `#[ignore]` with an explicit manual-only name, or inject a
command runner so tests assert command construction/error handling without calling Finder.
Keep coverage for the TUI confirmation path from finding 29, but never let the default test
suite touch the user's Trash. Update the comment on `empty_trash()` to say the action is
permanent.

**Tests:** add a non-destructive test around a small helper that builds the `osascript`
command, or put `empty_trash` behind a trait/function parameter and test with a fake runner.

**Effort:** ~30 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

### 45. Snapshot thinning accepts raw file/relative paths instead of a validated mount target

**Root cause:** `thin_snapshots` validates only `path.exists()` and then prints or runs
`tmutil thinlocalsnapshots` with the raw argument (`src/main.rs:813-830`). Every other
path-scoped report canonicalizes and rejects non-directories via `canonical_dir`, but
snapshot thinning can accept a regular file, a relative path, or a symlink path and pass it
straight to `tmutil`. The dry-run output can therefore show a command that is not a valid
mount target, and the confirmed run fails late or applies to whatever volume `tmutil`
derives from that raw path.

**Repro:** run `diskr --thin-snapshots 1G ./Cargo.toml` from the repo. The command passes the
existing-file check and prints a dry-run `tmutil thinlocalsnapshots ./Cargo.toml ...` command
instead of rejecting the non-directory path up front.

**Fix:** use the same `canonical_dir` validation as `--space`, then resolve the target volume
through `space::report_for_path` and pass `report.mount` to `thin_local_snapshots`. The
dry-run output should show the resolved mount and include the originally requested path as
context. This also makes symlink/dotted paths deterministic.

**Tests:** CLI helper coverage for a regular-file path should return `path is not a
directory`; a dotted directory path should be canonicalized in dry-run output; a mocked
`space::report_for_path`/thin runner should receive the mount path, not the raw input.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 46. Reclaim totals double-count nested fixed cache categories

**Root cause:** `FIXED_CATEGORIES` includes broad parent paths and specific child paths at
the same time. For example, `User caches` scans `~/Library/Caches`, while `Homebrew cache`,
`pip cache`, `uv cache`, `Go module/build cache`, `Chrome cache`, and `Safari cache` all
scan subdirectories under that same tree (`src/reclaim.rs:50-143`). `fixed_findings`
(`src/reclaim.rs:266-306`) independently sizes every matching category and
`report_with_home` sums all finding sizes into `report.total` without de-duplicating paths.

**Repro:** create a temp HOME with only `Library/Caches/Homebrew/bottle.bin`, then run
`HOME=$tmp target/debug/diskr --reclaim --json "$tmp"`. The report includes both
`Homebrew cache` and `User caches` for the same bytes, and `total_allocated` includes both.
The same inflation happens for pip/uv/browser cache subtrees whenever the scan root includes
`~/Library/Caches`.

**Impact:** the reclaim report can overstate recoverable space and can rank the broad
`User caches` row against more actionable child categories even though deleting the child
already deletes bytes included in the parent. This undercuts the "explain-first cleanup"
promise because the totals are not a disjoint sum.

**Fix:** make fixed findings disjoint before totals are computed. Options:

- Treat `User caches` as a residual bucket by excluding any path claimed by a more specific
  fixed category before scanning/summing it.
- Or keep the broad row for context but mark it as a roll-up and exclude it from
  `report.total` when it contains child findings.
- Keep JSON explicit: add a `rollup: true` or `included_in_total: false` field if roll-up
  rows remain visible.

**Tests:** fixture with `Library/Caches/Homebrew/file` should report either one counted
finding or a total equal to the file size, not parent + child. Add a second fixture with
only `Library/Caches/random-cache/file` so the residual `User caches` row still works.

**Effort:** ~2-3 hours including JSON/text/TUI total semantics.

**Status:**

- [ ] Completed
- **Changelog:**

### 47. Empty Trash is global even when the current reclaim report is not about Trash

**Root cause:** `E` in Reclaim focus always calls `request_empty_trash`, and
`request_empty_trash` arms a global Finder Empty Trash operation even when the current
`reclaim_report` has no `Trash` finding (`src/main.rs:1492-1496`,
`src/app.rs:1683-1695`). The size note is optional, so from a root like `/tmp` or a project
directory the confirmation can say only "Empty Trash permanently?" while the visible reclaim
panel is scoped to that unrelated root.

**Repro:** start diskr in a directory outside `$HOME`, tab to Reclaim, let the scan finish
with no Trash row, press `E`, then `y`. The app empties the user's real `~/.Trash` even
though the current report did not list that path or any reclaimable Trash size.

**Impact:** the action is technically confirmed, but the confirmation is detached from the
visible finding list. A user can believe they are acting on the current scoped reclaim
report while actually performing a global permanent cleanup action.

**Fix:** only enable `E` when the loaded reclaim report contains a `Trash` finding, and make
the modal show that finding's path/count/size. If no Trash finding is loaded, set status to
`Trash is not in this reclaim report` and do nothing. If the product wants a global Empty
Trash shortcut, make it explicit in the modal title/body (`Global Empty Trash`) and show
`~/.Trash` even when the current root does not include it.

**Tests:** construct an app in Reclaim focus with a report that lacks `Trash`, call
`request_empty_trash`, and assert `confirming_empty_trash == false`. A second test with a
Trash finding should assert the confirmation opens and the modal size source comes from that
finding.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 48. Stale background history saves can still overwrite newer baselines

**Root cause:** `start_history_save_request` runs `history::save(&cwd)` inside the worker
thread (`src/app.rs:1361-1373`). `history::save` scans and immediately writes
`history.json` through `store_record` before the UI receives the `HistoryMsg`
(`src/history.rs:89-94`, `src/history.rs:256-270`). The UI later discards stale messages by
`request_id`/`cwd` in `drain_history_results`, but the stale worker's disk write already
happened.

**Repro:** press `B` for a broad directory A, navigate away, then press `B` again for A (or
another directory that shares the same history file) while the first scan is still running.
If the older worker finishes last, it can write an older baseline after the newer one. The
UI may correctly ignore the old message while the persisted baseline on disk has still moved
backward.

**Impact:** history state can become older than what the UI reports, and concurrent
read-modify-write calls to `history.json` can also lose another baseline inserted by a
different worker/process. This is a state-integrity issue in the feature that is supposed to
answer "what changed since the last scan?"

**Fix:** split history save into "scan record" and "store record". The worker should only
return a `ScanRecord`; the main thread should call `store_record` after the request id and
cwd pass validation. If background persistence is still desired, write through a single
history writer queue and reject stale records by timestamp/request id before touching disk.
Also consider atomic temp-file + rename for `history.json`.

**Tests:** expose an in-memory or temp-file save path, send two save results out of order,
and assert only the newest validated record is stored. Add a regression where a stale save
message is ignored before any write happens.

**Effort:** ~half day because `history::scan_record`/`store_record` need a small API split.

**Status:**

- [ ] Completed
- **Changelog:**

### 49. Package-manager scans have no timeout or failure diagnostics

**Root cause:** every package-manager probe uses `Command::output()` through `run_command`
with no timeout and with stderr discarded (`src/packages.rs:1184-1196`). `scan_manager`
marks a manager as `available` based on `command_exists`/`pip_command`, then an empty or
failed command output becomes an empty package list (`src/packages.rs:370-404`). A stuck
`brew`, `npm`, `pip`, `cargo`, or `bun` command leaves the background package scan loading
indefinitely; a failed command can render as "0 packages" instead of "scan failed".

**Repro:** put a fake `npm` earlier in `PATH` that sleeps forever, then open the packages
pane. The package worker never returns and the TUI keeps the loading state/spinner. Put a
fake manager that exits non-zero with stderr, and the UI/report path silently treats it as no
packages.

**Impact:** package inspection is one of diskr's differentiators, but it depends on several
external CLIs with user-controlled shims and environment managers. Without deadlines and
diagnostics, users cannot tell the difference between "no packages", "manager failed", and
"diskr is still waiting on a hung command".

**Fix:** replace `run_command -> String` with a small result type containing stdout, stderr,
exit status, and timeout/error. Implement per-command deadlines (for example 5-10 seconds
for list/info commands, maybe longer for Homebrew cask metadata) by spawning and polling or
using a helper thread/process-kill wrapper. Surface manager-level warnings in
`ManagerReport` and render them in the package pane/JSON.

**Tests:** inject a fake command runner for package scans. Cover success, non-zero exit with
stderr, command-not-found, and timeout so reports preserve the manager name and a clear
diagnostic without blocking the test suite.

**Effort:** ~1 day if command execution is made injectable first.

**Status:**

- [ ] Completed
- **Changelog:**

### 50. PageUp/PageDown uses the file-pane height in every focus

**Root cause:** normal-mode PageUp/PageDown always calls `app.page_move`, and `page_move`
uses `self.files_area.height` regardless of the active focus (`src/main.rs:1296-1302`,
`src/main.rs:1345-1350`, `src/main.rs:1390-1396`, `src/app.rs:867-870`). That is correct
only for the Files pane. Package, disk, and reclaim navigation all move by the left file
list's visible height rather than their own visible row counts.

**Repro:** load enough packages or reclaim findings to overflow their panels, focus that
pane, and press PageDown. The selection jumps by the file-pane height (often much larger
than the side-panel list), can wrap around because `move_cursor` uses modulo arithmetic, and
does not correspond to one visible page of the active pane.

**Impact:** keyboard paging is unpredictable outside Files and can skip or wrap over rows in
the areas where users are making package/uninstall or reclaim/delete decisions. It is
especially confusing because modal paging was fixed separately, so PageDown behaves
differently in a modal versus the base package/reclaim pane.

**Fix:** store per-pane visible row counts/areas during render (`disks_area`,
`packages_area`, `reclaim_area` or direct page-row fields) and make `page_move` branch by
focus. For Disks, use the number of rendered disk cards. For Packages, use package list
height. For Reclaim, use the reclaim findings list height. Consider clamping rather than
wrapping for page moves while keeping single-step movement cyclic if desired.

**Tests:** unit-test `page_move` with focus set to Files, Packages, Disks, and Reclaim using
known page heights. Assert PageDown advances by the active pane's page size and does not
wrap unexpectedly when fewer than one page remains.

**Effort:** ~2-4 hours depending on how much pane geometry is exposed from `ui.rs`.

**Status:**

- [ ] Completed
- **Changelog:**

---

## Additional v0.1.47 Recheck Findings (2026-06-12)

Verified against commit `a617a79` (v0.1.47): `cargo fmt --check`, `cargo clippy -D warnings`
pass, and `cargo test --locked -- --skip empty_trash_runs` passes 124 tests (the skip avoids
the destructive test from finding 44, which is still present at `src/fs_ops.rs:188`).

### 51. Key handlers ignore modifiers, so Ctrl+C triggers rename and Ctrl+D arms delete

**Root cause:** no key handler in the event loop reads `key.modifiers` (`grep modifiers
src/*.rs` has zero hits). In raw mode crossterm delivers Ctrl+C as
`KeyEvent { code: Char('c'), modifiers: CONTROL }`, and every `KeyCode::Char(..)` arm in
`src/main.rs` matches on `key.code` alone. Normal mode therefore maps Ctrl+C to rename
(`'c'`, `src/main.rs:1456`), Ctrl+D to the delete confirmation (`'d'`), Ctrl+R to a full
rescan, Ctrl+V to mark, etc. In search/filter/rename input modes, `KeyCode::Char(ch)`
pushes the plain letter into the buffer, so Ctrl+C while renaming types a `c` instead of
cancelling.

**Repro:** open diskr, press Ctrl+C (terminal muscle memory for "abort"). The rename prompt
opens for the selected entry. Press Ctrl+D instead: the Trash confirmation arms.

**Impact:** the most common "get me out of here" chord performs file-management actions,
including arming a destructive confirmation. This also blocks ever using modifier chords
deliberately.

**Fix:** at the top of the `Event::Key` handling (or per match), ignore character keys when
`key.modifiers` intersects `CONTROL | ALT | SUPER` (allow `SHIFT`, which is how uppercase
`S`/`O`/`B`/`E` arrive). Recommended explicit behavior: Ctrl+C cancels the active
mode/modal like Esc (or quits from normal mode, matching terminal convention).

**Tests:** the event loop is not currently test-covered; at minimum, factor a
`fn key_action(key: KeyEvent, app_mode: ...)` mapping that can be unit-tested with
CONTROL-modified chars asserting "ignored/cancel", and plain chars asserting today's
behavior.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 52. Package filter cannot contain the letters `j` or `k`

**Root cause:** in `pkg_search_mode`, the arms `KeyCode::Down | KeyCode::Char('j')` and
`KeyCode::Up | KeyCode::Char('k')` (`src/main.rs:1337-1344`) are matched before
`KeyCode::Char(ch) => pkg_search_push(ch)`, so typing `j`/`k` moves the selection instead of
extending the query. File search (`src/main.rs:1274-1316`) gets this right: only arrow keys
navigate while a search is being typed.

**Repro:** focus Packages, press `/`, try to type `jq`, `just`, `kubectl`, or `jest`. The
query stays empty/partial and the selection jumps instead.

**Fix:** remove `Char('j')`/`Char('k')` from the pkg-search navigation arms so they fall
through to `pkg_search_push`, exactly like file search. Navigation stays on arrows while
typing.

**Tests:** unit-test `pkg_search_push('j')` reachability is trivial once the key mapping is
extracted (see finding 51); otherwise assert in a small integration-style test that a
query of "jq" filters `cached_pkg_search_text` as expected.

**Effort:** 10 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

### 53. Search/filter advertises "Enter keep" but Enter clears the filter exactly like Esc

**Root cause:** the status line renders `· Enter keep · Esc clear` for both file search and
package filter (`src/ui.rs:1461`, `src/ui.rs:1484`), but the handlers call the same exit
function for both keys: `Esc => exit_search()` and `Enter => exit_search()`
(`src/main.rs:1276-1283`), and likewise `exit_pkg_search()` for both in pkg mode
(`src/main.rs:1325-1332`). `exit_search`/`exit_pkg_search` clear the query and matches
(`src/app.rs:2686`, `src/app.rs:2556`), so the filtered view is always discarded.

**Repro:** press `/`, type a query that narrows the list, press Enter. The full unfiltered
list returns; only the selection survives. The promised difference between Enter and Esc
does not exist.

**Impact:** the UI promises a "keep the filtered view" mode that users of ncdu/fzf-style
filters expect, and the actual behavior contradicts the on-screen help. Working on a
filtered subset (e.g. mark-all + batch delete of `*.log`) is impossible.

**Fix:** introduce a kept-filter state: on Enter, leave `search_query`/`search_matches`
intact but exit input mode (stop routing chars to the query); render a `filter: foo` chip in
the files title or status; Esc (or `/` then Esc) clears the kept filter. The visible-entry
mapping already keys off `search_query.is_empty()`, so the minimal version is a
`filter_pinned: bool` plus keeping the query on Enter. `mark_all_visible` already operates
on visible entries, which makes kept filters immediately useful. Mirror for the package
filter.

**Tests:** enter search, push chars, Enter; assert `visible_entry_count()` still reflects
the filter and typing `j`/`k` now navigates. Esc afterwards restores the full list.

**Effort:** ~2-3 hours including pkg pane parity.

**Status:**

- [ ] Completed
- **Changelog:**

### 54. A panic leaves the terminal in raw mode with the message invisible (and release builds abort past the guard)

**Root cause:** terminal restoration relies solely on `TerminalGuard`'s `Drop`
(`src/main.rs:947-966`). There is no `std::panic::set_hook`. In dev builds a panic unwinds
and the guard runs, but the panic message is printed inside the alternate screen, which
`LeaveAlternateScreen` then discards — the user sees the app vanish with no error. In
release builds `panic = "abort"` (`Cargo.toml:28`) means `Drop` never runs at all: the
terminal is left in raw mode + alternate screen and needs a blind `reset`. Any
`unwrap`/`expect` or index slip anywhere in the TUI turns into a corrupted terminal with
zero diagnostics.

**Repro (dev):** insert a `panic!("boom")` in a key handler and press the key — the app
exits with no visible message. **(release):** same, plus the shell is left raw.

**Fix:** in `run_app`, before entering the TUI, install a panic hook that (1) disables raw
mode, (2) leaves the alternate screen, then (3) calls the previous default hook so the
message lands on the normal screen. Panic hooks run before abort, so this also covers
`panic = "abort"`. The standard ratatui pattern is ~10 lines. Optionally also catch
SIGTERM/SIGHUP for the same restore (crossterm exposes no helper; a minimal
`libc::signal` handler or accepting the gap is fine — the panic hook is the important
part).

**Tests:** manual; or a small `#[ignore]`d test that spawns the binary with an injected
panic env var and asserts stderr contains the panic message.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 55. Finding 28's rename re-selection fix is marked complete but absent from the tree

**Root cause:** finding 28 is checked `[x]` with a changelog naming a regression test
`rename_reload_selects_new_path_after_sorting`. Neither exists in the current tree: the
rename commit path still calls plain `self.reload()?` (`src/app.rs:2791`), which tries to
restore the *old* (now nonexistent) path and falls back to the previous index, and no
rename test exists anywhere in `src/app.rs`'s test module. The fix appears to have been
lost in one of the overlapping partial merges (the same class of event as finding 42), and
the suite passes because the test vanished along with the fix.

**Repro:** finding 28's original repro still reproduces — sort by name, rename `z.txt` to
`a.txt`; the selection stays at the old row index, so the next `d`/`Enter` targets a
neighbor.

**Fix:** re-apply: after a successful `std::fs::rename`, call
`self.reload_with_selection(Some(new_path.clone()), previous_index)` (the helper exists at
`src/app.rs:682`), and re-add the named regression test so the fix cannot silently
disappear again. Do not edit finding 28's historical entry; close this finding instead.

**Tests:** restore `rename_reload_selects_new_path_after_sorting` exactly as described in
finding 28.

**Effort:** ~30 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

### 56. Finding 12 is marked complete but the `?` help overlay does not exist; changelog corruption on 12/21

**Root cause:** finding 12 ("Help is one unwrapped, truncating line") is checked `[x]`, but
its changelog text is an unrelated copy-paste about cask metadata paths, and none of the
described work exists: there is no `Char('?')` binding anywhere in `src/main.rs`, no
`show_help` state, and `draw_help` (`src/ui.rs:1500-1580`) still renders ~23 hints in a
single height-1 line — now *longer* than when the finding was filed, so on a typical
100-column terminal everything from roughly `p packages` onward (including `d trash`,
`q quit`) is cut off. Finding 21's changelog has the same pasted cask text (its actual fix
— removing mouse capture — did land via finding 7, so only the record is wrong there).

**Impact:** the audit file can no longer be trusted as a completion record for these
entries, and the original discoverability problem is worse: the one place in the TUI that
lists keys truncates before the destructive ones.

**Fix:** implement finding 12 as originally specified (`?` modal via the existing
`centered_rect` + `Clear` pattern, grouped sections, shrink the bottom strip to ~8 hints
ending in `? help`), keyed off a shared keymap table so finding 40 can reuse it. Leave the
historical entries untouched per audit policy; record the real changelog here. When
verifying future completions, check that the changelog text actually describes the finding
it sits under.

**Tests:** content test that every key in the shared keymap appears in the overlay data;
visual pass on a narrow terminal.

**Effort:** ~2-3 hours (unchanged from finding 12's estimate).

**Status:**

- [ ] Completed
- **Changelog:**

### 57. Landing the cursor on an unsized directory cancels the in-flight batch scan and silently clears its spinners

**Root cause:** `Scanner::scan_all` bumps the shared cancellation generation on every call
(`src/scanner.rs:57`), so starting any new scan cancels the previous one mid-walk. On top
of that, `start_scan` rewrites the `scanning` flag for *every* entry from the new scan's
dir-set (`src/app.rs:2176`), clearing spinners for directories belonging to the superseded
scan. `scan_selected_missing_dir` fires on every cursor landing on an unsized,
not-currently-scanning directory. The guard `entry.scanning` protects dirs inside the
current scan, but any dir *outside* it (e.g. the 5th-12th dirs that `auto_scan`'s
`AUTO_SCAN_LIMIT = 4` did not include) triggers a new generation.

**Repro:** open a directory with 8+ unsized subdirectories. `auto_scan` starts 4 with
spinners and the status reads "scanning: 4/8". Move the cursor onto the 5th unsized dir:
the 4 spinners vanish, the batch is cancelled mid-walk (partial work discarded — the salvage
from finding 4A only rescues results already sent), and only the 5th dir scans. The final
status says "scan complete" even though 3 of the original 4 never finished.

**Impact:** the cancel mechanism built for finding 4 over-fires: ordinary cursor movement
discards real scan work, the UI lies about completion, and dirs you passed over re-scan
from scratch when you return. This is the user-visible cost of not having finding 4C's
queue.

**Fix:** short of 4C, make generations additive rather than global: only
`invalidate_pending_scan_results` (data-invalidating events) should cancel; a
navigation-triggered `start_scan` should *merge* — e.g. keep per-scan cancellation tokens
(`Scanner` hands back the token per `scan_all` and only `cancel_current` bumps it), and in
`start_scan` set `scanning` only for the new dirs (`entry.scanning |= ...`) instead of
overwriting all flags, keeping `active_scan_paths` as a union with per-path bookkeeping.
Alternatively implement 4C's single queue, which makes this whole class go away.

**Tests:** start a 4-dir scan, call `start_scan` for a 5th dir, assert the first scan's
cancellation token is not cancelled and the 4 original entries still have
`scanning == true`.

**Effort:** ~half day standalone; free with 4C.

**Status:**

- [ ] Completed
- **Changelog:**

### 58. History diffs and modal scans spawn unguarded, uncancellable background walks that pile up

**Root cause:** several one-shot workers have no concurrency guard and no cancellation:

- `apply_history_baseline` starts a full-tree diff worker on *every* navigation into a
  baselined cwd (`src/app.rs:633-642` → `start_history_diff_request`, `src/app.rs:1344`)
  with no `history_loading` check. `history::diff_from_record` re-sizes every immediate
  child recursively. Navigating in and out of `~` (with a `~` baseline) three times quickly
  leaves three concurrent whole-home walks running; superseded workers' results go to a
  dropped channel but the I/O continues to completion, competing with the interactive
  scanner. `refresh_history_state` also runs after every delete/rename/mkdir, so each
  confirmed delete triggers another full-tree diff walk.
- `open_top_files_for_path` while a previous scan is loading just drops the old receiver
  (`src/app.rs:1747-1752`); `close_top_files` (Esc) does the same — the orphaned
  `bulkstat::scan_dir(path, 50)` keeps walking (e.g. `~/Library`) with no way to stop it.
  The same pattern applies to reclaim scans (`request_reclaim_scan`, guarded against
  *concurrent* scans but uncancellable once started) and disk-info workers.

**Repro:** save a baseline for `$HOME` (`B`), then bounce between `~` and a subdirectory a
few times while watching CPU/disk in Activity Monitor: multiple `diskr` worker threads walk
the full home tree simultaneously. Or press `t` on `~/Library`, Esc immediately, press `t`
on a small dir — the Library walk continues in the background.

**Fix:** (a) add the missing guard: skip starting a new history diff when
`history_loading` and the request cwd is unchanged; (b) thread `ScanCancellation` (already
exists, `src/bulkstat.rs:92`) through `history::scan_record`, the top-files scan, and the
reclaim walk, cancelling on supersede/close/navigation; (c) consider reusing fresh
`size_cache` results for the history diff instead of an independent walk — the visible pane
often just scanned the same children.

**Tests:** assert `apply_history_baseline` does not spawn when a request for the same cwd
is in flight; assert `close_top_files` cancels its token (observable via a probe
cancellation struct).

**Effort:** guard: ~30 minutes; cancellation threading: ~half day.

**Status:**

- [ ] Completed
- **Changelog:**

### 59. Batch-delete confirmation size ignores marked files (only cached directory sizes count)

**Root cause:** `request_batch_delete` sums `self.size_cache.get(p)` over marked paths
(`src/app.rs:2929-2934`), but `size_cache` only ever contains *directory* scan results.
Marked files — the common case for `v`+`d` cleanup — contribute zero, so the modal shows
"Trash 12 items?" with no size, or a size counting only the marked directories. Entries
already carry file sizes from metadata (`Entry.size`), and the finding-16 design called for
summing from entries with a `≥` marker when unsized dirs make the total a lower bound.

**Repro:** mark three multi-GB files with `v`, press `d`. The confirmation shows no size at
all (`total_size == 0` suppresses the parenthetical), even though every size is visible in
the list behind the modal.

**Fix:** resolve each marked path through `entry_index`/`entries` first (file and dir sizes
both live there), falling back to `size_cache` for paths no longer visible; render `≥` when
any marked dir has `size == None` or `inaccessible > 0`. Cross-reference findings 16/27 —
the name preview landed, the size never did.

**Tests:** mark two files with known sizes in a temp dir, `request_batch_delete`, assert
the status contains the summed size; include an unsized directory and assert the `≥`
marker.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 60. Package detail modal shows an unrelated system package in Projects view

**Root cause:** `selected_pkg_detail` always resolves the visible index against
`cached_flat_packages` (`src/app.rs:2461-2475`), but in `PkgView::ProjectDeps` the visible
indices index into `project_deps`. `open_pkg_detail` only checks `pkg_item_count() > 0`
(`src/app.rs:2398`), and both `Enter` and `i` open it regardless of view
(`src/main.rs:1407`, `src/main.rs:1540`). With the Projects view active, row N's detail
modal therefore displays the Nth flat *system* package (sorted by size) — wrong name,
size, path, and dependency info — or renders an invisible-but-key-capturing modal when the
flat list is shorter than N.

**Repro:** load packages, switch to Projects (`l`), select any project row, press `i` or
Enter. The modal titles itself with e.g. the largest brew formula instead of the project.

**Impact:** flatly wrong information next to destructive actions; the modal's `d` hint
("trash dir") acts on the project dep dir while the displayed identity is a brew package.

**Fix:** either give ProjectDeps its own detail rendering (project path, manifest, dep
count, deps dir, size — all already on `ProjectDeps`), or make `open_pkg_detail` a no-op
outside `SystemManagers` view with a status hint. Guard `selected_pkg_detail` to return
`None` when `pkg_view != SystemManagers`.

**Tests:** app with one project dep and several flat packages; switch view, open detail,
assert it is project-specific (or refuses) rather than `cached_flat_packages[real_idx]`.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 61. Reclaim pane status line reflects the paths-modal selection, not the selected finding

**Root cause:** `selection_status` for `Focus::Reclaim` calls `app.selected_reclaim_path()`
(`src/ui.rs:2041`), which reads `findings[reclaim_paths_finding].paths[reclaim_paths_selected]`
— state owned by the *paths modal*. While browsing the findings list (modal closed),
`reclaim_paths_finding` still holds whatever finding was last opened (initially 0), so the
status line names the first path of an unrelated finding while the cursor sits on a
different one. The same stale lookup feeds Space/f/O via `selected_action_target`
(`src/main.rs:915`), so Quick Look/reveal in the findings list act on a path from the
previously opened finding rather than the selected row.

**Repro:** focus Reclaim with 3+ findings, open paths for finding 1 (`Enter`), Esc, move the
selection to finding 3. The status line (and `f`) still reference finding 1's first path.

**Fix:** when the paths modal is closed, derive both status and action target from
`selected_reclaim_finding()` (label, class, size, count, first path); only use
`selected_reclaim_path()` while `reclaim_paths_open`. Alternatively reset
`reclaim_paths_finding = selected_reclaim` whenever the findings selection moves.

**Tests:** build a report with two findings, select finding 1 without opening paths, assert
the status/action target reference finding 1 and not finding 0.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 62. Reclaim finding detail panel is drawn on top of the findings list, hiding middle rows

**Root cause:** `draw_reclaim_panel` renders the findings list in a
`centered_rect(70, 70, 72, 22)` overlay and then unconditionally draws the always-on
"reclaim finding" detail box in `centered_rect(70, 28, 72, 9)` (`src/ui.rs:411`,
`src/ui.rs:529`). Both rects are vertically centered, so the detail box sits exactly over
the middle of the list. On a 40-row terminal the list occupies rows ~6-33 and the detail
rows ~14-25: list rows 8-19 are permanently obscured. With more than ~7 findings the
selection highlight scrolls *underneath* the detail box and the user cannot see which row
is selected — while `d` remains armed for path deletion from that hidden context.

**Repro:** produce a reclaim report with 10+ findings (several project artifact types plus
fixed caches), focus Reclaim, press `j` repeatedly: the highlight disappears behind the
detail box mid-list and reappears below it.

**Fix:** split the overlay into a vertical layout (list `Min(n)`, detail `Length(8)`) inside
one block instead of two stacked centered rects — the same `Layout` pattern
`draw_reclaim_paths` already uses for its footer. The detail content stays identical.

**Tests:** geometry unit test that the list rows and detail rows do not intersect for
representative terminal sizes.

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

**Status:**

- [ ] Completed
- **Changelog:**

### 63. Persistent state writes are non-atomic and history load failures are silent

**Root cause:** both persistence layers write with a bare `std::fs::write`:
`size-cache.json` (`src/state.rs:81`) and `history.json` (`src/history.rs:266`). A crash,
power loss, or full disk mid-write leaves a truncated JSON file. The size-cache loader then
fails and the app starts with "size cache ignored: …" (acceptable signal, total cache loss),
but the history loader's failure is swallowed entirely — `refresh_history_state` does
`history::load_record_for_path(&self.cwd).unwrap_or(None)` (`src/app.rs:629`), so a corrupt
`history.json` silently presents as "no baselines exist anywhere" with no hint to the user
that saved data is unreadable. Two concurrent diskr instances also clobber each other's
writes wholesale (size cache: last quit wins; history: finding 48 covers the
read-modify-write race for ordering, but the torn-file risk is this finding).

**Repro:** truncate `~/Library/Application Support/diskr/history.json` mid-object; launch
diskr in a previously baselined directory. The header shows no baseline chip and `B`
"works", silently replacing the whole file's content with one entry on the next full
rewrite.

**Fix:** write via temp file + `rename` in the same directory for both files (atomic on
APFS); surface history load errors once in status ("history unreadable: … (delete to
reset)") the way the size cache already does. Optionally take an advisory `flock` on the
state dir while writing to serialize concurrent instances.

**Tests:** point `state_dir` at a temp dir (helper already exists for the size-cache path
variants), write garbage history.json, assert `load_record_for_path` returns an error that
the app surfaces rather than `None`; assert store goes through a temp+rename (observable
by filename or by injecting the write path).

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

**Status:**

- [ ] Completed
- **Changelog:**

### 64. Mount-boundary policy misses non-/Volumes mounts and counts APFS helper volumes when scanning /

**Root cause:** `should_skip_subdir` only refuses to cross device boundaries for paths under
`/Volumes` (`src/bulkstat.rs:537-542`). Consequences:

- Scanning `/` descends into `/System/Volumes/Preboot`, `/System/Volumes/VM` (swap files,
  multi-GB), `/System/Volumes/Update`, etc. — separate APFS volumes whose contents are then
  attributed to the system-volume total. The number diskr shows for `/` exceeds what
  Finder/diskutil report for Macintosh HD by the helper-volume sizes, with no marker.
- Network/FUSE mounts outside `/Volumes` (SMB mounted at a custom path, sshfs in `~/mnt`)
  are walked and counted as local data — misleading for a "free up disk space" decision and
  a hang risk on slow or wedged network filesystems (uncancellable in the non-interactive
  paths, see finding 58).

**Repro:** `diskr --top 5 /` on a machine with swap in use; compare `total_allocated`
against `diskutil info /` used space — the delta tracks `/System/Volumes/VM` +
`Preboot`. For the mount case, `mount_smbfs //server/share ~/mnt && diskr --top 5 ~` walks
the share.

**Fix:** the firmlink design (finding 6b) requires allowing exactly two devices: the scan
root's dev and the data volume's dev. Resolve the data-volume devid once per scan
(`symlink_metadata("/System/Volumes/Data")`) and skip any subdir whose devid matches
neither, anywhere in the tree — counting each skip in `skipped_mounts` (already surfaced in
UI/JSON). Keep the existing `/System/Volumes/Data` self-skip. If helper volumes should stay
countable for "whole container" curiosity, gate the stricter policy on scan root != `/` or
add them as their own surfaced rows rather than silently folding them in.

**Tests:** extend the `ScanPolicy` unit tests: a subdir with a third devid outside
`/Volumes` must be skipped; a data-volume devid must not be.

**Effort:** ~half day including verification against Finder numbers.

**Status:**

- [ ] Completed
- **Changelog:**

### 65. Scan totals silently exclude symlinks, directory entry blocks, and per-entry stat errors

**Root cause:** `scan_one_dir` counts only `VREG` entries (`src/bulkstat.rs:460-508`).
Symlink inodes (logical = target-path length, plus allocated blocks), directory entries'
own allocated blocks, and special files contribute nothing, and an entry whose attributes
the kernel could not read (`ATTR_CMN_ERROR`, `src/bulkstat.rs:457-459`) is skipped without
incrementing `inaccessible`. `du` counts directory blocks and symlinks, so diskr
systematically undercounts relative to `du -A`/Finder on trees with many directories
(node_modules-style trees can differ by hundreds of MB from directory blocks alone).

**Repro:** `mkdir -p t/{a..z}/{a..z}; diskr --top 1 t` vs `du -sk t` — diskr reports ~0
while du reports the directory blocks.

**Fix:** request `ATTR_FILE_ALLOCSIZE`-equivalents for directories (dirs return
`ATTR_DIR_*`; simplest is counting `allocsize` when present regardless of objtype, or
adding the parent dir's own `lstat` blocks once per `scan_one_dir`), add `VLNK` logical
sizes, and count `err != 0` entries as inaccessible. Alternatively, document the policy
("regular file content only") in README's How-it-works so the discrepancy is explained
rather than discovered.

**Tests:** fixture with empty nested dirs asserting nonzero allocated; symlink fixture
asserting its size counts once.

**Effort:** ~2-3 hours (or 15 minutes for the documentation-only option).

**Status:**

- [ ] Completed
- **Changelog:**

### 66. Cache invalidation never removes descendants, leaving resurrectable stale sizes

**Root cause:** `invalidate_cache_for` removes the path itself and walks *ancestors*
(`src/app.rs:2089-2097`), but never descendants. Deleting or renaming a directory leaves
all its children's entries in `size_cache`/`cache_age` under now-dead paths. Effects:
(a) dead entries consume the 50k LRU budget and persist to `size-cache.json`; (b) if the
same path is later recreated (re-clone a repo, re-download a folder), `rebuild_entries`
serves the old size from cache within the same session *without* a stale marker — the `~`
marking only applies to entries loaded from disk at startup (`src/app.rs:732`). Related:
`force_rescan` invalidates only the visible directories (`src/app.rs:1024-1031`), so after
`r` the freshly scanned parent can disagree with still-cached child sizes shown when
navigating into it, again unmarked.

**Repro:** scan `proj/` so `proj/node_modules` is cached; delete `proj` via `d`; recreate
`proj/node_modules` with different content; navigate in — the old size renders as fresh.

**Fix:** in `remove_cached_size`/`invalidate_cache_for`, also retain-filter every cached
path with `path.starts_with(invalidated)` (one pass over the map; deletes are rare).
For the `r` coherence gap, either prefix-invalidate the visible dirs' subtrees or mark
descendant entries stale instead of removing them.

**Tests:** cache parent+child, invalidate parent, assert child entries are gone from all
four cache maps and `size_cache_dirty` is set.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 67. pip sizing mixes two Python environments and can match the wrong dist-info

**Root cause:** three related accuracy gaps in the pip path:

- The package *list* comes from `pip3` (or `python3 -m pip` fallback) via
  `run_pip_command` (`src/packages.rs:724`), but the site-packages root always comes from
  plain `python3 -c "import site; …"` (`src/packages.rs:1390-1405`). On machines where
  `pip3` belongs to Homebrew's python while `python3` is the Xcode CLT one (or any
  pyenv/asdf split), names are listed from one environment and sized against another —
  rows show `?` sizes or, worse, sizes/paths of a same-named package from the other
  install. Same class of bug as finding 35 (npm/nvm).
- Only `site.getsitepackages()[0]` is consulted; `pip install --user` packages live in
  `site.getusersitepackages()` and always size as `?` even though pip listed them.
- `find_pip_dist_info` accepts any dist-info whose normalized prefix is
  `<name>-<anything>` (`src/packages.rs:1268-1276`); with both `sentry` and `sentry-sdk`
  installed, a lookup for `sentry` can return `sentry-sdk-2.0.dist-info` depending on
  directory iteration order, attributing the wrong RECORD's sizes.

**Repro:** (env mismatch) `brew install python` providing `pip3` while `python3` resolves
to `/usr/bin/python3`, then `diskr --packages`; (prefix) install `sentry-sdk` plus any
distribution literally named `sentry` and compare the two rows' sizes. (user-site)
`pip3 install --user requests` → `?` size.

**Fix:** derive both list and paths from the same interpreter: resolve the interpreter
behind the chosen pip backend (`pip3 -c` is not a thing, but
`python3 -m pip` ↔ `python3`; for `pip3`, parse `pip3 --version`'s reported python path or
shell out via that python). Query `site.getsitepackages()` *plus* `getusersitepackages()`
and search dist-infos across all roots. In `find_pip_dist_info`, require the character
after `name-` to be a digit (version) instead of accepting any suffix.

**Tests:** dist-info fixture with `sentry-2.0.dist-info` and `sentry-sdk-2.0.dist-info`
asserting exact-name resolution; user-site fixture via an injected roots list.

**Effort:** ~half day.

**Status:**

- [ ] Completed
- **Changelog:**

### 68. History diffing is O(n²) and history.json is fully re-parsed on every navigation

**Root cause:** `diff_records` does a linear `find` over `before.children` for every
`after` child plus a second quadratic pass for removals (`src/history.rs:118-146`) — fine
for 50 children, but a maildir/photos directory with 50k immediate children turns each
(now background, finding 58) diff into ~2.5B comparisons. Separately, every `enter`/
`go_up`/delete/rename calls `refresh_history_state` → `load_record_for_path`
(`src/app.rs:628-631`, `src/history.rs:244`), which reads and JSON-parses the *entire*
history file (all baselines for all paths) just to look up one key.

**Repro:** save baselines for a handful of large directories, then navigate around the TUI
with a history file of a few MB — every directory change re-parses it; diff a directory
with tens of thousands of children and watch the diff worker burn CPU beyond the scan
itself.

**Fix:** build a `HashMap<&str, SizeInfo>` over `before.children` for the diff (10-line
change, keeps `diff_records` pure); cache the parsed history map in `App` and invalidate it
when a save completes (or just memoize per cwd with the file's mtime).

**Tests:** existing `diff_records` tests cover behavior; add a larger generated-children
test if regression protection is wanted.

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

### 69. README factual claims have drifted (beyond the key-table lag in finding 40)

**Root cause:** README's "How it works" states "diskr is ~6,000 lines of Rust with no
runtime beyond `ratatui`, `crossterm`, and `libc`" (`README.md:88`). The tree is ~14,000
lines, and `Cargo.toml` also depends on `rayon`, `serde_json`, and `anyhow` — and the
sentence "no allocation per entry, no serde" sits next to a serde_json dependency used for
every JSON report and cache file. The Keys table (`README.md:59-84`) and `print_help`
(`src/main.rs:296-327`) additionally drifted in opposite directions from finding 40's
snapshot: README lacks `c n v a R t B E u x i` rows that `--help` has, while `--help` lacks
`y` (copy path) and `s` (open Terminal) that README documents, and README's Tab row still
says "files/disks/packages" without Reclaim.

**Impact:** the README is the crates.io face of the project; stale architecture claims and
two help surfaces that disagree with each other (and with the truncating in-app strip,
finding 56) undermine the "honest README" the original audit praised.

**Fix:** fold into finding 40's shared-keymap work; additionally correct the line count
(or drop the number), list the real dependency set, and reword "no serde" to describe the
scanner hot path specifically.

**Tests:** the finding-40 assertion (every key in the shared table appears in README and
`print_help`) covers the keys half; the prose needs an editing pass only.

**Effort:** ~30 minutes on top of finding 40.

**Status:**

- [ ] Completed
- **Changelog:**

### 70. Minor UX and code-hygiene paper cuts (grab-bag)

Each item is independently small; fix opportunistically or split out when touched.

- **`r` in Reclaim focus rescans the files pane behind the overlay** instead of the reclaim
  report (`src/main.rs:1444-1451` routes everything non-Packages to `force_rescan`); `R`
  does the reclaim rescan. Route `r` to `request_reclaim_scan` when focus is Reclaim.
- **`p` bypasses `set_focus`** (`src/main.rs:1517-1520` sets `app.focus` directly), so the
  status line is not cleared the way Tab focus changes clear it (`src/main.rs:1634-1645`).
- **Tab-cycling through Packages triggers a full package-manager scan** as a side effect
  (`set_focus` → `load_packages`, `src/main.rs:1642-1644`): passing through the pane to
  reach Reclaim shells out to brew/npm/pip/cargo/bun. Consider loading on first
  interaction/dwell instead of on transit focus.
- **`change_status` mislabels logical-only shrink as "grew"**: a change with
  `delta_allocated() == 0` and negative `delta_logical()` reports "grew"
  (`src/main.rs:606-613`).
- **Scanning rows drop the bar column entirely** (`src/ui.rs:264` requires `!e.scanning`),
  so the mtime column shifts left on those rows while neighbors keep theirs; emit a blank
  bar-width placeholder instead.
- **Column truncation is char-count based, not display-width based**
  (`src/ui.rs:1865-1891`): CJK/emoji filenames (2-cell glyphs) overflow the name column and
  misalign size/bar columns. A `unicode-width`-style width function fixes it (small
  hand-rolled table is enough if avoiding a dep).
- **Files pane title shows the total during search** (`files (N)`, `src/ui.rs:160`) rather
  than `matches/total`; the match count only lives in the status line.
- **Disk rows label by device node** (`f_mntfromname` like `/dev/disk3s1s1`,
  `src/app.rs:3251`) rather than the volume name; `disk_label` renders "device mount".
  Using the mount's last component (or `f_mntonname` for `/` → "Macintosh HD" via
  diskutil) reads far better.
- **`fda_limited` is probed once at startup** (`src/app.rs:618`); granting Full Disk Access
  mid-session leaves the warning chip until restart. Re-probe on `r`.
- **`request_empty_trash` silently does nothing** when a previous empty-trash worker is
  still pending (`src/app.rs:1682-1684` early-returns with no status).
- **Rename/mkdir overlay has no cursor indicator** (`src/ui.rs:95-104` renders the buffer
  as plain text); a trailing `▏` or reverse-video cell makes editing position visible.
- **Batch delete runs synchronously on the UI thread** (`src/app.rs:1968-1993` loops
  `delete_to_trash`): marking hundreds of files and confirming freezes the UI with no
  progress until the loop finishes. Move to a worker with a progress status like other
  long operations.
- **Stale `#[allow(dead_code)]` on used items**: `selected_path` (`src/app.rs:2981`) and
  `copy_to_clipboard` (`src/app.rs:3276`) are both live via the `y`/`s` keys; `FileInfo`
  (`src/app.rs:133`) is rendered by `draw_file_info`. Also an empty placeholder
  `impl PkgView { // Methods related to PkgView... }` block (`src/app.rs:482-484`) left
  from a partial merge.
- **`brew_prefix()` hardcodes `/opt/homebrew` vs `/usr/local` by build arch**
  (`src/packages.rs:1382-1388`); honoring `HOMEBREW_PREFIX` env (cheap) before falling back
  would cover Rosetta/custom-prefix setups whose Cellar otherwise scans empty.
- **`count_pyproject_deps` misses PEP 621 inline arrays**: `[project]` tables with
  `dependencies = ["a", "b"]` count 0 because section detection requires "dependencies" in
  the header line (`src/packages.rs:1089-1106`).

**Status:**

- [ ] Completed
- **Changelog:**

---

## Suggested sequencing

| Phase                       | Items                                        | Theme                             |
| --------------------------- | -------------------------------------------- | --------------------------------- |
| Build/test blockers         | 42, 44                                       | Restore validation and remove destructive tests |
| Quick wins (a day)          | 2, 1, 13, 14, 24, 25, 28, 33, 38, 39, 47, 50, 10-clipboard | Small fixes, immediate feel       |
| State safety                | 26, 27, 29, 30, 31, 43, 48                   | 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, 46                 | Numbers and action targets users can believe |
| CLI guardrails              | 45                                           | Validate destructive/semi-destructive commands |
| 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, 49                      | Decide, simplify, and keep UI fast |
| 2026-06-12 recheck, input/terminal bugs | 51, 52, 53, 54                  | Keys do what users expect; panics don't brick the terminal |
| 2026-06-12 recheck, regressions & audit integrity | 55, 56                | Re-land lost fixes; restore trust in completion records |
| 2026-06-12 recheck, scan/worker lifecycle | 57, 58, 66, 68                | Background work that cancels, dedupes, and stays coherent |
| 2026-06-12 recheck, accuracy | 59, 60, 61, 62, 64, 65, 67                   | Displayed numbers and targets match reality |
| 2026-06-12 recheck, persistence & docs | 63, 69, 70                       | Durable state files; honest docs; paper cuts |

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.