Merge pull request #217 from levineuwirth/tree-primitive-framing
feat(listview): the tree primitive — optional depth/id, primitive-owned folding
This commit is contained in:
commit
db1bbe94a6
65
COHERENCE.md
65
COHERENCE.md
|
|
@ -107,7 +107,7 @@ remain open to them.
|
|||
| 11 | Config layering + provenance | **Partial (foundation only)** | Typed registry is right; 5 settings live in it; no value provenance |
|
||||
| 12 | Profiles | **Missing** | One hardcoded default keymap; not a named concept |
|
||||
| 13 | Package lifecycle UX | **Resolution without lifecycle** | Mature resolver/lockfile; init-only install; no uninstall/disable/search |
|
||||
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only 3 call sites, all LSP panels; buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is still ✗ and is now the arc's successor** |
|
||||
| 14 | Workbench primitives | **Partial (best trajectory)** | Listview is a real primitive but only **4** call sites, all LSP panels (`*lsp*` added post-audit by #204); buffer-list and search re-implement it; **the bottom panel is COMPLETE — both frontends, and Stage 3 flipped the adopter default so omission means the panel**. **Tree is implemented (◐) with the LSP outline as its one adopter; the remaining consumers, including dired's `i`, have not adopted** |
|
||||
| 15 | Contextual affordances | **Weak** | Right-click menu only; code actions apply first-blindly; no git integration at all |
|
||||
| 16 | Semantic frontend | **Strong** | v6..=v21 schema support; production attach remains v20 during the dark panel slice; degradation practiced |
|
||||
| 17 | Distribution | **Partial** | **v1.1.0 ships prebuilt Linux/macOS binaries on tag** (#211) with checksums and a stated glibc floor. No channels, in-place update, rollback, signing, or package-manager distribution |
|
||||
|
|
@ -1282,10 +1282,23 @@ Primitive-by-primitive against the list above:
|
|||
buffer-local keymap idiom (RET/SPC visit, n/p, g refresh, q quit)
|
||||
that is inspectable and rebindable (§6's counter-example). **But its
|
||||
adoption is narrower than this document claimed, and the correction
|
||||
matters more than the grade.** Measured at `ad41cf1`: there are
|
||||
matters more than the grade.** Measured at `ad41cf1`: there were
|
||||
exactly **three** `pmacs.listview.open` call sites, **all three in
|
||||
`builtin/runtime/lsp.lua`** — `*references*` (`:2056`), `*outline*`
|
||||
(`:2102`) and `*lsp-help*` (`:2513`). The three other `listview`
|
||||
(`:2102`) and `*lsp-help*` (`:2513`).
|
||||
|
||||
**Updated: there are now FOUR.** Journey Stage 1b-2 (**#204**) added
|
||||
`*lsp*` via `lsp.status` — the audited claim above changed and that PR
|
||||
did not update it, so this correction rides the tree-primitive framing
|
||||
that found it (§25). All four remain in `lsp.lua`; per §25 the
|
||||
symbols are authoritative and the `ad41cf1` line numbers have drifted.
|
||||
|
||||
**`*lsp*` is the only one of the four with a working refresh** — it is
|
||||
the only one supplying `on_refresh`. `g` is bound on all four
|
||||
unconditionally by `bind_local_keymap`, so the other three carry a
|
||||
**dead refresh binding**: bound, dispatched, silently does nothing.
|
||||
|
||||
The three other `listview`
|
||||
mentions under `builtin/` are comments in `compile.lua` and
|
||||
`dired.lua` citing "the listview idiom", which is a *pattern being
|
||||
copied*, not the primitive being used.
|
||||
|
|
@ -1354,15 +1367,27 @@ Primitive-by-primitive against the list above:
|
|||
(§9).
|
||||
- **Help view** △ — exists twice (§5); needs unification, not
|
||||
invention.
|
||||
- **Tree** ✗ — none. The named future consumers (project files, symbol
|
||||
hierarchy, package dependency graph, worker trees, git status) will
|
||||
each need it; building it once *before* dired's directory view and
|
||||
the workers tree harden their own conventions is exactly this
|
||||
section's point. Dired Stage 1 (merged #165) landed **without** inventing
|
||||
one: its listing is flat (Emacs parity), and the recursive
|
||||
in-buffer case — `i` insert-subdirectory — is a named deferral in
|
||||
`docs/dired-framing.md` §13, which is where a shared tree primitive
|
||||
would land.
|
||||
- **Tree** ◐ — **implemented, one consumer.** `listview` carries
|
||||
optional `depth` and `id` on rows, primitive-owned collapse state, and
|
||||
selection re-seated by id rather than by line; `TAB` toggles. Absent
|
||||
`depth`/`id`, a row behaves exactly as before, which is what leaves
|
||||
the flat consumers untouched (pinned by byte-identity coverage).
|
||||
**The LSP outline is the only adopter**: it previously flattened a
|
||||
genuine `DocumentSymbol` tree into indented strings, and now supplies
|
||||
structure while keeping its rendered text.
|
||||
|
||||
The organising fact, worth carrying: **folding is local projection
|
||||
state, not a refresh protocol.** Collapse only hides rows and never
|
||||
changes a surviving row's depth, so the primitive re-renders from its
|
||||
own array without calling the consumer — which is why the outline
|
||||
works at all, having no `on_refresh`.
|
||||
|
||||
**Still one consumer, hence ◐ not ✓.** The named future consumers
|
||||
(project files, package dependency graph, worker trees, git status)
|
||||
have not adopted, and dired's recursive `i` insert-subdirectory — the
|
||||
second real constraint source — remains the deferral in
|
||||
`docs/dired-framing.md` §13. Dired Stage 1 (merged #165) landed
|
||||
**without** inventing its own, which is what kept this possible.
|
||||
- **Structured table / inspector / diff view** ✗ — none. (`describe.*`
|
||||
tables are the inspector's data model without a view; the
|
||||
wire-declared `ResourceOffer` family was reserved for diff/blame
|
||||
|
|
@ -1669,9 +1694,19 @@ and §18's floor ride on this.
|
|||
|
||||
**State: the bottom panel is DONE (§14) — both frontends, Stage 1 #155
|
||||
through Stage 2B-3, and Stage 3 flipped the adopter default so omitting
|
||||
`display` means the panel. Arc 7 is complete.** Remaining elsewhere: the tree primitive (build it before dired
|
||||
and the worker tree invent two), table/inspector/diff, help unification.
|
||||
Wiring plus one modest model piece (the tree model).
|
||||
`display` means the panel. Arc 7 is complete.**
|
||||
|
||||
**The tree primitive is IMPLEMENTED too (§14, ◐)** — `listview` carries
|
||||
optional `depth`/`id`, primitive-owned collapse, and selection re-seated
|
||||
by id. It was built before dired's recursive view and the worker tree
|
||||
could invent their own, which was this priority's stated reason for
|
||||
doing it early.
|
||||
|
||||
**What remains is ADOPTION, not construction.** The LSP outline is the
|
||||
only consumer; dired's `i` insert-subdirectory is the next real
|
||||
constraint source, and DAP's variables view is why the primitive was
|
||||
worth building first. Also remaining: table / inspector / diff, and help
|
||||
unification.
|
||||
|
||||
### Priority 6: Productize configuration
|
||||
|
||||
|
|
|
|||
|
|
@ -114,21 +114,148 @@ end
|
|||
-- `M-x buffer.undo` did too, and no rebinding can remove that. The
|
||||
-- primitive lifts the rope lock, writes, discards the history and
|
||||
-- re-asserts the lock, all inside one registry borrow.
|
||||
-- Tree support (docs/tree-primitive-framing.md, Q#TR1-TR4).
|
||||
--
|
||||
-- A row MAY carry `depth` (0-based, structural) and `id` (a STRING or
|
||||
-- NUMBER, consumer-supplied, compared by value). Both optional: a row
|
||||
-- without them behaves exactly as before, which is what keeps the
|
||||
-- three flat consumers byte-identical.
|
||||
--
|
||||
-- `text` stays CONSUMER-RENDERED (Q#TR4). The primitive owns structure,
|
||||
-- not presentation -- collapse only ever HIDES rows and never changes a
|
||||
-- surviving row's depth, so pre-rendered indentation remains correct
|
||||
-- and the primitive never has to re-format anything.
|
||||
--
|
||||
-- Descendants are a CONTIGUOUS RUN of following rows with greater
|
||||
-- depth. That holds because consumers emit parents before children in
|
||||
-- document order (the LSP outline's `Symbol` ordering guarantees it);
|
||||
-- a consumer that emits depth out of order gets nonsense, which is why
|
||||
-- `has_children` reads only the NEXT row rather than scanning.
|
||||
local function has_children(rows, i)
|
||||
local d = rows[i].depth
|
||||
if not d then return false end
|
||||
local nxt = rows[i + 1]
|
||||
return nxt ~= nil and (nxt.depth or 0) > d
|
||||
end
|
||||
|
||||
-- Is `rows[i]` hidden because some ANCESTOR is collapsed?
|
||||
--
|
||||
-- Walks backwards to shallower rows, which is the ancestor chain under
|
||||
-- the contiguous-run invariant above. Stops at depth 0: a root has no
|
||||
-- ancestor to hide it.
|
||||
local function hidden_by_ancestor(p, rows, i)
|
||||
local d = rows[i].depth
|
||||
if not d or d == 0 then return false end
|
||||
local want = d - 1
|
||||
for j = i - 1, 1, -1 do
|
||||
local dj = rows[j].depth or 0
|
||||
if dj <= want then
|
||||
if rows[j].id ~= nil and p.collapsed[rows[j].id] then return true end
|
||||
want = dj - 1
|
||||
if want < 0 then return false end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
-- Ids must be usable, unique table keys, and none of the three checks
|
||||
-- below is fussiness about types.
|
||||
--
|
||||
-- SCALAR. Selection compares ids with `==`, which honours `__eq`;
|
||||
-- collapse state stores them as TABLE KEYS, and Lua indexes tables by
|
||||
-- raw identity, consulting no metamethod. A table id would therefore
|
||||
-- satisfy one and quietly fail the other: after a refresh minted fresh
|
||||
-- id tables, selection would be restored and the fold would be lost.
|
||||
--
|
||||
-- Equality-aware collapse lookup is the alternative, and it is worse
|
||||
-- here: `hidden_by_ancestor` runs per row and would turn a linear
|
||||
-- render quadratic to support a key type no consumer has wanted.
|
||||
--
|
||||
-- NOT NaN. `0/0` passes a `type(x) == "number"` test and then *errors*
|
||||
-- at `p.collapsed[row.id]` with "table index is NaN" — the one scalar
|
||||
-- Lua accepts as a number and refuses as a key. Caught here so the
|
||||
-- report names the row, rather than surfacing on whichever later TAB
|
||||
-- happens to reach it.
|
||||
--
|
||||
-- UNIQUE. Every lookup here resolves an id to the FIRST row bearing
|
||||
-- it, so duplicates do not merely collide: selecting the second such
|
||||
-- row toggles the first and re-seats the cursor onto it. An id that
|
||||
-- does not identify a node is not an id, and the contract's word for
|
||||
-- itself is identity.
|
||||
--
|
||||
-- All three are enforced where rows enter, so a bad id is a named
|
||||
-- error at the call site instead of a lost fold or a stray jump later.
|
||||
local function check_ids(rows)
|
||||
local seen = {}
|
||||
for i, row in ipairs(rows) do
|
||||
local id, k = row.id, type(row.id)
|
||||
if id ~= nil then
|
||||
if k ~= "string" and k ~= "number" then
|
||||
error(string.format(
|
||||
"listview: row %d has a %s id; ids must be a string or number "
|
||||
.. "(collapse state keys a table by identity, so a %s id would "
|
||||
.. "lose its fold across a refresh)", i, k, k))
|
||||
end
|
||||
if id ~= id then
|
||||
error(string.format(
|
||||
"listview: row %d has a NaN id; NaN is a number but not a "
|
||||
.. "usable table key, and collapse state would raise "
|
||||
.. "\"table index is NaN\" on the first fold", i))
|
||||
end
|
||||
if seen[id] then
|
||||
error(string.format(
|
||||
"listview: rows %d and %d share the id %q; ids must be unique "
|
||||
.. "(every lookup resolves to the first match, so selecting "
|
||||
.. "the later row would toggle and re-seat the earlier one)",
|
||||
seen[id], i, tostring(id)))
|
||||
end
|
||||
seen[id] = i
|
||||
end
|
||||
end
|
||||
return rows
|
||||
end
|
||||
|
||||
local function render(p, rows)
|
||||
local lines = { p.header }
|
||||
p.visible = 0
|
||||
p.line_to_item = {}
|
||||
for _, row in ipairs(rows) do
|
||||
p.line_to_row = {}
|
||||
for i, row in ipairs(rows) do
|
||||
if not hidden_by_ancestor(p, rows, i) then
|
||||
lines[#lines + 1] = row.text
|
||||
-- SPARSE BY CONSTRUCTION: `item` is optional, and a display-only
|
||||
-- row (a grouping header in a tree, say) supplies none, so this
|
||||
-- key is simply absent for it. Nothing may take `#` of this
|
||||
-- table; `visible` below is the row count.
|
||||
p.line_to_item[#lines - 1] = row.item
|
||||
p.line_to_row[#lines - 1] = row
|
||||
p.visible = #lines - 1
|
||||
end
|
||||
end
|
||||
pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
|
||||
end
|
||||
|
||||
-- The data line currently showing `id`, or nil. Selection is re-seated
|
||||
-- BY ID rather than by line (Q#TR3): a collapse or expand inserts or
|
||||
-- removes rows above the cursor, so a line-keyed restore lands on an
|
||||
-- unrelated node.
|
||||
local function line_of_id(p, id)
|
||||
if id == nil then return nil end
|
||||
for line, row in pairs(p.line_to_row) do
|
||||
if row.id ~= nil and row.id == id then return line end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- Re-seat the cursor on data line `line` (1-based, clamped).
|
||||
-- `switch_active_buffer` zeroes the window cursor, so a fresh switch
|
||||
-- puts us on the header; walk down from there.
|
||||
local function seat_cursor(p, line)
|
||||
local count = #p.line_to_item
|
||||
-- `p.visible`, NOT `#p.line_to_item`: that map is sparse whenever a
|
||||
-- row omits the optional `item`, and `#` on a sparse table is not
|
||||
-- the row count. Reading it there left a tree of display-only rows
|
||||
-- with the cursor stranded on the header, where TAB finds no node.
|
||||
local count = p.visible or 0
|
||||
if count == 0 then return end
|
||||
local target = math.max(1, math.min(line or 1, count))
|
||||
for _ = 1, target do
|
||||
|
|
@ -146,6 +273,7 @@ local function bind_local_keymap(buf)
|
|||
bind("<down>", "cursor.down")
|
||||
bind("p", "cursor.up")
|
||||
bind("<up>", "cursor.up")
|
||||
bind("TAB", "listview.toggle")
|
||||
bind("g", "listview.refresh")
|
||||
bind("q", "listview.quit")
|
||||
end
|
||||
|
|
@ -186,7 +314,8 @@ local function ensure_panel(name)
|
|||
end
|
||||
|
||||
local buf = pmacs.buffer.create(actual)
|
||||
p = { requested_name = name, buffer = buf, line_to_item = {} }
|
||||
p = { requested_name = name, buffer = buf, line_to_item = {},
|
||||
line_to_row = {}, collapsed = {}, rows = {}, visible = 0 }
|
||||
panels[#panels + 1] = p
|
||||
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
|
||||
-- error. Kept beside the rope lock, not replaced by it: the layering
|
||||
|
|
@ -219,7 +348,12 @@ function pmacs.listview.open(spec)
|
|||
if active and not panel_for_buffer(active) then
|
||||
p.prev = active
|
||||
end
|
||||
render(p, spec.rows or {})
|
||||
-- Keep the row array: collapse re-renders from it WITHOUT calling the
|
||||
-- consumer, which is what lets a panel with no `on_refresh` still
|
||||
-- expand and collapse (the outline has none -- framing §1.5a).
|
||||
p.rows = check_ids(spec.rows or {})
|
||||
p.collapsed = {}
|
||||
render(p, p.rows)
|
||||
-- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and
|
||||
-- `listview.refresh` are active-window-only, so an interactive panel
|
||||
-- MUST take `select = true` or it would silently seat the wrong
|
||||
|
|
@ -261,7 +395,12 @@ pmacs.command.define {
|
|||
local p = active_panel()
|
||||
if not (p and p.on_refresh) then return end
|
||||
local saved = pmacs.editor.cursor_line()
|
||||
local rows = p.on_refresh() or {}
|
||||
-- Q#TR3: remember the NODE, not the line. A refresh that changes
|
||||
-- the row set moves every line; the id survives it.
|
||||
local saved_row = p.line_to_row[saved]
|
||||
local saved_id = saved_row and saved_row.id
|
||||
local rows = check_ids(p.on_refresh() or {})
|
||||
p.rows = rows
|
||||
render(p, rows)
|
||||
-- `set_generated_contents` has already refreshed this window's
|
||||
-- TextView. Re-seat through the editor primitives instead of
|
||||
|
|
@ -270,7 +409,63 @@ pmacs.command.define {
|
|||
pmacs.editor.clear_selection()
|
||||
pmacs.editor.set_view_top(0)
|
||||
pmacs.editor.move_to_line(0)
|
||||
seat_cursor(p, saved)
|
||||
seat_cursor(p, line_of_id(p, saved_id) or saved)
|
||||
end,
|
||||
}
|
||||
|
||||
-- TAB toggles the node under the cursor. A leaf is a no-op with a
|
||||
-- status, never a silent nothing -- the outline's `g` is already a
|
||||
-- dead binding that responds to nothing (framing §1.3a) and this
|
||||
-- primitive should not add a second one.
|
||||
pmacs.command.define {
|
||||
name = "listview.toggle",
|
||||
description = "Collapse or expand the tree node under the cursor.",
|
||||
fn = function()
|
||||
local p = active_panel()
|
||||
if not p then return end
|
||||
-- A FLAT panel must keep its pre-tree TAB behaviour exactly.
|
||||
--
|
||||
-- `bind_local_keymap` binds TAB for every listview, so this command
|
||||
-- now intercepts a key that previously fell through to the global
|
||||
-- `buffer.tab` and was refused by the Q#P3 read-only intercept.
|
||||
-- Emitting a listview status instead would be a behaviour change
|
||||
-- for the three flat consumers -- invisible to a byte-identity test,
|
||||
-- which sees the buffer and not the status line or the dispatch
|
||||
-- path. So a panel with no tree rows at all delegates.
|
||||
local is_tree = false
|
||||
for _, r in ipairs(p.rows) do
|
||||
if r.id ~= nil then is_tree = true break end
|
||||
end
|
||||
if not is_tree then
|
||||
pmacs.command.invoke("buffer.tab")
|
||||
return
|
||||
end
|
||||
|
||||
local line = pmacs.editor.cursor_line()
|
||||
local row = p.line_to_row[line]
|
||||
if not (row and row.id ~= nil) then
|
||||
pmacs.editor.set_status("listview: no node here")
|
||||
return
|
||||
end
|
||||
-- `has_children` reads the FULL row array, not the rendered subset:
|
||||
-- a collapsed node's children are absent from `line_to_row` by
|
||||
-- construction, so asking the rendered view whether it has any
|
||||
-- would answer "no" for every collapsed node and make expanding
|
||||
-- impossible.
|
||||
local idx
|
||||
for i, r in ipairs(p.rows) do
|
||||
if r.id ~= nil and r.id == row.id then idx = i break end
|
||||
end
|
||||
if not (idx and has_children(p.rows, idx)) then
|
||||
pmacs.editor.set_status("listview: no children")
|
||||
return
|
||||
end
|
||||
p.collapsed[row.id] = not p.collapsed[row.id] or nil
|
||||
render(p, p.rows)
|
||||
pmacs.editor.clear_selection()
|
||||
pmacs.editor.set_view_top(0)
|
||||
pmacs.editor.move_to_line(0)
|
||||
seat_cursor(p, line_of_id(p, row.id) or line)
|
||||
end,
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -2480,15 +2480,28 @@ function pmacs.lsp.document_symbols()
|
|||
for _, sym in ipairs(syms) do
|
||||
local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol"
|
||||
rows[#rows + 1] = {
|
||||
-- Tree primitive (docs/tree-primitive-framing.md): `depth` is
|
||||
-- STRUCTURAL and `text` stays rendered here (Q#TR4). Collapse
|
||||
-- only hides rows and never changes a surviving row's depth, so
|
||||
-- the indentation below remains correct without the primitive
|
||||
-- re-formatting anything.
|
||||
--
|
||||
-- `id` is line:col (Q#TR3) — unique per document and stable
|
||||
-- across a re-render. The `::` parent chain was rejected: it
|
||||
-- collides on overloads and same-named siblings, which is
|
||||
-- exactly where a stale expansion would reattach to the wrong
|
||||
-- node.
|
||||
text = string.format(
|
||||
"%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag),
|
||||
item = sym,
|
||||
depth = sym.depth or 0,
|
||||
id = string.format("%d:%d", sym.line, sym.col),
|
||||
}
|
||||
end
|
||||
pmacs.listview.open {
|
||||
name = "*outline*",
|
||||
header = string.format(
|
||||
"%d symbol%s RET visit n/p move q quit",
|
||||
"%d symbol%s RET visit TAB fold n/p move q quit",
|
||||
#syms, (#syms == 1 and "" or "s")),
|
||||
rows = rows,
|
||||
on_visit = function(sym)
|
||||
|
|
|
|||
|
|
@ -19,10 +19,21 @@ until review caught it — the #171 defect recurring. Its block is below.
|
|||
|
||||
**Updated later the same day.** #215 **merged** (`main` @ `12f2970`) and
|
||||
that lane is **rewritten, not removed** — rule 4 removes a lane when its
|
||||
ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) is now in
|
||||
flight on `ci-signal-hardening`, with its own lane block, its own
|
||||
checkpoint table, and a lane written **before** the PR was opened rather
|
||||
than after review asked for it.
|
||||
ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) ran on
|
||||
`ci-signal-hardening`, with its own lane block, its own checkpoint
|
||||
table, and a lane written **before** the PR was opened rather than
|
||||
after review asked for it. **It merged as #216 on 2026-08-05**, which
|
||||
completes the arc: R2 and R4 are retired with discriminating
|
||||
witnesses.
|
||||
|
||||
**The lane is kept, not yet removed, and that is a deferral rather than
|
||||
a judgement.** Rule 4 would remove it now — but its residue must be
|
||||
re-homed first, or removing it loses the state: **R1** is referred to
|
||||
the async-runtime lane (Q#MCI3) and **R3** is an unresolved possible
|
||||
product defect owned by the process-signal / PTY lane, neither of which
|
||||
has a block here yet. Retiring this arc and opening those two is an
|
||||
**absorption pass**, and doing it inside an unrelated feature PR is how
|
||||
a ledger acquires edits nobody reviewed.
|
||||
|
||||
**Updated 2026-08-04.** Four PRs landed since: the CI CRDT coverage
|
||||
lane #209, Distribution Stage 1 #211 (released as **v1.1.0**), the
|
||||
|
|
@ -169,7 +180,128 @@ form. All four steps ran clean. **The two-argument form still does not
|
|||
work** for a remote-only branch (`fatal: invalid reference`), which is
|
||||
why every lane below spells out the `-b` form.
|
||||
|
||||
## macOS CI signal integrity — STAGE 1 MERGED (#215); STAGE 2 IN REVIEW
|
||||
## Tree primitive (P5) — PR #217 OPEN, awaiting review
|
||||
|
||||
**PR #217** — https://github.com/levineuwirth/pmacs/pull/217. It was
|
||||
held through four framing rounds plus a review round; the hold is over
|
||||
and the work is with the user. Full gate suite green at the tip,
|
||||
including `PMACS_REQUIRE_GPU=1 -p pmacs-gpu` 221/0.
|
||||
|
||||
- **Branch `tree-primitive-framing`**, base `githubsucks/main` @
|
||||
`12f2970`. **Pushed and in sync** — held means no PR is open, not
|
||||
that the work is stranded locally, so it is portable per the
|
||||
handoff's rule. `githubsucks/tree-primitive-framing` is the
|
||||
authoritative tip; `d92f0ad` was it as of the line above, and any
|
||||
edit to this lane advances past it, which is why the ref and not a
|
||||
pinned SHA is the thing to trust. Recover with `git fetch githubsucks
|
||||
&& git checkout tree-primitive-framing`. Framing
|
||||
`docs/tree-primitive-framing.md` **revision 5** — approved after four
|
||||
review rounds, with Q#TR1–TR4 decided.
|
||||
- **The substantive arc** (not an exhaustive log — read `git log
|
||||
12f2970..githubsucks/tree-primitive-framing` for that): `61b1062`
|
||||
framing through `5186bfd` rev 4, which carried the `COHERENCE.md` §14
|
||||
call-site correction; `49a42ec` the primitive; `8f64c3b`
|
||||
byte-identity coverage plus the verification record; `38e94dc` this
|
||||
lane, §14's ✗ → ◐ and the handoff facts; `c59de95` the review round —
|
||||
moving-selection witness, flat-panel TAB delegation, §20's build →
|
||||
adopt correction.
|
||||
|
||||
### What it ships
|
||||
|
||||
`listview` gains **optional** `depth` and `id` on rows; absent, a row
|
||||
behaves exactly as before, which is what leaves the flat consumers
|
||||
untouched. Collapse state is **primitive-owned**, keyed by
|
||||
consumer-supplied id. Selection is re-seated **by id, not by line**.
|
||||
`TAB` toggles; a leaf reports rather than silently doing nothing.
|
||||
|
||||
**The observation that made it cheap:** collapse only ever *hides* rows
|
||||
and never changes a surviving row's depth, and consumers emit parents
|
||||
before children, so descendants are a **contiguous run**. Folding is
|
||||
therefore **local projection state, not a refresh protocol** — the
|
||||
primitive re-renders from its own array without calling the consumer,
|
||||
which is why the anchor consumer works at all: **the outline has no
|
||||
`on_refresh`**.
|
||||
|
||||
The LSP outline adopts, supplying `depth` and `id = line:col`; its
|
||||
`text` stays consumer-rendered per Q#TR4.
|
||||
|
||||
### Verification
|
||||
|
||||
| gate | result |
|
||||
|---|---|
|
||||
| luajit sweep | **3453 / 0** (= `main` 3450 + 3 listview tests) |
|
||||
| crdt sweep, isolated ×2 | **3722 / 0** (= `main` 3718 + 4 tests) |
|
||||
| `listview_acceptance` | 22 / 22 |
|
||||
| `m4_acceptance` | 150 / 150 (basedpyright skipped) |
|
||||
| `--lib` | 1896 / 0 |
|
||||
| fmt · diff-check · clippy ±crdt | pass |
|
||||
|
||||
Both behavioural claims are **bite-verified**: disabling the ancestor
|
||||
filter fails the fold test; an unconditional gutter fails the
|
||||
byte-identity test.
|
||||
|
||||
**One unclassified occurrence** — the first crdt sweep reported 7
|
||||
failures whose signatures were destroyed before being read. It is
|
||||
recorded in the framing's §6a, **not** as a row in
|
||||
`docs/ci-red-signatures.md`, because it has no normalized signature to
|
||||
match. Two non-causal hypotheses are recorded there; neither is testable
|
||||
now.
|
||||
|
||||
### Recovery, once pushed
|
||||
|
||||
```sh
|
||||
git fetch githubsucks
|
||||
git worktree add ../pmacs-tree \
|
||||
-b tree-primitive-framing \
|
||||
githubsucks/tree-primitive-framing
|
||||
```
|
||||
|
||||
### Not in scope
|
||||
|
||||
dired's `i` insert-subdirectory (the second consumer, its own stage);
|
||||
the other four §14 consumers; DAP's variables view; giving the outline a
|
||||
refresh, which is LSP request-lifecycle work.
|
||||
|
||||
## Leaked daemons from `gpu_invocation_acceptance` — NEEDS A LANE
|
||||
|
||||
**Found 2026-08-05 while cleaning up after the tree-primitive work. No
|
||||
branch, no framing.**
|
||||
|
||||
- **42 orphaned `pmacs --daemon` processes** were resident on the
|
||||
development machine, **the oldest 3 days 23 hours old**. All had been
|
||||
**reparented to systemd** (`ppid=1`) and all had **deleted sockets**,
|
||||
so nothing could ever reach or reap them.
|
||||
- **Source: `tests/gpu_invocation_acceptance.rs`** — the one-command
|
||||
tests, whose daemons carry `--socket <tempdir>/one-command.sock`. The
|
||||
tempdir is cleaned up; the daemon is not.
|
||||
- **Rate measured, not estimated: 3 per sweep.** A single isolated
|
||||
`--features luajit,crdt` sweep leaked exactly three. 42 is what
|
||||
several days of sweeps accumulate to.
|
||||
- **This predates the tree work** — the oldest is four days old — so it
|
||||
is a standing leak, not something a current lane introduced.
|
||||
|
||||
**Why it belongs to the reap-ledger family.** This is precisely the
|
||||
shape that lane exists for: a process that outlives its supervisor with
|
||||
nothing left watching it. The ledger arms only for `spec.group`, and
|
||||
these are daemons spawned by a test harness rather than by compile mode,
|
||||
so **nothing in the existing ledger covers them**.
|
||||
|
||||
**Why it matters beyond tidiness.** Dozens of resident daemons were
|
||||
present during every local sweep run this week, including the one that
|
||||
produced the unclassified failure recorded in the **tree-primitive lane
|
||||
above** (and, in full, in that lane's framing §6a). That
|
||||
makes them a **rival explanation** to the shared-target-dir mechanism
|
||||
for that occurrence, and neither can be tested against it now — the
|
||||
signatures were not captured. A leak that quietly changes the
|
||||
environment of every subsequent test run is a measurement problem as
|
||||
well as a resource one.
|
||||
|
||||
**First questions for whoever takes it:** does the test harness fail to
|
||||
reap, or does the daemon fail to exit when its socket disappears? Those
|
||||
have different fixes, and the second would be a product defect rather
|
||||
than a test one.
|
||||
|
||||
## macOS CI signal integrity — STAGES 1 AND 2 MERGED (#215, #216)
|
||||
|
||||
**This file requires a lane for every open PR** (see the #171/#174 note
|
||||
above: an open PR is exactly the volatile work this file records, and
|
||||
|
|
@ -180,16 +312,18 @@ which is where the same defect stops recurring.
|
|||
Framing `docs/macos-ci-signal-integrity-framing.md` **revision 3**,
|
||||
already approved. Stage 1 is acceptance 1–5; Stage 2 is acceptance 6–9.
|
||||
|
||||
### Stage 2 — hardening, IN REVIEW
|
||||
### Stage 2 — hardening, MERGED as #216
|
||||
|
||||
- **Branch `ci-signal-hardening`**, worktree `../pmacs-ci-signals`, base
|
||||
`githubsucks/main` @ `12f2970` (the #215 merge). Opened from an
|
||||
**isolated worktree** because the shared checkout was on another
|
||||
lane's branch with clean-but-foreign state; never switch it.
|
||||
- **PR: <https://github.com/levineuwirth/pmacs/pull/216>**. This block
|
||||
was written *with* the work, before the PR existed, so the row below
|
||||
was filled in rather than invented. **Checkpoints, newest last**,
|
||||
because a lane that records only one head goes stale on the next push:
|
||||
- **PR: <https://github.com/levineuwirth/pmacs/pull/216>, MERGED**
|
||||
2026-08-05 as `2657568`, all 14 checks green at head `8ab20b5`. This
|
||||
block was written *with* the work, before the PR existed, so the row
|
||||
below was filled in rather than invented. **Checkpoints, newest
|
||||
last**, because a lane that records only one head goes stale on the
|
||||
next push:
|
||||
|
||||
| head | CI run | result |
|
||||
|---|---|---|
|
||||
|
|
|
|||
|
|
@ -124,6 +124,36 @@ commands, read `docs/active-work.md` immediately after this file.
|
|||
- **A capability fallback must strip the QUIT ACTION too**, not just
|
||||
the side parameters — a quit action stranded on a document window
|
||||
makes a later `q` try to restore a presentation that never happened.
|
||||
- **The tree primitive ships — `listview` gained depth, collapse and
|
||||
identity** (P5, §14's last missing workbench primitive; implemented,
|
||||
PR held). Rows carry **optional** `depth` and `id`; absent, a row
|
||||
behaves exactly as before, which is what leaves the flat consumers
|
||||
untouched. The LSP outline is the one adopter. Durable facts:
|
||||
- **Folding is local projection state, not a refresh protocol.**
|
||||
Collapse only *hides* rows and never changes a surviving row's
|
||||
depth, and consumers emit parents before children, so descendants
|
||||
are a **contiguous run**. The primitive therefore re-renders from
|
||||
its own array **without calling the consumer** — which is the only
|
||||
reason the anchor consumer works, because **the outline has no
|
||||
`on_refresh` at all**. A design requiring the consumer to re-supply
|
||||
rows on every fold would have fitted no existing consumer.
|
||||
- **Identity is consumer-supplied and compared by equality; the
|
||||
primitive never derives one.** `item` is opaque by design. The
|
||||
outline uses `line:col`, because the `::` parent chain collides on
|
||||
overloads and same-named siblings — exactly where a stale expansion
|
||||
would reattach to the wrong node. **Selection is re-seated by id,
|
||||
not by line**, since a fold inserts or removes rows above the
|
||||
cursor.
|
||||
- **`has_children` must read the FULL row array, not the rendered
|
||||
subset.** A collapsed node's children are absent from the rendered
|
||||
map by construction, so asking the view would answer "no" for every
|
||||
collapsed node and make expanding impossible — a self-sealing bug
|
||||
that looks like fold working and unfold silently not.
|
||||
- **A bite that passes validates the pair, not the test.** The first
|
||||
byte-identity injection used `row.depth or 0`; flat rows have no
|
||||
depth, so it changed nothing and the test "passed" against a
|
||||
regression the flat path is immune to. Ask which defect you
|
||||
injected before believing a green bite.
|
||||
- **pmacs is installable without cloning — Distribution Stage 1, #211,
|
||||
released as v1.1.0.** A `v*` tag builds `pmacs` and `pmacs-gpu` on
|
||||
pinned `ubuntu-22.04` / `macos-15` and publishes a GitHub Release with
|
||||
|
|
@ -228,7 +258,7 @@ anchor, so every item is startable.
|
|||
| 2 | Workspace + location | Missing; model gap | The long-lead arc. Start before a fifth subsystem grows its own root convention — four have already diverged (§7) |
|
||||
| 3 | Extension ownership | Missing; prerequisite-shaped | **`pmacs.hook.remove` does not exist.** That one bug-sized gap blocks §13's disable/uninstall, §10's trust classes, and package-scoped cancellation |
|
||||
| 4 | **Discovery** | **Stage 1 MERGED (#207)** | Stage 2 candidates, in rough dependency order: richer M-x rows (**protocol change** — `MinibufferPrompt.candidates` is `Vec<String>`; `CompletionPopupRow` already proves the pattern), `Command` gaining title/category/aliases/flags/arg-schema (~147 definition sites), predicate evaluation, help-layer unification, and the help-prefix decision |
|
||||
| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (Stage 3 merged, #213) | The bottom panel is finished on both frontends and the adopter default is flipped. **The tree primitive is now the arc's successor** — `COHERENCE.md` §14 grades Tree ✗, and DAP's variables view is its next would-be inventor. Build it *before* dired's `i` and the worker tree invent two |
|
||||
| 5 | Workbench convergence | Partial; **Arc 7 COMPLETE** (#213) and **the tree primitive is implemented** (PR held) | The bottom panel is finished on both frontends and the adopter default is flipped. The tree primitive has landed on a held PR: §14's Tree moves ✗ → ◐ with the LSP outline as its one adopter. **Next: adoption** — dired's `i`, then DAP's variables view, which is why it was built first |
|
||||
| 6 | Config productization | Foundation only | Value provenance, then layering, then adoption migration (**table-valued settings are the hard prerequisite** — `ConfigValue` is four scalars) |
|
||||
| 7 | Package lifecycle | Not started | Correctly sequenced after P3 |
|
||||
| 8 | **Distribution** | **Stage 1 SHIPPED (v1.1.0, #211)** | Binaries on tag, checksums, machine-checked glibc floor. **Journey step 1 now works and the "invisible until this exists" blocker is lifted.** Next is a *decision* about channels / update / signing, not a queued plan |
|
||||
|
|
@ -317,18 +347,34 @@ someone forgot.
|
|||
`gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu`
|
||||
binary. `cargo build --workspace --no-default-features --features
|
||||
luajit,crdt` is the invocation that produces both binaries.
|
||||
- **A shared `CARGO_TARGET_DIR` makes a local sweep unattributable.**
|
||||
- **A shared `CARGO_TARGET_DIR` makes concurrent sweeps unattributable.**
|
||||
Every worktree on this machine resolves to the same target directory,
|
||||
so `target/debug/pmacs` is a **shared mutable file**: a concurrent
|
||||
`cargo test --workspace` in another worktree, at default (non-`crdt`)
|
||||
features, overwrites the binary that a running `crdt` sweep is
|
||||
spawning. That produced seven failures in three real-daemon suites
|
||||
during the Stage 2 hardening sweep while the baseline was clean, and
|
||||
the failure text named its own cause — *"start the daemon built with
|
||||
the `crdt` feature"*. Confirmed with `pgrep` and discriminated by
|
||||
re-running from the same tree with a **dedicated**
|
||||
`CARGO_TARGET_DIR`. **Check for other live worktrees before believing
|
||||
a sweep failure, and give the sweep its own target directory.**
|
||||
so `target/debug/pmacs` is a **shared mutable file**: a
|
||||
`cargo test --workspace` at default features in one worktree
|
||||
overwrites the binary a running `crdt` sweep is spawning, and every
|
||||
real-daemon suite then starts the wrong one. **Established once, on
|
||||
2026-08-05, in the Stage 2 hardening sweep** — seven failures across
|
||||
three real-daemon suites against a clean baseline, the failure text
|
||||
naming its own cause (*"start the daemon built with the `crdt`
|
||||
feature"*), `pgrep` confirming the rival build, and a re-run from the
|
||||
same tree with a **dedicated** `CARGO_TARGET_DIR` giving 41/41. The
|
||||
reciprocal case was seen from the other side the same day.
|
||||
**Give a second worktree its own target dir**, check for live
|
||||
worktrees before believing a sweep failure, and treat any red from a
|
||||
sweep that overlapped another build as unattributable rather than as
|
||||
evidence. *(This mechanism does NOT retroactively explain the tree
|
||||
lane's unclassified occurrence — that one's signatures were destroyed
|
||||
before being read, so it has no captured text to match against this
|
||||
one's, and it keeps two non-causal hypotheses. A mechanism
|
||||
established in one occurrence is not evidence about a different
|
||||
occurrence that was never characterized.)*
|
||||
- **A local sweep leaks daemons, and they accumulate across days.**
|
||||
`gpu_invocation_acceptance`'s one-command tests leave ~3 orphaned
|
||||
`pmacs --daemon` processes per sweep, reparented to systemd with
|
||||
deleted sockets; 42 were resident at one point, the oldest four days
|
||||
old. They are a rival explanation for any load-sensitive local
|
||||
failure, so **check `pgrep -f "pmacs --daemon"` before trusting a
|
||||
local red**. Lane recorded in `docs/active-work.md`.
|
||||
- **A local sweep is blind to whichever feature configuration it does
|
||||
not build.** Stage 3's census and every verification sweep ran
|
||||
`--features luajit` WITHOUT `crdt`, so no crdt-gated suite was
|
||||
|
|
@ -2033,7 +2079,20 @@ before trusting them:
|
|||
`m6_8_supervisor_reaps_all_children_across_cycles`) are timing-based;
|
||||
`editor::composition_overhead_under_ten_percent` is a render-ratio
|
||||
microbenchmark that fails ~1/3 even isolated single-threaded (already
|
||||
`cfg!(macos)`-disabled). Vterm Stage 3's merge CI saw one macOS timeout in
|
||||
`cfg!(macos)`-disabled). *(Local measurements, 2026-08-05, 16-core
|
||||
Linux, tree-primitive branch, which could not reach it — its diff
|
||||
versus `main` touches no `src/`, no crate, no manifest. **Two reds,
|
||||
both inside the full `--features crdt` lib run**, at `dispatch
|
||||
overhead` 30.7% and 10.3%; two full-suite runs at the same tips were
|
||||
green; and **10/10 green run alone**, ratios spanning -2.3% to
|
||||
+1.5%. So the in-suite/isolated split has now been seen twice, and
|
||||
the second red cleared the 10% budget by 0.3 points — the threshold
|
||||
is marginal, not comfortably clear. Recorded as measurements, NOT a
|
||||
cause: ten isolated greens establish that the ratio sits nowhere near
|
||||
the threshold when alone, not that in-suite contention is what pushed
|
||||
it over, and two reds against two greens in-suite is intermittence
|
||||
rather than a mechanism. Not a `ci-red-signatures.md` row either —
|
||||
that registry judges red **CI** runs, and these were local.)* Vterm Stage 3's merge CI saw one macOS timeout in
|
||||
`real_tui_terminal_smoke_restores_host_after_output_input_resize_scroll_copy_and_bell`;
|
||||
the complete failed-job rerun passed. The required-GPU gate also failed once
|
||||
in `headless_diag_face_recolors_band_counter_despite_unchanged_text`, then
|
||||
|
|
|
|||
|
|
@ -0,0 +1,594 @@
|
|||
# Framing — the tree primitive
|
||||
|
||||
**Revision 5.** Status: **IMPLEMENTED and GATED; PR HELD for review.**
|
||||
Q#TR1–TR4 decided at approval. Both sweeps green and both behavioural
|
||||
claims bite-verified — see §6a. Scouted against
|
||||
`githubsucks/main` @ `12f2970`. Carries a correction to `COHERENCE.md`
|
||||
§14 (revision 4, below).
|
||||
|
||||
**Revision 4 → 5** records the four decisions and makes acceptance final.
|
||||
|
||||
**The decision that made the others cheap:** collapse only ever *hides*
|
||||
rows — it never changes a surviving row's depth. Combined with §1.1's
|
||||
document order (**parents before children**), a node's descendants are a
|
||||
**contiguous run of following rows with greater depth**. So collapse is
|
||||
**filtering an existing array**, not re-deriving one. The primitive
|
||||
therefore never calls the consumer to re-render a collapse, and
|
||||
pre-rendered indentation stays correct — which is why Q#TR4 resolves
|
||||
toward the consumer keeping `text`.
|
||||
|
||||
| question | decision |
|
||||
|---|---|
|
||||
| **Q#TR1** | **Extend `listview`.** A separate `treeview` would either duplicate ~200 lines of panel discipline (Q#GB18 handle identity, Q#GB13 `<2>` disambiguation, the read-only intercept, `prev` capture, quit chain, generated-buffer writes) or require *extracting* them from a shipped primitive first — the riskier change. Extending is backward-compatible by construction: absent `depth`/`id` give today's behaviour exactly, which the three flat consumers already produce. |
|
||||
| **Q#TR2** | **Primitive-owned collapse state**, keyed by row id, held in the panel record. Consumer-owned would make every consumer reimplement refresh survival. |
|
||||
| **Q#TR3** | **Consumer-supplied `row.id`**, a **string or number**, compared by value; the primitive never derives one. *(Review narrowed this from "opaque": collapse state keys a table, and Lua table indexing ignores `__eq`, so an opaque id could restore selection while silently losing its fold. `check_ids` enforces it where rows enter — and with it that ids are **unique** and **not NaN**, since every lookup takes the first match (so a duplicate makes selecting the later row toggle the earlier) and `0/0` is the one scalar Lua counts as a number and refuses as a table key.)* The outline uses **`line:col`** — unique per document and stable across re-render, where the `::` parent chain collides on overloads. |
|
||||
| **Q#TR4** | **Consumer keeps pre-rendered `text`**; `depth` is structural only. Also sidesteps the conflict with dired's fixed-width `_layout` column contract when it adopts. |
|
||||
|
||||
**Acceptance 5 is decided too: byte-identity coverage is written**,
|
||||
including the fake-LSP harness work for `*references*`.
|
||||
|
||||
**Revision 3 → 4**:
|
||||
|
||||
- **"No `g`" was literally false**, in revisions 2 and 3 both.
|
||||
`bind_local_keymap` binds `g → listview.refresh` on **every** panel
|
||||
unconditionally (`listview.lua:147`). What three of the four lack is
|
||||
an `on_refresh`. The §1.3a table now separates **`g` bound**,
|
||||
**refresh advertised** and **refresh functional**, because those are
|
||||
three different facts and I had been collapsing them into one.
|
||||
Consequence worth noting on its own: the outline has a **dead refresh
|
||||
binding** — `g` is dispatched and silently does nothing, with no
|
||||
status message.
|
||||
- **`COHERENCE.md` §14 is corrected in this branch**, not deferred.
|
||||
§25 requires an audited claim to be updated by the PR that changes
|
||||
it; **#204 changed it and missed it**, so the correction rides the
|
||||
framing that found it. The `ad41cf1` audit fact is retained as
|
||||
history, with the current count of four and `*lsp*` named as the
|
||||
post-audit addition. The §0 scorecard row moves with the body, since
|
||||
it carried the same "3 call sites".
|
||||
|
||||
**Revision 2 → 3**, three further review findings, all verified in
|
||||
source:
|
||||
|
||||
- **`*references*` has no `on_refresh`** — revision 2 claimed it had
|
||||
refresh, twice. The consumer with refresh is **`*lsp*`** (§1.5a). The error
|
||||
came from reading `listview.lua`'s **module-docstring example**, which
|
||||
illustrates the API using `*references*` with `g refresh` in the
|
||||
header. An example is not a consumer. *The refresh-scoping conclusion
|
||||
is unaffected — it depended on the OUTLINE lacking refresh, which
|
||||
holds.*
|
||||
- **The branch plan still said "references or buffer-list"** — the same
|
||||
`*buffer-list*` error acceptance 5 had already been corrected for,
|
||||
surviving one section further down.
|
||||
- **"Byte-identically, pinned by their existing suites" was
|
||||
unsupported.** `listview_acceptance` states in its own header that
|
||||
`*references*` needs a live LSP and is not exercised there, and the m4
|
||||
hover test asserts content *presence*, not byte-exact output.
|
||||
Acceptance 5 now poses that as a decision — write the byte-identity
|
||||
test, or weaken the claim — with a leaning and its cost.
|
||||
|
||||
**Found while verifying those: §14's "exactly three call sites" is
|
||||
stale — there are FOUR** (§1.3a), and the fourth (`*lsp*`, added by
|
||||
#204) is the only one with refresh.
|
||||
|
||||
**Revision 1 → 2**, all from review, all verified against source before
|
||||
applying:
|
||||
|
||||
- **Refresh was unscoped for the anchor consumer.** Two criteria rested
|
||||
on `g` refresh; the outline has no `g`, no `on_refresh`, and
|
||||
`listview.refresh` no-ops without one. Refresh is now **out of scope**,
|
||||
with the LSP re-request question it actually raises stated (§1.5a).
|
||||
- **Acceptance 1 decided Q#TR4 while calling it open** — "no
|
||||
`string.rep` in `lsp.lua`" commits to primitive-owned indentation. It
|
||||
is representation-neutral now.
|
||||
- **Q#TR1 misread §14.** A tree is not the "second primitive" §14 warns
|
||||
against; §14 **explicitly lists one**. The warning is against bespoke
|
||||
per-consumer plumbing. Retradeoffed without prejudging.
|
||||
- **The regression criterion named the wrong consumers.**
|
||||
`*buffer-list*` and project search do **not** use listview — §14 says
|
||||
so and calls the older claim an error. The real siblings are
|
||||
`*references*` and `*lsp-help*`.
|
||||
- **Consumer accounting tightened**: five named future consumers, not
|
||||
six; **one** existing anchor plus **one** future constraint source,
|
||||
not "two that exist"; and "every input a tree needs" qualified, since
|
||||
stable identity is exactly what is missing.
|
||||
|
||||
`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 names five
|
||||
future consumers — project files, symbol hierarchy, package dependency
|
||||
graph, worker trees, git status — and designing a shared primitive
|
||||
against five hypothetical ones is how you get a model that fits none.
|
||||
|
||||
The scout found a narrower and firmer basis, and the distinction between
|
||||
its two halves matters:
|
||||
|
||||
- **One EXISTING anchor consumer.** The LSP outline already ships a
|
||||
tree and fakes it (§1.1). It is the only consumer that exists today.
|
||||
- **One FUTURE constraint source.** dired's `i` insert-subdirectory is
|
||||
scoped and deliberately deferred (§1.2). It constrains the design; it
|
||||
does not validate it, because nothing has been built against it.
|
||||
|
||||
Calling these "two consumers that exist" would overstate the evidence by
|
||||
exactly one.
|
||||
|
||||
---
|
||||
|
||||
## 0. Coherence impact (COHERENCE §20)
|
||||
|
||||
- **Concern: §14 Coherent Workbench Primitives.** Tree is the one
|
||||
remaining ✗ in its inventory. This closes it for the **one consumer
|
||||
that exists** (the LSP outline) 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<Symbol>`, 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 to RENDER is already computed** — depth,
|
||||
parent, and order — and only the view throws it away. That is not the
|
||||
same as every input a tree needs: **stable node identity is missing**
|
||||
(§1.5, Q#TR3), and it is the one input no existing field supplies.
|
||||
|
||||
**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 this constraint
|
||||
source carries 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.3a There are FOUR listview consumers now, not three
|
||||
|
||||
§14 measured "exactly **three** `pmacs.listview.open` call sites, all
|
||||
three in `builtin/runtime/lsp.lua`" at `ad41cf1`. **There are four**, and
|
||||
the fourth matters here:
|
||||
|
||||
| call site | panel | `on_visit` | `on_refresh` | `g` bound? | refresh advertised? | refresh FUNCTIONAL? |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `lsp.lua:2442` | `*references*` | yes | no | **yes** | no | **no** |
|
||||
| `lsp.lua:2488` | `*outline*` | yes | no | **yes** | no | **no** |
|
||||
| `lsp.lua:2924` | `*lsp-help*` | no | no | **yes** | no | **no** |
|
||||
| `lsp.lua:3004` | `*lsp*` (`lsp.status`) | no | **yes** | **yes** | **yes** | **yes** |
|
||||
|
||||
**`g` is bound on ALL FOUR.** `bind_local_keymap` binds
|
||||
`g → listview.refresh` for every panel unconditionally
|
||||
(`listview.lua:147`), so "no `g`" — which revisions 2 and 3 both said —
|
||||
is **literally false**. What three of them lack is an `on_refresh`, and
|
||||
`listview.refresh` returns immediately without one.
|
||||
|
||||
**So `g` on the outline is a DEAD BINDING**: bound, dispatched,
|
||||
silently does nothing. That is a small UX wart in its own right — a key
|
||||
that responds to nothing, with no status message — and it is a separate
|
||||
observation from anything this framing proposes. Recorded, not fixed
|
||||
here.
|
||||
|
||||
`*lsp*` arrived with Journey Stage 1b-2 (#204), after §14's audit. It is
|
||||
**the only listview consumer with refresh at all**, which is why §1.5a's
|
||||
scoping conclusion holds: refresh is a feature exactly one panel has, and
|
||||
it is not the anchor.
|
||||
|
||||
§14's line numbers have also drifted (`:2056`/`:2102`/`:2513` against
|
||||
today's `:2442`/`:2488`/`:2924`). The count is the part that matters;
|
||||
this is recorded so the next reader does not inherit "three".
|
||||
|
||||
### 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 = <any> }, ... },
|
||||
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 `<any>` —
|
||||
**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.5a …but the ANCHOR CONSUMER HAS NO REFRESH AT ALL
|
||||
|
||||
Revision 1 built two acceptance criteria on `g` refresh without checking
|
||||
that the outline supports it. **It does not:**
|
||||
|
||||
- its header offers `RET visit n/p move q quit` — refresh is **not
|
||||
advertised** (`lsp.lua:2490`);
|
||||
- it supplies **no `on_refresh`**;
|
||||
- `listview.refresh` opens `if not (p and p.on_refresh) then return
|
||||
end` — **a no-op** for this panel (`listview.lua:262`);
|
||||
- and `g` **is** bound regardless (§1.3a), so the outline has a
|
||||
**dead refresh binding**, not an absent one.
|
||||
|
||||
So "collapse state survives `g`" was unreachable for the only consumer
|
||||
that exists. **Refresh is therefore out of scope for this stage** unless
|
||||
someone first answers a question this framing does not: an outline
|
||||
refresh means **re-requesting `textDocument/documentSymbol`**, which is
|
||||
an async LSP round-trip with its own await, failure and staleness
|
||||
handling — and it raises who owns the resulting state when the response
|
||||
arrives against a buffer the user may have edited or left.
|
||||
|
||||
That is a real feature — **`*lsp*` (`lsp.status`) has `g refresh` and an
|
||||
`on_refresh`** (`lsp.lua:3004`); the outline never gained one — and it is
|
||||
**not** a tree concern. Bundling it here would make the tree lane
|
||||
responsible for LSP request lifecycle.
|
||||
|
||||
*(Revision 2 attributed refresh to `*references*` twice. It has neither:
|
||||
its header is `RET visit n/p move q quit` and it supplies only
|
||||
`on_visit` (`lsp.lua:2442`). The error came from reading
|
||||
`listview.lua`'s **module-docstring example**, which illustrates the API
|
||||
using `name = "*references*"` and a header containing `g refresh` — an
|
||||
example, not a consumer.)*
|
||||
|
||||
**Consequence for acceptance:** the criteria are re-scoped to what the
|
||||
anchor consumer can actually exercise — collapse and selection surviving
|
||||
**re-render**, which the primitive controls — and refresh-survival is
|
||||
recorded as a follow-on for whoever gives the outline a refresh.
|
||||
|
||||
### 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`?**
|
||||
|
||||
**Revision 1 framed this wrongly and the correction changes the
|
||||
tradeoff.** It claimed a separate treeview would be "exactly the
|
||||
second primitive §14 warns about". §14 does not warn against a tree —
|
||||
**it explicitly lists one**, alongside virtual list, in the reusable
|
||||
set it wants: *"editable text view, virtual list, **tree**, structured
|
||||
table, inspector…"* (`COHERENCE.md:1264`). A tree surface is a named
|
||||
goal, not a violation.
|
||||
|
||||
What §14 actually warns against is **bespoke per-consumer plumbing** —
|
||||
each subsystem inventing its own UI vocabulary. A `treeview` that
|
||||
shares the existing buffer/panel disciplines (generated-buffer writes,
|
||||
Q#GB18 handle identity, panel placement, `q` quit-action) is not that;
|
||||
a tree hand-rolled inside `lsp.lua` would be.
|
||||
|
||||
So the real tradeoff is narrower:
|
||||
- **Extending listview** touches three shipped call sites and the
|
||||
`line_to_item` contract, and risks making a working flat primitive
|
||||
worse for the consumers that do not need depth.
|
||||
- **A separate treeview** keeps the flat primitive untouched, but must
|
||||
*share* rather than *duplicate* the panel disciplines — and "shares
|
||||
them" is an implementation claim that has to be verified, not
|
||||
asserted.
|
||||
|
||||
**No leaning recorded**; the scout found nothing 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 `<any>` 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 supplies structural `depth` and `id`** so the
|
||||
primitive can collapse and expand, and `Symbol` is unchanged.
|
||||
**Per Q#TR4 its `text` stays consumer-rendered** — the `string.rep`
|
||||
indentation remains in `lsp.lua`, because collapse hides rows without
|
||||
changing any surviving row's depth, so pre-rendered indentation is
|
||||
still correct. `id` is `line:col`.
|
||||
2. **Collapse and expand work**, and **collapse state survives a
|
||||
re-render** — the primitive re-emitting the buffer from the same
|
||||
model. *(Not "survives `g` refresh": the anchor consumer has no
|
||||
refresh at all — §1.5a.)*
|
||||
3. **Selection survives a re-render 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. **listview's OTHER consumers are unaffected** — `*references*`,
|
||||
`*lsp-help*` and `*lsp*` (§1.3a: four call sites, all in `lsp.lua`).
|
||||
|
||||
**What existing suites actually pin, stated honestly.**
|
||||
`listview_acceptance` drives the substrate hermetically and says so
|
||||
in its own header: *"The references panel itself needs a live LSP and
|
||||
is validated manually / via the m4 harness"* — so **it does not
|
||||
exercise `*references*` at all**. The m4 hover test asserts content
|
||||
*presence*, not byte-exact output. **"Byte-identical, pinned by
|
||||
existing suites" was therefore unsupported** for both panels named.
|
||||
|
||||
So this criterion needs a decision, not a wording tweak:
|
||||
- **either** byte-identity becomes a **new test requirement** this
|
||||
stage writes — capturing each panel's rendered buffer before and
|
||||
after and diffing it, which needs the m4 fake-LSP harness for
|
||||
`*references*`;
|
||||
- **or** the claim weakens to what is genuinely pinned today:
|
||||
the substrate behaviours `listview_acceptance` covers (open,
|
||||
navigate, visit, `q` restore, the read-only intercept, the
|
||||
round-trip gate, refresh) plus content-presence for hover.
|
||||
|
||||
**DECIDED: write the byte-identity test**, including the fake-LSP
|
||||
harness work for `*references*`. A flat consumer silently gaining an
|
||||
indent column is exactly the regression this criterion exists to
|
||||
catch, and content-presence would not see it.
|
||||
|
||||
**Revision 1 named `*buffer-list*` and project search here and was
|
||||
wrong** — §14 measured that they do **not** use listview and calls
|
||||
the earlier claim a documentation error (`COHERENCE.md:1286`).
|
||||
Repeating it would have re-introduced a mistake that document exists
|
||||
to correct. If the broader surfaces are ever in scope, their
|
||||
independent render paths and tests must be named explicitly.
|
||||
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.
|
||||
- **Giving the outline a refresh** (§1.5a). **`*lsp*` has `g` and an
|
||||
`on_refresh`**; the outline never gained one. Adding it means
|
||||
re-requesting `textDocument/documentSymbol` with its own await,
|
||||
failure and staleness handling, and deciding who owns the result when
|
||||
it arrives against a buffer the user may have edited. **That is LSP
|
||||
request-lifecycle work, not tree work** — and only once it exists can
|
||||
"collapse survives refresh" be a criterion rather than an aspiration.
|
||||
|
||||
---
|
||||
|
||||
## 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.
|
||||
|
||||
---
|
||||
|
||||
## 6a. Verification record, including one unclassified occurrence
|
||||
|
||||
**The luajit sweep is 3453 / 0** and the count reconciles exactly:
|
||||
`main` is 3450 (Stage 3's 3449 sweep predated its own capability-fallback
|
||||
pin) plus this lane's three listview tests and one m4 test.
|
||||
|
||||
**The crdt sweep is 3722 / 0**, likewise +4 on `main`'s 3718.
|
||||
|
||||
### An UNCLASSIFIED, UNCAPTURED local occurrence
|
||||
|
||||
The **first** local crdt sweep of this branch reported **7 failures**.
|
||||
**It is recorded here as unclassified and it is deliberately NOT a row
|
||||
in `docs/ci-red-signatures.md`** — that registry keys on a normalized
|
||||
signature, and this occurrence has none to match, so a row would confer
|
||||
recognisability it cannot support.
|
||||
|
||||
**The signatures were destroyed before they were read.** The sweep was
|
||||
piped through an aggregation that emitted only totals. That is the exact
|
||||
failure the registry exists to prevent, committed one lane after writing
|
||||
it — and it is why the cause cannot now be established rather than
|
||||
merely being unknown.
|
||||
|
||||
**Re-runs, with what each does and does not support:**
|
||||
|
||||
| run | isolated? | result |
|
||||
|---|---|---|
|
||||
| first | no — concurrent with another lane's build | **7 failed, signatures lost** |
|
||||
| second | no | 3722 / 0 |
|
||||
| A | **no** — the isolation guard printed "aborting" and did not abort | 3722 / 0 |
|
||||
| B | **yes** — verified idle | 3722 / 0 |
|
||||
| C | **yes** — verified idle | 3722 / 0 |
|
||||
|
||||
Two genuinely isolated runs, both clean. **That supports repeatability
|
||||
under isolation. It does not establish what caused the original.**
|
||||
|
||||
### Two NON-CAUSAL hypotheses, neither testable now
|
||||
|
||||
Both are mechanisms known to have been present. Neither is offered as an
|
||||
explanation, because the occurrence's signatures no longer exist to test
|
||||
either against:
|
||||
|
||||
1. **Shared `CARGO_TARGET_DIR`.** Another lane's worktree shared
|
||||
`/home/jeans/build/cargo-target`, so its `cargo test --workspace`
|
||||
overwrote `target/debug/pmacs` mid-sweep. That lane observed the
|
||||
reciprocal case independently, caught the concurrent build with
|
||||
`pgrep`, and its failing text named its own cause ("start the daemon
|
||||
built with the `crdt` feature").
|
||||
2. **Resident leaked daemons.** ~40 orphaned `pmacs --daemon` processes
|
||||
were present, some four days old (see the lane in
|
||||
`docs/active-work.md`). Isolated sweeps leak 3–4 each, so the
|
||||
population was growing throughout.
|
||||
|
||||
**Having two plausible mechanisms and no way to discriminate is the
|
||||
result.** Reporting either as *the* cause would be the reasoning this
|
||||
project has rejected repeatedly: concluding something about an
|
||||
occurrence from something that was not about that occurrence.
|
||||
|
||||
## 7. Branch plan
|
||||
|
||||
Q#TR1 is decided, so the listview-extension shape applies:
|
||||
|
||||
1. **Extend `listview`** — optional `depth` / `id` on rows,
|
||||
primitive-owned collapse state, ancestor-collapsed filtering in
|
||||
`render`, selection re-seated **by id**, and a toggle binding. Absent
|
||||
`depth`/`id` must behave exactly as today.
|
||||
2. **Byte-identity proof for the flat consumers** (acceptance 5),
|
||||
landing *before* the outline adopts, so any regression in
|
||||
`*references*`, `*lsp-help*` or `*lsp*` is attributable to the
|
||||
primitive change rather than to adoption.
|
||||
3. **The outline adopts** — supplies `depth` and `id = line:col`, keeps
|
||||
its rendered `text`.
|
||||
4. **Lane, handoff and `COHERENCE.md` §14** updated; the §14 correction
|
||||
from revision 4 rides this PR per §25.
|
||||
|
||||
dired's `i` is **not** attempted here (§5).
|
||||
|
|
@ -739,6 +739,201 @@ fn s1_11_a_disambiguated_panel_still_answers_ret_g_and_q() {
|
|||
/// raw-switch and capability-fallback listview loops**. `s1_12` pins the
|
||||
/// second by keeping its panels in document windows; this pins the
|
||||
/// first.
|
||||
/// Tree primitive — a panel with `depth` + `id` collapses and expands,
|
||||
/// and BOTH the collapse state and the selection survive a re-render.
|
||||
///
|
||||
/// Acceptance 2 and 3. The re-render is what the primitive controls;
|
||||
/// `g` refresh is deliberately out of scope because the anchor consumer
|
||||
/// (the outline) has no `on_refresh` at all — see the framing's §1.5a.
|
||||
#[test]
|
||||
fn tr_1_collapse_hides_descendants_and_survives_re_render() {
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
r#"pmacs.listview.open {
|
||||
name = "*tree*",
|
||||
header = "tree TAB fold",
|
||||
rows = {
|
||||
{ text = "root", item = "root", depth = 0, id = "a" },
|
||||
{ text = " kid1", item = "kid1", depth = 1, id = "b" },
|
||||
{ text = " kid2", item = "kid2", depth = 1, id = "c" },
|
||||
{ text = "tail", item = "tail", depth = 0, id = "d" },
|
||||
},
|
||||
}"#,
|
||||
);
|
||||
let body = |s: &EditorState| active_text(s);
|
||||
assert!(
|
||||
body(&s).contains("kid1"),
|
||||
"children visible before collapse"
|
||||
);
|
||||
|
||||
// Cursor opens on the first data row (root); TAB collapses it.
|
||||
press(&mut s, KeyCode::Tab);
|
||||
let collapsed = body(&s);
|
||||
assert!(
|
||||
!collapsed.contains("kid1"),
|
||||
"descendants hidden: {collapsed}"
|
||||
);
|
||||
assert!(
|
||||
!collapsed.contains("kid2"),
|
||||
"ALL descendants hidden: {collapsed}"
|
||||
);
|
||||
assert!(
|
||||
collapsed.contains("root") && collapsed.contains("tail"),
|
||||
"the node itself and its SIBLING survive — collapse hides \
|
||||
descendants, not the following run: {collapsed}"
|
||||
);
|
||||
|
||||
// Selection is re-seated by ID, so the cursor is still on `root`.
|
||||
let on_root: String = eval(
|
||||
&s,
|
||||
"return pmacs.describe.buffer(pmacs.window.buffer()).name",
|
||||
);
|
||||
assert_eq!(on_root, "*tree*");
|
||||
|
||||
press(&mut s, KeyCode::Tab);
|
||||
assert!(
|
||||
body(&s).contains("kid1") && body(&s).contains("kid2"),
|
||||
"TAB again expands"
|
||||
);
|
||||
}
|
||||
|
||||
/// Selection survives a re-render that MOVES the selected node.
|
||||
///
|
||||
/// `tr_1` is not sufficient for this and was vacuous as a selection
|
||||
/// test: it toggles the ROOT, which occupies line 1 before and after the
|
||||
/// collapse, so the old line-based re-seating would pass it unchanged.
|
||||
/// A selection test has to move the node.
|
||||
///
|
||||
/// Here `on_refresh` inserts a child ABOVE the selected sibling, so the
|
||||
/// sibling's line shifts. Re-seating by line would land on the inserted
|
||||
/// row; re-seating by id stays on the sibling.
|
||||
#[test]
|
||||
fn tr_4_selection_follows_the_node_when_rows_are_inserted_above_it() {
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
r#"_G.EXTRA = false
|
||||
pmacs.listview.open {
|
||||
name = "*tree*", header = "tree",
|
||||
rows = {
|
||||
{ text = "root", item = "root", depth = 0, id = "a" },
|
||||
{ text = " kid", item = "kid", depth = 1, id = "b" },
|
||||
{ text = "sibling", item = "sibling", depth = 0, id = "z" },
|
||||
},
|
||||
on_refresh = function()
|
||||
if _G.EXTRA then
|
||||
return {
|
||||
{ text = "root", item = "root", depth = 0, id = "a" },
|
||||
{ text = " kid", item = "kid", depth = 1, id = "b" },
|
||||
{ text = " kid2", item = "kid2", depth = 1, id = "c" },
|
||||
{ text = "sibling", item = "sib", depth = 0, id = "z" },
|
||||
}
|
||||
end
|
||||
return {
|
||||
{ text = "root", item = "root", depth = 0, id = "a" },
|
||||
{ text = " kid", item = "kid", depth = 1, id = "b" },
|
||||
{ text = "sibling", item = "sib", depth = 0, id = "z" },
|
||||
}
|
||||
end,
|
||||
}"#,
|
||||
);
|
||||
|
||||
// Select `sibling` — data line 3.
|
||||
press(&mut s, KeyCode::Char('n'));
|
||||
press(&mut s, KeyCode::Char('n'));
|
||||
let line_before: i64 = eval(&s, "return pmacs.editor.cursor_line()");
|
||||
let text_at = |s: &EditorState| -> String {
|
||||
let body = active_text(s);
|
||||
let line: i64 = eval(s, "return pmacs.editor.cursor_line()");
|
||||
body.lines()
|
||||
.nth(usize::try_from(line).expect("line fits"))
|
||||
.unwrap_or_default()
|
||||
.to_string()
|
||||
};
|
||||
assert_eq!(text_at(&s), "sibling", "premise: sibling is selected");
|
||||
|
||||
// Refresh inserts `kid2` ABOVE sibling, so its line moves.
|
||||
exec(&s, "_G.EXTRA = true");
|
||||
press(&mut s, KeyCode::Char('g'));
|
||||
|
||||
let line_after: i64 = eval(&s, "return pmacs.editor.cursor_line()");
|
||||
// Substantive claim first, so a regression reports as what it is.
|
||||
// Under line-based re-seating the cursor stays on line 3, which now
|
||||
// holds the INSERTED row.
|
||||
assert_eq!(
|
||||
text_at(&s),
|
||||
"sibling",
|
||||
"selection follows the NODE, not the line"
|
||||
);
|
||||
// …and the fixture really did move it, so the assertion above is not
|
||||
// satisfied by the node happening to stay put (which is exactly how
|
||||
// `tr_1` is vacuous as a selection test).
|
||||
assert_ne!(
|
||||
line_before, line_after,
|
||||
"fixture: the insert must move the selected node"
|
||||
);
|
||||
}
|
||||
|
||||
/// A leaf reports rather than silently doing nothing.
|
||||
///
|
||||
/// The outline's `g` is already a dead binding — bound, dispatched, no
|
||||
/// feedback (framing §1.3a). This primitive must not add a second one.
|
||||
#[test]
|
||||
fn tr_2_toggling_a_leaf_reports_instead_of_silently_doing_nothing() {
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
r#"pmacs.listview.open {
|
||||
name = "*tree*", header = "tree",
|
||||
rows = { { text = "leaf", item = "leaf", depth = 0, id = "only" } },
|
||||
}"#,
|
||||
);
|
||||
press(&mut s, KeyCode::Tab);
|
||||
assert!(
|
||||
status(&s).contains("no children"),
|
||||
"a leaf toggle says so; got: {}",
|
||||
status(&s)
|
||||
);
|
||||
}
|
||||
|
||||
/// Rows WITHOUT `depth`/`id` behave exactly as before — the property
|
||||
/// that keeps the three flat consumers unaffected (acceptance 5).
|
||||
#[test]
|
||||
fn tr_3_a_flat_panel_is_untouched_by_the_tree_extension() {
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
r#"pmacs.listview.open {
|
||||
name = "*flat*", header = "flat",
|
||||
rows = { { text = "one", item = 1 }, { text = "two", item = 2 } },
|
||||
}"#,
|
||||
);
|
||||
let before = active_text(&s);
|
||||
let status_before = status(&s);
|
||||
press(&mut s, KeyCode::Tab);
|
||||
assert_eq!(
|
||||
active_text(&s),
|
||||
before,
|
||||
"TAB on a depthless panel changes nothing"
|
||||
);
|
||||
// Byte-identity of the BUFFER is not enough: TAB is bound for every
|
||||
// listview, so the tree command intercepts a key that previously
|
||||
// fell through to `buffer.tab` and the read-only intercept. A
|
||||
// listview-specific status here would be a behaviour change the
|
||||
// flat consumers never had, and invisible to a buffer comparison.
|
||||
assert!(
|
||||
!status(&s).contains("no node here") && !status(&s).contains("no children"),
|
||||
"a flat panel must not gain tree feedback; status was {:?} (was {:?})",
|
||||
status(&s),
|
||||
status_before
|
||||
);
|
||||
assert!(
|
||||
before.contains("one") && before.contains("two"),
|
||||
"both rows render: {before}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn s3_1_q_walks_the_side_presentation_chain_back_to_the_document() {
|
||||
let mut s = editor();
|
||||
|
|
@ -880,6 +1075,127 @@ fn s1_14_no_bypass_write_or_name_keyed_identity_remains() {
|
|||
);
|
||||
}
|
||||
|
||||
/// A tree row need not carry `item` — `on_visit` is optional, so a
|
||||
/// display-only node (a grouping header) is a legitimate row. The
|
||||
/// cursor must still seat on it.
|
||||
///
|
||||
/// This bit: `line_to_item` is SPARSE when rows omit `item`, and
|
||||
/// `seat_cursor` took `#` of it. For an all-display-only tree that
|
||||
/// length is 0, so the cursor never left the header — where TAB finds
|
||||
/// no row and answers "no node here", making the tree unfoldable.
|
||||
#[test]
|
||||
fn tr_5_a_tree_of_display_only_rows_is_still_navigable_and_foldable() {
|
||||
let mut s = editor();
|
||||
exec(
|
||||
&s,
|
||||
r#"pmacs.listview.open {
|
||||
name = "*tr5*",
|
||||
header = "display-only TAB fold",
|
||||
rows = {
|
||||
{ text = "root", depth = 0, id = "r" },
|
||||
{ text = " kid", depth = 1, id = "rk" },
|
||||
},
|
||||
}"#,
|
||||
);
|
||||
|
||||
// Seated on a data row, not stranded on the header.
|
||||
let line: i64 = eval(&s, "return pmacs.editor.cursor_line()");
|
||||
assert_eq!(
|
||||
line, 1,
|
||||
"cursor seats on the first data row despite no `item`"
|
||||
);
|
||||
|
||||
press(&mut s, KeyCode::Tab);
|
||||
assert!(
|
||||
!status(&s).contains("no node here"),
|
||||
"TAB found the node: {}",
|
||||
status(&s)
|
||||
);
|
||||
assert!(
|
||||
!active_text(&s).contains("kid"),
|
||||
"and folded it: {:?}",
|
||||
active_text(&s)
|
||||
);
|
||||
}
|
||||
|
||||
/// Q#TR3's contract is that ids compare by value. Collapse state keys
|
||||
/// a Lua table, and table indexing consults no `__eq`, so a non-scalar
|
||||
/// id would compare equal for selection and unequal for folding: a
|
||||
/// refresh would restore the cursor and silently lose the fold. The
|
||||
/// contract is narrowed to scalars and enforced where rows enter,
|
||||
/// rather than left to surface as a lost fold much later.
|
||||
#[test]
|
||||
fn tr_6_a_non_scalar_id_is_rejected_where_rows_enter() {
|
||||
let s = editor();
|
||||
let err: String = eval(
|
||||
&s,
|
||||
r#"local ok, e = pcall(function()
|
||||
pmacs.listview.open {
|
||||
name = "*tr6*",
|
||||
header = "h",
|
||||
rows = { { text = "a", depth = 0, id = {} } },
|
||||
}
|
||||
end)
|
||||
return tostring(e)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("ids must be a string or number"),
|
||||
"rejected where rows enter, with a reason: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A NaN id passes `type(x) == "number"` and then errors at
|
||||
/// `p.collapsed[row.id]` with "table index is NaN" — the one scalar
|
||||
/// Lua accepts as a number and refuses as a table key. It must be
|
||||
/// caught where rows enter, naming the row, rather than surfacing on
|
||||
/// whichever later TAB happens to reach it.
|
||||
#[test]
|
||||
fn tr_7_a_nan_id_is_rejected_rather_than_erroring_on_the_first_fold() {
|
||||
let s = editor();
|
||||
let err: String = eval(
|
||||
&s,
|
||||
r#"local ok, e = pcall(function()
|
||||
pmacs.listview.open {
|
||||
name = "*tr7*",
|
||||
header = "h",
|
||||
rows = { { text = "a", depth = 0, id = 0 / 0 } },
|
||||
}
|
||||
end)
|
||||
return tostring(e)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("NaN id"),
|
||||
"named at entry, not as a table-index error later: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Duplicate ids do not merely collide — every lookup resolves an id to
|
||||
/// the FIRST row bearing it, so selecting the second toggles the first
|
||||
/// and re-seats the cursor onto it. An id that does not identify a node
|
||||
/// is not an id.
|
||||
#[test]
|
||||
fn tr_8_duplicate_ids_are_rejected_because_lookup_takes_the_first_match() {
|
||||
let s = editor();
|
||||
let err: String = eval(
|
||||
&s,
|
||||
r#"local ok, e = pcall(function()
|
||||
pmacs.listview.open {
|
||||
name = "*tr8*",
|
||||
header = "h",
|
||||
rows = {
|
||||
{ text = "first", depth = 0, id = "same" },
|
||||
{ text = "second", depth = 0, id = "same" },
|
||||
},
|
||||
}
|
||||
end)
|
||||
return tostring(e)"#,
|
||||
);
|
||||
assert!(
|
||||
err.contains("share the id") && err.contains("rows 1 and 2"),
|
||||
"both offending rows named: {err}"
|
||||
);
|
||||
}
|
||||
|
||||
// Isolated bootstrap storage roots (see the module docs): an
|
||||
// integration test is compiled without `cfg(test)`, so a raw
|
||||
// `EditorState::new()` would read the developer's real `init.lua` and
|
||||
|
|
|
|||
|
|
@ -8025,6 +8025,99 @@ fn open_against_fake(path: &std::path::Path) -> pmacs::editor::EditorState {
|
|||
/// hierarchical documentSymbol response ("Outer" class > "inner"
|
||||
/// method): open, depth-indented rows, RET jump-ring visit to the
|
||||
/// symbol's selectionRange, M-, back to the outline row, q restore.
|
||||
/// Tree primitive, acceptance 5 — the FLAT listview consumers render
|
||||
/// **byte-identically** after the depth/collapse extension.
|
||||
///
|
||||
/// This exists because the weaker claim was not true. `listview_
|
||||
/// acceptance` says in its own header that the references panel "needs
|
||||
/// a live LSP and is validated manually / via the m4 harness", so it
|
||||
/// does not exercise `*references*` at all; and the hover test asserts
|
||||
/// content *presence*, not exact output. Neither would notice a flat
|
||||
/// consumer silently gaining an indent column — which is precisely the
|
||||
/// regression a tree extension can introduce.
|
||||
///
|
||||
/// So the assertion is on the **exact rendered bytes**, through the
|
||||
/// real entry points, against the fake language server.
|
||||
#[test]
|
||||
fn flat_listview_consumers_render_byte_identically_after_the_tree_extension() {
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let a_path = dir.path().join("r.rs");
|
||||
std::fs::write(&a_path, b"fn main() {}\n").expect("write r");
|
||||
let mut state = open_against_fake(&a_path);
|
||||
|
||||
let body = |state: &pmacs::editor::EditorState| -> String {
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("local b = pmacs.window.buffer() return b:slice(0, b:len())")
|
||||
.eval()
|
||||
.expect("panel text")
|
||||
};
|
||||
|
||||
// --- *references* (on_visit, no depth) ---
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.lsp.find_references()")
|
||||
.exec()
|
||||
.expect("invoke find_references");
|
||||
assert!(
|
||||
pump_lua_flag(
|
||||
&mut state,
|
||||
"pmacs.describe.buffer(pmacs.window.buffer()).name == '*references*'",
|
||||
5,
|
||||
),
|
||||
"the references panel opened"
|
||||
);
|
||||
let refs = body(&state);
|
||||
let (header, rows) = refs.split_once('\n').expect("header then rows");
|
||||
assert_eq!(
|
||||
header, "1 reference RET visit n/p move q quit",
|
||||
"the header is unchanged — no fold affordance is advertised on a \
|
||||
flat panel"
|
||||
);
|
||||
// EXACT: the row is the location string and nothing else. An added
|
||||
// indent column, tree gutter or fold marker would all fail here.
|
||||
assert_eq!(
|
||||
rows,
|
||||
format!("{}:12:3", a_path.display()),
|
||||
"the flat references row renders verbatim"
|
||||
);
|
||||
|
||||
// --- *lsp* (on_refresh, no depth) ---
|
||||
state
|
||||
.lua_host
|
||||
.lua()
|
||||
.load("pmacs.command.invoke('lsp.status')")
|
||||
.exec()
|
||||
.expect("invoke lsp.status");
|
||||
let status_body = body(&state);
|
||||
let (status_header, status_rows) = status_body.split_once('\n').expect("header then rows");
|
||||
assert_eq!(
|
||||
status_header, "LSP status g refresh q quit",
|
||||
"the one panel WITH refresh keeps its exact header"
|
||||
);
|
||||
// `*lsp*` formats its OWN indentation — two spaces on detail lines —
|
||||
// so "starts with a space" is not a violation here. What must hold
|
||||
// is that the primitive reproduces the consumer's text EXACTLY: a
|
||||
// prefix added by render would shift this line and break the match.
|
||||
//
|
||||
// Matched as a whole line rather than a substring, because a
|
||||
// substring would still be found inside a further-indented version
|
||||
// of itself. Volatile parts (pid, elapsed) are deliberately not
|
||||
// included.
|
||||
assert!(
|
||||
status_rows
|
||||
.lines()
|
||||
.any(|l| l == " capabilities: sync, hover, completion, definition, diagnostics"),
|
||||
"the consumer's own two-space indentation survives verbatim; got:\n{status_rows}"
|
||||
);
|
||||
assert!(
|
||||
status_rows.lines().any(|l| l == "Servers:"),
|
||||
"an unindented row stays unindented; got:\n{status_rows}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[allow(
|
||||
clippy::too_many_lines,
|
||||
|
|
|
|||
Loading…
Reference in New Issue