diff --git a/docs/tree-primitive-framing.md b/docs/tree-primitive-framing.md new file mode 100644 index 0000000..b174c06 --- /dev/null +++ b/docs/tree-primitive-framing.md @@ -0,0 +1,298 @@ +# Framing — the tree primitive + +**Revision 1.** Status: framing only. No implementation. Scouted against +`githubsucks/main` @ `12f2970`. + +`COHERENCE.md` §14 grades **Tree ✗ — none**, and it is the last missing +workbench primitive. §20's Priority 5 names it as what remains after the +bottom panel, and its argument is specific: *"building it once before +dired's directory view and the workers tree harden their own conventions +is exactly this section's point."* + +**This document deliberately narrows that argument.** §14 lists six +future consumers — project files, symbol hierarchy, package dependency +graph, worker trees, git status — and designing a shared primitive +against six hypothetical consumers is how you get a model that fits +none. The scout found something better: **one consumer already ships a +tree and fakes it**, and a second is already scoped and deliberately +deferred. Those two are the design's evidence base. + +--- + +## 0. Coherence impact (COHERENCE §20) + +- **Concern: §14 Coherent Workbench Primitives.** Tree is the one + remaining ✗ in its inventory. This closes it for the two consumers + that exist and gives the rest an adoption path. +- **Journey steps touched:** none directly. Step 6 (LSP) gains a real + hierarchy view where it currently has indented text. +- **Interaction islands (§6): NONE, unless evidence forces one.** The + default is the ordinary **buffer-local keymap** idiom that listview, + compile, dired and terminal already use — a tree's expand/collapse + keys are buffer-local bindings on a generated buffer, not a new + dispatch shadow. §6 grades islands "weak, and growing"; this stage + must not add to that count. If some behaviour genuinely cannot be + expressed as a buffer-local binding, that is a finding to report, not + a licence to add an island. +- **Config registry adoption:** none proposed. If a preference emerges + (initial expansion depth, say), it enters the registry rather than + becoming a hardcoded constant — but nothing yet requires one. +- **Background-work attribution:** none. +- **Enables:** DAP's variables view, which is inherently a tree + (scopes → objects → fields) and would otherwise become the **third** + bespoke implementation. + +--- + +## 1. Ground truth (measured at `12f2970`) + +### 1.1 The LSP outline is a shipped tree consumer, faking it + +**The hierarchy already exists and is already discarded.** + +`Symbol::push_hier` (`src/symbol.rs:110`) walks a genuine LSP +`DocumentSymbol` tree — it recurses on `item.get("children")` — and +**flattens it** into `Vec`, preserving: + +- `depth: u32` — "Nesting depth in a hierarchical `DocumentSymbol` tree"; +- `parent` — `containerName` for flat shapes, or the parent chain joined + with `::` for hierarchical ones; +- document order, **parents before children** (`SymbolResponse.symbols`). + +`lsp.lua` then re-renders that depth as *leading spaces inside the row +text*: + +```lua +-- Arc 1b phase 2: a browsable *outline* panel. Symbols arrive +-- FLAT with a `depth` field --- indent, don't recurse. +text = string.format("%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag), +``` + +So the outline has **no collapse, no expand, no parent/child +navigation** — indentation is a string. Every input a tree needs is +already computed; only the view throws it away. + +**This is the anchor consumer.** It needs no new data plumbing, and its +limitation is observable today rather than hypothetical. + +### 1.2 dired declined to invent one, and its case is already scoped + +Dired Stage 1 landed a **flat** listing for Emacs parity and deferred +the recursive case explicitly: `docs/dired-framing.md` §13 names +**`i` insert-subdirectory (in-buffer recursive listing)** as deferred. + +§14 credits this directly: dired "landed **without** inventing one". That +restraint is what keeps the door open — and it means the second consumer +arrives with real requirements rather than a wishlist: a fixed-width +column contract (`pmacs.dired._layout`), a frozen test fixture, and +path-keyed entries. + +### 1.3 The workers view is NOT a consumer yet + +`editor.list-workers` is +`pmacs.window.switch_buffer(pmacs.workers.show())` — a **Rust-generated +text buffer, raw-switched** into the active window. It is not a listview +and has no rows. §14's "worker trees" is a future consumer, not a +current one. + +*(Incidental, and worth a separate look: that raw switch is the same +`switch_buffer` pattern that broke the outline under bottom-panel Stage +3. It is harmless while `*workers*` is not a panel, and inherits the +hazard the moment it becomes one.)* + +### 1.4 What listview's model would have to gain + +listview's contract today: + +```lua +pmacs.listview.open { + name = "*references*", + header = "12 references RET visit n/p move g refresh q quit", + rows = { { text = "src/foo.rs:12:4", item = }, ... }, + on_visit = function(item) ... end, + on_refresh = function() return rows end, +} +``` + +and `render` is: + +```lua +local lines = { p.header } +p.line_to_item = {} +for _, row in ipairs(rows) do + lines[#lines + 1] = row.text + p.line_to_item[#lines - 1] = row.item +end +pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n")) +``` + +**A flat array plus a line→item map.** Depth appears nowhere; it is +baked into `row.text` before listview ever sees it. `item` is `` — +**opaque to listview by design**, which matters for §2's identity +question. + +### 1.5 Refresh restores a LINE, not a node — and that is the crux + +```lua +local saved = pmacs.editor.cursor_line() +local rows = p.on_refresh() or {} +render(p, rows) +... +seat_cursor(p, saved) +``` + +`refresh` saves a **line number**, rebuilds the rows wholesale from a +freshly-produced array, and re-seats by walking `move_down` that many +times. + +Today this is a mild wrong-restore: if the new list has a different +shape, the cursor lands on whatever row now occupies that line. For a +flat list of roughly stable shape, tolerable. + +**Collapse breaks it outright.** Expanding a node inserts rows *above* +the cursor, so a line-keyed restore lands somewhere unrelated. And +collapse state itself must survive refresh, which requires recognising +"the same node" across two independently-produced arrays — something +neither `line_to_item` nor an opaque `item` can do. + +**This is why Q#TR3 exists and why it is not a detail.** It decides +whether selection and expansion can survive a model update at all. + +### 1.6 What is NOT established + +- **Nothing is implemented or measured.** Unlike the last two lanes + there is no fallout to census: §14 grades Tree ✗, so there is no + existing behaviour to preserve and no baseline to diff. **This framing + argues a model rather than measuring one**, which is the shape that + has historically needed the most review rounds here (Lean Stage 3b + took six; the signal lane had three tolerance rules rejected in a + row). Treat its claims as proposals. +- **No consumer has asked for collapse.** The outline's limitation is + inferred from its structure and its own "indent, don't recurse" + comment, not from a user report. +- **dired's `i` has not been re-scouted** against current `main`; §13's + deferral is the only evidence that its requirements are as described. + +--- + +## 2. Questions + +All four are genuinely open. The first two the review already flagged as +open; the third is the one review added; the fourth follows from §1.4. + +- **Q#TR1 — extend `listview`, or add a separate `treeview`?** + Extending touches three shipped call sites and the `line_to_item` + contract, and risks making a working flat primitive worse for the two + consumers that do not need depth. A separate primitive avoids that but + is exactly the "second primitive" §14 warns about, and would duplicate + panel plumbing, `q`/`g` bindings, the Q#GB18 identity rule and the + generated-buffer write invariant. **No leaning recorded** — the scout + did not find evidence that decides it. +- **Q#TR2 — who owns collapse state?** Candidates: the primitive (keyed + by node id), the consumer (passed in with the rows each render), or + the buffer (as generated-buffer state). Consumer-owned keeps the + primitive stateless and makes refresh the consumer's problem; + primitive-owned centralises it and forces Q#TR3 to be answered first. +- **Q#TR3 — what is a stable node identity across refresh?** *(Added at + review.)* This determines whether **selection and expansion survive a + model update** (§1.5). Constraints the scout established: + - listview cannot derive one: `item` is `` and opaque. + - The outline's data is nearly sufficient — the `::`-joined parent + chain plus name — but **not unique**: overloads and same-named + siblings collide. + - dired's would be genuinely stable: the path. + - So identity is almost certainly **consumer-supplied**, which makes + it part of the primitive's public contract rather than an internal + detail. That is a real API commitment and should be decided + deliberately, not defaulted into. +- **Q#TR4 — does the row still carry pre-rendered `text`?** Today the + consumer formats indentation into the string. If the primitive owns + depth it should probably own indentation too — but the outline also + appends `[kind]` tags and dired has a fixed-width column contract, so + "the primitive renders the row" may not survive contact with either. + +--- + +## 3. Bets + +- **Bet 1 — the outline is a sufficient first consumer on its own.** Its + data already carries depth and parent; adopting it requires no LSP-side + change. *Falsified if adoption needs `Symbol` to change shape.* +- **Bet 2 — identity must be consumer-supplied** (§1.5, Q#TR3). + *Falsified if some derivable key proves both stable and unique across + the two consumers.* +- **Bet 3 — no interaction island is required.** Expand/collapse are + buffer-local bindings on a generated buffer, exactly as `RET`/`n`/`p`/ + `g`/`q` already are. *Falsified if some behaviour cannot be expressed + that way — which would be a finding worth reporting, not a licence.* + +--- + +## 4. Acceptance + +**Not final** — this framing argues a model, and the criteria cannot be +fixed until Q#TR1–TR3 are decided. The shapes they will take: + +1. The **LSP outline** renders through the primitive with **no + `string.rep` indentation in `lsp.lua`**, and `Symbol` is unchanged. +2. **Collapse and expand** work, and **collapse state survives + `g` refresh** — the criterion that Q#TR3 exists to make possible. +3. **Selection survives refresh by node, not by line** (§1.5). +4. **No new interaction island**: every tree key is a buffer-local + binding, and the dispatch-shadow count is unchanged. Asserted, not + assumed. +5. The **flat consumers are unaffected** — references, buffer-list and + the search panel render byte-identically, pinned by their existing + suites rather than by inspection. +6. **Q#GB18 identity holds**: a foreign buffer with the panel's name is + never adopted, and the primitive is keyed by handle, not name. +7. The **generated-buffer write invariant** is preserved + (`set_generated_contents`), including the rope lock and history rules + the listview suite already pins. + +--- + +## 5. Parked + +- **The other four §14 consumers** — project files, package dependency + graph, worker trees, git status. They adopt later; designing for them + now is the failure this framing avoids. +- **DAP's variables view.** The reason the primitive is worth building + before the debugger, and not part of it. +- **dired's `i` insert-subdirectory.** The second consumer, and the + right forcing function for the design — but it is its own stage with + its own framing, and dired Stage 2b/3 are ahead of it. +- **Making `*workers*` a listview/tree consumer** (§1.3), and the + raw-switch hazard noted there. +- **Tree rendering in the GPU frontend** beyond whatever the shared + generated-buffer path already gives. + +--- + +## 6. Gates + +The standing `CLAUDE.md` suite. The suites most likely to move are +`listview_acceptance` and `m4_acceptance` (the LSP outline and hover +panels are listview consumers — bottom-panel Stage 3 established that +transitive relationship the hard way). + +**Sweep both feature configurations.** Stage 3 shipped a broken +crdt-gated suite because every local sweep ran `--features luajit` +without `crdt`; and `--no-fail-fast` is required, or a multi-suite break +reports as one suite. + +--- + +## 7. Branch plan + +Not settled, because it depends on Q#TR1. Two shapes: + +- **If listview is extended:** one branch, with the flat-consumer + no-change proof (acceptance 5) landing *before* the outline adopts, so + a regression in references or buffer-list is attributable. +- **If a separate `treeview`:** the primitive and its first consumer are + separable, and the outline's adoption can be its own PR. + +Either way the outline adopts **before** dired's `i` is attempted: it is +the consumer whose data already fits, and it is the one that proves the +model without also needing a new listing mode.