# diskr Product & Code Audit

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

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

---

## Bugs

### 1. Search-mode index corruption

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

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

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

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

**Fix:**

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 2. Frozen spinners during quiet scans

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

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

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

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

**Status:**

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

### 3. Permission failures silently report 0 B

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

**Fix design:**

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

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

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

**Effort:** ~half a day.

**Status:**

- [ ] Completed
- **Changelog:**

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

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

**Fix — three tiers:**

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Status:**

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

---

## Incomplete

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

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

---

## Missing

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

Four sub-projects, ordered by value:

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 16. File operations

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 17. Size-bar visualization

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 18. Full-subtree scan mode

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 19. Persistent size cache

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

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

### 20. File info popup

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

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

**Status:**

- [ ] Completed
- **Changelog:**

---

## Have but don't need

### 21. `EnableMouseCapture`

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

**Status:**

- [ ] Completed
- **Changelog:**

### 22. Outer scanner thread layer

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

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

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

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

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

**Status:**

- [ ] Completed
- **Changelog:**

---

## Suggested sequencing

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

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