# diskr Product & Code Audit

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

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

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Status:**

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

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

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

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

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

**Pragmatic policy:**

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 7. Mouse capture with zero mouse support

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

**Fix options:**

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 8. Brew cask sizes are wrong

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

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

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

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

**Status:**

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

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

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

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

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

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

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

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

**Status:**

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

---

## Completed

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

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

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

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

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

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

**Status:**

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

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

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

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

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

**Status:**

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Status:**

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

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

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

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

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

**Status:**

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

---

## Missing

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

Four sub-projects, ordered by value:

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

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

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

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

**Status:**

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

### 16. File operations

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

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

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

**Status:**

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

### 17. Size-bar visualization

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

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

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

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

**Status:**

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

### 18. Full-subtree scan mode

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

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

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

**Status:**

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

### 19. Persistent size cache

**Phase 1 — trust but mark stale:**

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

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

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

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

**Status:**

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

### 20. File info popup

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

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

**Status:**

- [ ] Completed
- **Changelog:**

---

## Have but don't need

### 21. `EnableMouseCapture`

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

**Status:**

- [ ] Completed
- **Changelog:**

### 22. Outer scanner thread layer

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

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

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

**Status:**

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

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

---

## Additional v0.1.35 Recheck Findings

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

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

**Repro:**

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

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

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

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

- [ ] Completed
- **Changelog:**

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

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Effort:** ~1 day.

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Effort:** 15 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Effort:** ~1 hour.

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Effort:** ~half day.

**Status:**

- [ ] Completed
- **Changelog:**

### 38. Baseline header text is malformed

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

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

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

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

**Effort:** 15 minutes.

**Status:**

- [ ] Completed
- **Changelog:**

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

- [ ] Completed
- **Changelog:**

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

---

## Suggested sequencing

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

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