Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions docs/specs/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
# Specifications by workstream — MacDirStats

Each `SPEC-*.md` is a self-contained requirements document for a deferred workstream from the action plan, written to be picked up in a dedicated session. Common format:

1. **Objective & findings covered** — the why, with references to the audits.
2. **Current state of the code (verified)** — what exists, cited `file:line`.
3. **Design axes & tradeoffs** — real options, with a justified recommendation.
4. **Implementation plan** — files, steps.
5. **Verification** — tests + live driving (the screenshot + coordinate-click method is established and works).
6. **Risks & assumptions (🔬)** — what remains to be proven.
7. **Effort & dependencies**.

Guiding principle (carried over from the plan): **integrity > performance > robustness > simplicity > maintainability**, long-term first.

| Spec | Workstream | Findings | Effort | Depends on |
|---|---|---|---|---|
| [SPEC-01](SPEC-01-keyboard-navigation.md) | Keyboard navigation & native list | J3.1, J4.2, J3.7 | 1–2 d | — |
| [SPEC-02](SPEC-02-invalidation-rescan.md) | Sub-tree invalidation & re-scan | B1(cure), A6, A7, J4.4, D1 | 1–2 d | — |
| [SPEC-03](SPEC-03-exact-vs-attribution.md) | Exact counting vs attribution + reconciliation | A3, A4, A10, J9 | 2–3 d | — |
| [SPEC-04](SPEC-04-fsevents-live.md) | FSEvents — live dashboard | S3, J5.2, J4.5, D1 | 1–2 d | SPEC-02 |
| [SPEC-05](SPEC-05-file-level-treemap.md) | File-level refinement **on zoom** (per-folder overview) | S5 | 2–3 d | — |
| [SPEC-06](SPEC-06-pluggable-backends.md) | Pluggable backends | S6 | ~1 d/backend | — |
| [SPEC-07](SPEC-07-distribution.md) | **Signed/notarized DMG distribution via GitHub** (v1) | J1.1–J1.5, D4 | 1–2 d | — |
| [SPEC-08](SPEC-08-accessibility.md) | Full accessibility & i18n | J10.1–J10.4, J9.5 | 1–2 d | SPEC-01 (partial) |
| [SPEC-09](SPEC-09-gpu-treemap-rendering.md) | **3D-native** GPU treemap rendering (Metal, ortho 2D projection) | perf-resize (PR #17) | 2–3 d | PR #17 (NSView seam, done) |

**Recommended order**: SPEC-02 (unblocks SPEC-04 and closes A6/A7 cleanly) → SPEC-01 → SPEC-03 → SPEC-08 → SPEC-04 → SPEC-05/06/07 depending on product priorities.
72 changes: 72 additions & 0 deletions docs/specs/SPEC-01-keyboard-navigation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# SPEC-01 — Keyboard navigation & native list

> **Findings**: J3.1 (100% mouse-driven app), J4.2 (no ⌘⌫ nor multi-selection), partially J3.7 (sorting/columns). Refers to D-E of the plan.
> **Status**: ✅ **IMPLEMENTED** (Axis B — `NSTableView` as `NSViewRepresentable`). See [DirectoryTable.swift](../../Sources/SpaceMatters/Views/DirectoryTable.swift).

## 0. Implementation result

- `DirectoryTable: NSViewRepresentable` (NSScrollView + `MacDirTableView`), rows = `NSHostingView(OutlineRowView)` → **zero visual regression** (SwiftUI rendering reused as-is).
- **Deterministic** mouse: `MacDirTableView.hitTest` returns the table for any in-bounds point (purely visual cells); selection goes through `NSTableView.mouseDown`/`row(at:)`, not through responder-chain forwarding. Collapse/expand chevron by geometry in `mouseDown`.
- Keyboard: ↑/↓ native, ←/→ collapse/parent & expand/child, ⏎ zoom/open, **⌘⌫ trash the selection (gated on `!isScanning`)**, space Quick Look, native type-select.
- **Multi-selection** (`allowsMultipleSelection`): `ScanController.selectedRowIDs` (set) + `setListSelection(_:primary:)` — the *primary* drives the treemap, the set drives ⌘⌫. Per-row context menu (Quick Look/Open/Reveal/Copy/Trash/Delete) via `menu(for:)`.
- treemap↔list sync: `selectedRowID` remains the single source; `updateNSView` idempotent, `scrollRowToVisible` on `revealTarget`.
- **Verified**: green build; 12 tests (including `listSelectionTracksPrimaryAndSet`, `deletingAncestorClearsStaleMultiSelection`); **live** — the native table renders the outline with full fidelity (chevrons/icons/bars/sizes/sorting), selection drawn, **each row exposed to AX** with a label ("Folder alpha, 1000 KB"), no crash. Driving via synthesized events (click/arrows) was blocked by a *Secure Event Input* panel from another app; logic covered by the ViewModel tests.
- **Remaining optional (outside the 100% scope)**: J3.7 sortable column headers — explicitly "optional" in §5.7. Multi-target context menu (today ⌘⌫ covers bulk trashing).

## 1. Objective

Make the directory list keyboard-drivable for the target user (senior dev): ↑/↓ arrows to move through it, ←/→ to collapse/expand, ⏎ to zoom/open, ⌘⌫ to move to trash, space bar for QuickLook, multi-selection (⇧/⌘-click) for bulk cleanup, and type-select (typing a prefix).

## 2. Current state of the code (verified)

- [DirectoryListView.swift](../../Sources/SpaceMatters/Views/DirectoryListView.swift): `List { ForEach(rows) { OutlineRowView… } }`, `listStyle(.plain)`, custom rows. Selection via `.onTapGesture { select() }`, zoom via `.simultaneousGesture(TapGesture(count: 2))`, chevron via a dedicated `.onTapGesture`, `.contextMenu`. The selection is `controller.selectedRowID` (derives `selection`).
- `visibleRows()` ([ScanController.swift:398](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L398)) produces a **flat array** (dirs + files interleaved, biggest-first) — already the right model for a table.
- Zoom/selection/expansion are centralized in `ScanController` (good).

## 3. Lesson learned (verified live)

`List(selection:)` + `.tag(row.id)` was tried and **driven on screen**:
- the `List` does **not** take keyboard focus (arrows inert);
- native selection does **not** engage on single click as long as the rows carry their own `onTapGesture`/`contentShape`/double-click;
- removing the `onTapGesture` **breaks** single-click without unblocking the arrows.

→ Keyboard navigation **is not a bolt-on** on the current SwiftUI `List`.

## 4. Design axes & tradeoffs

- **Axis A — All-SwiftUI `List(selection:)`**: strip the rows of all their gestures, let the `List` own the selection, re-add chevron/double-click/hover some other way. *Tradeoff*: fights SwiftUI focus quirks (observed), native selection rendering to re-style, arrow behavior not guaranteed on a `List` with custom content. **High risk, uncertain outcome.**
- **Axis B — `NSViewRepresentable` around an `NSTableView` (recommended)**: **deterministic** control of the keyboard (arrows, ⌘⌫, type-select, multi-select), focus (first responder), selection, and scrolling. This is how dense Mac apps (Finder-like) do it. *Tradeoff*: more code (≈250–350 lines), a SwiftUI↔AppKit bridge to maintain. *Benefit*: robust, testable, extensible (sortable columns J3.7, multi-selection J4.2 for free).
- **Axis C — `NSOutlineView`**: handles the tree natively (disclosure), but we already have a *flat* model (`visibleRows()`) and expansion lives in the controller → `NSTableView` fits better, less friction.

**Recommendation: Axis B.** An `NSTableView` as `NSViewRepresentable`, fed by `visibleRows()`, cells rendered via `NSHostingView(OutlineRowView)` to **reuse the existing SwiftUI design** (zero visual regression). Keyboard/focus/multi-selection become native and reliable.

## 5. Implementation plan

1. **`DirectoryTable: NSViewRepresentable`** producing `NSScrollView` + `NSTableView` (one column, fixed `rowHeight`, `usesAlternatingRowBackgroundColors = false`, plain style).
2. **Data source / delegate** (`Coordinator`): rows = `controller.visibleRows()`; `numberOfRows`, `viewFor:` → `NSHostingView(rootView: OutlineRowView(row:…))` reused (pooled via `makeView(withIdentifier:)`).
3. **Selection**: `allowsMultipleSelection = true`. `tableViewSelectionDidChange` → push to `controller.selectedRowID`/`selection`; conversely, `updateNSView` re-selects when `controller.selectedRowID` changes (breadcrumb/treemap → list).
4. **Keyboard** (subclass `NSTableView.keyDown` or `NSResponder`):
- ↑/↓: native.
- ←/→: collapse/expand the selected folder row (`controller.toggleExpanded`).
- ⏎: `controller.zoom(into:)` (folder) / `openItem` (file).
- ⌘⌫: trash the selected row(s), **gated on `!isScanning`** (J4.4), via the existing async `remove(...)` flow.
- space: QuickLook of the selected URLs.
- type-select: native via `tableView(_:typeSelectStringFor:)`.
5. **Multi-selection → bulk actions**: `controller.remove(rows:)` iterating the async flow; equivalent `contextMenu(forSelectionType:)` context menu.
6. Replace `List{…}` with `DirectoryTable(controller:)` in `DirectoryListView`. Keep `revealTarget`/scroll-to (via `scrollRowToVisible`).
7. Optional (J3.7): sortable column headers (name / size / file count / date) → `sortDescriptors` → new sort in the controller.

## 6. Verification

- **Live (established method)**: `--open <fixture>`, screenshot, `key code 125/126` (↓/↑) → the selection moves (verifiable by the highlight); ⌘⌫ on a row → trash + total decremented; ⇧-click → multi-selection; space → QuickLook panel.
- **ViewModel tests**: `controller.selectRow`, multi-remove, gating during scan (extend `NavigationTests`).

## 7. Risks & assumptions

- 🔬 **Per-row `NSHostingView` perf** on a very large list: mitigate via view reuse + a lightweight `OutlineRowView`; to be measured on a folder with 100k visible rows (rare — the list is virtualized to O(visible)).
- 🔬 Bidirectional list-selection ↔ treemap synchronization without an event loop: keep `selectedRowID` as the single source, `updateNSView` idempotent.
- Focus ring / selection appearance to match the theme (draw the selection in `OutlineRowView` via `isSelected`, disable the AppKit-drawn selection).

## 8. Effort & dependencies

**1–2 days.** No dependencies. Unblocks J3.1, J4.2, and paves the way for J3.7 (columns) and part of SPEC-08 (natively accessible focus/rotor).
63 changes: 63 additions & 0 deletions docs/specs/SPEC-02-invalidation-rescan.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# SPEC-02 — Sub-tree invalidation & re-scan

> **Findings**: B1 (deep fix — the safeguard is already in place), **A6** (types table stale after deletion), A7 (folder counter), J4.4 (delete during scan — gated), D1 (TOCTOU). Refers to D-B/S2 of the plan.
> **Status**: ✅ **IMPLEMENTED** — A6 resolved (deletion path, exact) + `invalidate(subtree:)` building block delivered for SPEC-04.

## 0. Implementation result

- **A6 resolved (exact) — deletion**: `remove(directory:)` walks the sub-tree **before** deletion (`DirectoryScanner.subtreeExtensions(path:)`, an exact mirror of the scan tally: same `ExtKey`, same alloc/logical sizes) and subtracts that contribution from the scanner's `extStats` table (`subtractExtensions`, zeroed entries removed). `remove(file:)` subtracts the file's extension. The File-types panel updates. Test: `deletingSubtreeUpdatesFileTypeTable` (deleted `.mp4` disappear, `.txt` intact).
- **`invalidate(subtree:)` building block** (reused by SPEC-04): **in-place** re-scan of the sub-tree (seed on the node → correct parents), **exact** reconciliation of the ancestor aggregates (old→new delta), of the **dirCount** (A7), and **nav re-resolution by path** (`node(at:)`) since the descendant nodes become fresh objects (no dangling `unowned`). Test: `invalidateReflectsExternalChanges` (external file+folder addition reflected, zoom re-bound by path).
- **Approach = recommended hybrid (§4/§7)**: exact aggregate subtraction retained (avoids re-scanning a potentially huge parent); only `extStats` is corrected by delta.
- **Documented limit (extStats in `invalidate` only)**: for an extension **shared across sub-trees AND modified** within the invalidated sub-tree, the delta is not exact (the original sub-contribution is not stored per node — low-RAM stance assumed §3); error bounded by the change's delta, self-corrected at the next full rescan. **The deletion path (the A6 finding) stays exact** (walk before the change). Sizes/counts/dirCount always exact.
- **B1/A7/J4.4**: already in place, unchanged and still valid. **D1**: bounded by the `!isScanning` gating; live detection → SPEC-04.

## 1. Objective

Replace the **manual surgery** of the aggregates in `remove()` with a **targeted invalidation + re-scan** of the parent directory. Benefits: removes the entire class of fragile accounting (A6 impossible to fix otherwise — see §3), eliminates any residual risk of inconsistency, and provides the **common building block** reused by FSEvents (SPEC-04).

## 2. Current state of the code (verified)

- `remove(directory:)`/`applyDirectoryRemoval` ([ScanController.swift:507+](../../Sources/SpaceMatters/ViewModel/ScanController.swift#L507)): subtracts the aggregates of each ancestor (atomics), moves `zoomRoot`/`selection`/`expanded` up out of the sub-tree (safeguard B1), decrements `dirCount` (A7), detaches the node.
- **What is NOT adjusted**: the scanner's `extStats` (A6). The per-extension breakdown of the deleted sub-tree is **not stored** in the tree (nodes only keep `dominantExt` + aggregates). So subtracting the per-extension contributions of the deleted sub-tree is **infeasible** without re-enumerating.
- The scanner already knows how to scan a sub-tree: `DirectoryScanner(root:, seeds:[Seed(path:node:)])` — a deletion = a new seed on `node.parent`.

## 3. The crux of the problem (A6)

`extStats: [ExtKey: ExtStat]` is **global to the scan**, built by accumulation during the scan. After deletion of a sub-tree, it still contains the bytes/counts of the deleted files. Three ways out:

- **Store the extensions table per node**: unbounded memory cost (the opposite of the "1 node/directory, low-RAM" stance). ❌
- **Re-scan the deleted sub-tree *before* deletion to know its contributions and subtract them**: possible but fragile (double work, race with the scan). ⚠️
- **Invalidation + re-scan of the parent (recommended)**: after a successful deletion, re-scanning the parent directory rebuilds its children **and** re-accumulates `extStats` for that sub-tree. It remains to reconcile with the global table.

## 4. Design axes & tradeoffs

- **Axis A — Consolidate the in-place mutation**: add the `extStats` subtraction. *Cleanly infeasible* (§3). ❌
- **Axis B — Re-scan of the parent, extensions table recomputed globally**: on deletion, (1) detach the node (as today, B1 safeguards retained), (2) relaunch a `DirectoryScanner` seeded on `parent.path` in a **fresh sub-tree**, (3) atomic swap of `parent._children`, (4) **rebuild `extStats` by re-walking the entire remaining tree** (the files are not in memory → disk re-enumeration of the folders, costly) *or* maintain `extStats` as a sum of recomputed per-seed tables.
- **Axis C — Re-scan of the parent + `extStats` maintained per sub-tree**: the scanner keeps `extStats` **per first-level directory** (or per seed), so that invalidating a sub-tree = subtract its sub-table + re-accumulate. More state, but exact O(1) subtraction.

**Recommendation: Axis B, pragmatic variant.** The re-scan of the parent is the true cure for B1/A6/A7. For `extStats`, **mark the "File types" panel as approximate** just after the deletion *and* recompute it via **lazy** re-enumeration of the re-scanned parent (the parent re-scan already re-accumulates the sub-tree's extensions; it suffices to subtract the parent's old contribution known before the re-scan). Key decision documented below.

**`extStats` decision**: before the re-scan, **snapshot the extensions table restricted to the parent's sub-tree** (obtainable via a "dry-run" re-scan pass of the parent, or by subtracting post-re-scan). *The simplest and exact*: the parent re-scan produces its **new** extensions sub-table; we keep an `extStats` **per direct-parent-of-seed node** in order to do `global = Σ sub-tables`. 🔬 to validate: the memory overhead of a sub-table per child-of-seed (bounded by the number of distinct extensions, small).

## 5. Implementation plan

1. `DirectoryScanner`: expose a **sub-tree re-scan** mode — `rescan(node:path:)` that restarts from a single seed, builds a temporary root `FSNode`, and returns `(children, directFiles, extSubtable, dirCount)`.
2. `ScanController.remove(...)`: on disk success → instead of the aggregate surgery, call `invalidate(subtree: node.parent)`.
3. `invalidate(subtree:)`: launches the re-scan (detached, non-blocking), applies on MainActor: swap `_children`, recompute the ancestor aggregates (delta = new − old sub-total), update `extStats` (Σ sub-tables), `dirCount`, re-resolution of `selection`/`zoomRoot` **by path** (not by node identity, since the nodes are fresh — reuse `path(for:)` + a reverse resolution).
4. Keep the B1 safeguards (they become redundant but harmless) during the migration, then remove them.
5. Gating during scan (J4.4): already in place; the invalidation only runs when `phase != .scanning`.

## 6. Verification

- **Tests** (extend `NavigationTests`): after deleting a sub-tree containing `.mp4`, **`extStats` no longer contains the deleted `.mp4`** (locks A6); `dirCount`, aggregates and absence of a dangling node stay correct; `selection`/`zoomRoot` re-resolved by path.
- **Live**: delete `sub2` from the fixture → the "File types" panel loses the `.bin` of `sub2/deep`, total/folders consistent (before/after screenshot).

## 7. Risks & assumptions

- 🔬 Re-resolution of `selection`/`zoomRoot` by path after node swap: requires a transient path→node index.
- 🔬 Cost of re-scanning a very large parent (e.g. deleting a folder in `/` re-scans the whole root): bound it by re-scanning — **the deleted node itself no longer exists**, so we re-scan its parent — potentially huge. Mitigation: only re-scan if the parent has few remaining children, otherwise keep the aggregate subtraction (exact) and invalidate ONLY the sub-tree's `extStats` (hybrid).
- The hybrid choice (aggregates by subtraction + extStats by sub-table) is perhaps the best simplicity/cost tradeoff — to be decided at implementation time.

## 8. Effort & dependencies

**1–2 days.** No upstream dependency. **Unblocks SPEC-04 (FSEvents)** which reuses `invalidate(subtree:)`.
Loading
Loading