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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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)
|
buffer-local keymap idiom (RET/SPC visit, n/p, g refresh, q quit)
|
||||||
that is inspectable and rebindable (§6's counter-example). **But its
|
that is inspectable and rebindable (§6's counter-example). **But its
|
||||||
adoption is narrower than this document claimed, and the correction
|
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
|
exactly **three** `pmacs.listview.open` call sites, **all three in
|
||||||
`builtin/runtime/lsp.lua`** — `*references*` (`:2056`), `*outline*`
|
`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
|
mentions under `builtin/` are comments in `compile.lua` and
|
||||||
`dired.lua` citing "the listview idiom", which is a *pattern being
|
`dired.lua` citing "the listview idiom", which is a *pattern being
|
||||||
copied*, not the primitive being used.
|
copied*, not the primitive being used.
|
||||||
|
|
@ -1354,15 +1367,27 @@ Primitive-by-primitive against the list above:
|
||||||
(§9).
|
(§9).
|
||||||
- **Help view** △ — exists twice (§5); needs unification, not
|
- **Help view** △ — exists twice (§5); needs unification, not
|
||||||
invention.
|
invention.
|
||||||
- **Tree** ✗ — none. The named future consumers (project files, symbol
|
- **Tree** ◐ — **implemented, one consumer.** `listview` carries
|
||||||
hierarchy, package dependency graph, worker trees, git status) will
|
optional `depth` and `id` on rows, primitive-owned collapse state, and
|
||||||
each need it; building it once *before* dired's directory view and
|
selection re-seated by id rather than by line; `TAB` toggles. Absent
|
||||||
the workers tree harden their own conventions is exactly this
|
`depth`/`id`, a row behaves exactly as before, which is what leaves
|
||||||
section's point. Dired Stage 1 (merged #165) landed **without** inventing
|
the flat consumers untouched (pinned by byte-identity coverage).
|
||||||
one: its listing is flat (Emacs parity), and the recursive
|
**The LSP outline is the only adopter**: it previously flattened a
|
||||||
in-buffer case — `i` insert-subdirectory — is a named deferral in
|
genuine `DocumentSymbol` tree into indented strings, and now supplies
|
||||||
`docs/dired-framing.md` §13, which is where a shared tree primitive
|
structure while keeping its rendered text.
|
||||||
would land.
|
|
||||||
|
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.*`
|
- **Structured table / inspector / diff view** ✗ — none. (`describe.*`
|
||||||
tables are the inspector's data model without a view; the
|
tables are the inspector's data model without a view; the
|
||||||
wire-declared `ResourceOffer` family was reserved for diff/blame
|
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
|
**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
|
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
|
`display` means the panel. Arc 7 is complete.**
|
||||||
and the worker tree invent two), table/inspector/diff, help unification.
|
|
||||||
Wiring plus one modest model piece (the tree model).
|
**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
|
### Priority 6: Productize configuration
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -114,21 +114,148 @@ end
|
||||||
-- `M-x buffer.undo` did too, and no rebinding can remove that. The
|
-- `M-x buffer.undo` did too, and no rebinding can remove that. The
|
||||||
-- primitive lifts the rope lock, writes, discards the history and
|
-- primitive lifts the rope lock, writes, discards the history and
|
||||||
-- re-asserts the lock, all inside one registry borrow.
|
-- 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 function render(p, rows)
|
||||||
local lines = { p.header }
|
local lines = { p.header }
|
||||||
|
p.visible = 0
|
||||||
p.line_to_item = {}
|
p.line_to_item = {}
|
||||||
for _, row in ipairs(rows) do
|
p.line_to_row = {}
|
||||||
lines[#lines + 1] = row.text
|
for i, row in ipairs(rows) do
|
||||||
p.line_to_item[#lines - 1] = row.item
|
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
|
end
|
||||||
pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
|
pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
|
||||||
end
|
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).
|
-- Re-seat the cursor on data line `line` (1-based, clamped).
|
||||||
-- `switch_active_buffer` zeroes the window cursor, so a fresh switch
|
-- `switch_active_buffer` zeroes the window cursor, so a fresh switch
|
||||||
-- puts us on the header; walk down from there.
|
-- puts us on the header; walk down from there.
|
||||||
local function seat_cursor(p, line)
|
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
|
if count == 0 then return end
|
||||||
local target = math.max(1, math.min(line or 1, count))
|
local target = math.max(1, math.min(line or 1, count))
|
||||||
for _ = 1, target do
|
for _ = 1, target do
|
||||||
|
|
@ -146,6 +273,7 @@ local function bind_local_keymap(buf)
|
||||||
bind("<down>", "cursor.down")
|
bind("<down>", "cursor.down")
|
||||||
bind("p", "cursor.up")
|
bind("p", "cursor.up")
|
||||||
bind("<up>", "cursor.up")
|
bind("<up>", "cursor.up")
|
||||||
|
bind("TAB", "listview.toggle")
|
||||||
bind("g", "listview.refresh")
|
bind("g", "listview.refresh")
|
||||||
bind("q", "listview.quit")
|
bind("q", "listview.quit")
|
||||||
end
|
end
|
||||||
|
|
@ -186,7 +314,8 @@ local function ensure_panel(name)
|
||||||
end
|
end
|
||||||
|
|
||||||
local buf = pmacs.buffer.create(actual)
|
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
|
panels[#panels + 1] = p
|
||||||
-- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
|
-- 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
|
-- 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
|
if active and not panel_for_buffer(active) then
|
||||||
p.prev = active
|
p.prev = active
|
||||||
end
|
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
|
-- Bottom-panel arc (Q#BP11b): the placement opt-in. `seat_cursor` and
|
||||||
-- `listview.refresh` are active-window-only, so an interactive panel
|
-- `listview.refresh` are active-window-only, so an interactive panel
|
||||||
-- MUST take `select = true` or it would silently seat the wrong
|
-- MUST take `select = true` or it would silently seat the wrong
|
||||||
|
|
@ -261,7 +395,12 @@ pmacs.command.define {
|
||||||
local p = active_panel()
|
local p = active_panel()
|
||||||
if not (p and p.on_refresh) then return end
|
if not (p and p.on_refresh) then return end
|
||||||
local saved = pmacs.editor.cursor_line()
|
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)
|
render(p, rows)
|
||||||
-- `set_generated_contents` has already refreshed this window's
|
-- `set_generated_contents` has already refreshed this window's
|
||||||
-- TextView. Re-seat through the editor primitives instead of
|
-- TextView. Re-seat through the editor primitives instead of
|
||||||
|
|
@ -270,7 +409,63 @@ pmacs.command.define {
|
||||||
pmacs.editor.clear_selection()
|
pmacs.editor.clear_selection()
|
||||||
pmacs.editor.set_view_top(0)
|
pmacs.editor.set_view_top(0)
|
||||||
pmacs.editor.move_to_line(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,
|
end,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2480,15 +2480,28 @@ function pmacs.lsp.document_symbols()
|
||||||
for _, sym in ipairs(syms) do
|
for _, sym in ipairs(syms) do
|
||||||
local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol"
|
local tag = SYMBOL_KIND_TAGS[sym.kind] or "symbol"
|
||||||
rows[#rows + 1] = {
|
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(
|
text = string.format(
|
||||||
"%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag),
|
"%s%s [%s]", string.rep(" ", sym.depth or 0), sym.name, tag),
|
||||||
item = sym,
|
item = sym,
|
||||||
|
depth = sym.depth or 0,
|
||||||
|
id = string.format("%d:%d", sym.line, sym.col),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
pmacs.listview.open {
|
pmacs.listview.open {
|
||||||
name = "*outline*",
|
name = "*outline*",
|
||||||
header = string.format(
|
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")),
|
#syms, (#syms == 1 and "" or "s")),
|
||||||
rows = rows,
|
rows = rows,
|
||||||
on_visit = function(sym)
|
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
|
**Updated later the same day.** #215 **merged** (`main` @ `12f2970`) and
|
||||||
that lane is **rewritten, not removed** — rule 4 removes a lane when its
|
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
|
ARC is done, and Stage 2 is the arc. Stage 2 (**hardening**) ran on
|
||||||
flight on `ci-signal-hardening`, with its own lane block, its own
|
`ci-signal-hardening`, with its own lane block, its own checkpoint
|
||||||
checkpoint table, and a lane written **before** the PR was opened rather
|
table, and a lane written **before** the PR was opened rather than
|
||||||
than after review asked for it.
|
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
|
**Updated 2026-08-04.** Four PRs landed since: the CI CRDT coverage
|
||||||
lane #209, Distribution Stage 1 #211 (released as **v1.1.0**), the
|
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
|
work** for a remote-only branch (`fatal: invalid reference`), which is
|
||||||
why every lane below spells out the `-b` form.
|
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
|
**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
|
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**,
|
Framing `docs/macos-ci-signal-integrity-framing.md` **revision 3**,
|
||||||
already approved. Stage 1 is acceptance 1–5; Stage 2 is acceptance 6–9.
|
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
|
- **Branch `ci-signal-hardening`**, worktree `../pmacs-ci-signals`, base
|
||||||
`githubsucks/main` @ `12f2970` (the #215 merge). Opened from an
|
`githubsucks/main` @ `12f2970` (the #215 merge). Opened from an
|
||||||
**isolated worktree** because the shared checkout was on another
|
**isolated worktree** because the shared checkout was on another
|
||||||
lane's branch with clean-but-foreign state; never switch it.
|
lane's branch with clean-but-foreign state; never switch it.
|
||||||
- **PR: <https://github.com/levineuwirth/pmacs/pull/216>**. This block
|
- **PR: <https://github.com/levineuwirth/pmacs/pull/216>, MERGED**
|
||||||
was written *with* the work, before the PR existed, so the row below
|
2026-08-05 as `2657568`, all 14 checks green at head `8ab20b5`. This
|
||||||
was filled in rather than invented. **Checkpoints, newest last**,
|
block was written *with* the work, before the PR existed, so the row
|
||||||
because a lane that records only one head goes stale on the next push:
|
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 |
|
| 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
|
- **A capability fallback must strip the QUIT ACTION too**, not just
|
||||||
the side parameters — a quit action stranded on a document window
|
the side parameters — a quit action stranded on a document window
|
||||||
makes a later `q` try to restore a presentation that never happened.
|
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,
|
- **pmacs is installable without cloning — Distribution Stage 1, #211,
|
||||||
released as v1.1.0.** A `v*` tag builds `pmacs` and `pmacs-gpu` on
|
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
|
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) |
|
| 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 |
|
| 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 |
|
| 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) |
|
| 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 |
|
| 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 |
|
| 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`
|
`gpu_invocation_acceptance` tests fail on a missing `pmacs-gpu`
|
||||||
binary. `cargo build --workspace --no-default-features --features
|
binary. `cargo build --workspace --no-default-features --features
|
||||||
luajit,crdt` is the invocation that produces both binaries.
|
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,
|
Every worktree on this machine resolves to the same target directory,
|
||||||
so `target/debug/pmacs` is a **shared mutable file**: a concurrent
|
so `target/debug/pmacs` is a **shared mutable file**: a
|
||||||
`cargo test --workspace` in another worktree, at default (non-`crdt`)
|
`cargo test --workspace` at default features in one worktree
|
||||||
features, overwrites the binary that a running `crdt` sweep is
|
overwrites the binary a running `crdt` sweep is spawning, and every
|
||||||
spawning. That produced seven failures in three real-daemon suites
|
real-daemon suite then starts the wrong one. **Established once, on
|
||||||
during the Stage 2 hardening sweep while the baseline was clean, and
|
2026-08-05, in the Stage 2 hardening sweep** — seven failures across
|
||||||
the failure text named its own cause — *"start the daemon built with
|
three real-daemon suites against a clean baseline, the failure text
|
||||||
the `crdt` feature"*. Confirmed with `pgrep` and discriminated by
|
naming its own cause (*"start the daemon built with the `crdt`
|
||||||
re-running from the same tree with a **dedicated**
|
feature"*), `pgrep` confirming the rival build, and a re-run from the
|
||||||
`CARGO_TARGET_DIR`. **Check for other live worktrees before believing
|
same tree with a **dedicated** `CARGO_TARGET_DIR` giving 41/41. The
|
||||||
a sweep failure, and give the sweep its own target directory.**
|
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
|
- **A local sweep is blind to whichever feature configuration it does
|
||||||
not build.** Stage 3's census and every verification sweep ran
|
not build.** Stage 3's census and every verification sweep ran
|
||||||
`--features luajit` WITHOUT `crdt`, so no crdt-gated suite was
|
`--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;
|
`m6_8_supervisor_reaps_all_children_across_cycles`) are timing-based;
|
||||||
`editor::composition_overhead_under_ten_percent` is a render-ratio
|
`editor::composition_overhead_under_ten_percent` is a render-ratio
|
||||||
microbenchmark that fails ~1/3 even isolated single-threaded (already
|
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`;
|
`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
|
the complete failed-job rerun passed. The required-GPU gate also failed once
|
||||||
in `headless_diag_face_recolors_band_counter_despite_unchanged_text`, then
|
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
|
/// raw-switch and capability-fallback listview loops**. `s1_12` pins the
|
||||||
/// second by keeping its panels in document windows; this pins the
|
/// second by keeping its panels in document windows; this pins the
|
||||||
/// first.
|
/// 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]
|
#[test]
|
||||||
fn s3_1_q_walks_the_side_presentation_chain_back_to_the_document() {
|
fn s3_1_q_walks_the_side_presentation_chain_back_to_the_document() {
|
||||||
let mut s = editor();
|
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
|
// Isolated bootstrap storage roots (see the module docs): an
|
||||||
// integration test is compiled without `cfg(test)`, so a raw
|
// integration test is compiled without `cfg(test)`, so a raw
|
||||||
// `EditorState::new()` would read the developer's real `init.lua` and
|
// `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"
|
/// hierarchical documentSymbol response ("Outer" class > "inner"
|
||||||
/// method): open, depth-indented rows, RET jump-ring visit to the
|
/// method): open, depth-indented rows, RET jump-ring visit to the
|
||||||
/// symbol's selectionRange, M-, back to the outline row, q restore.
|
/// 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]
|
#[test]
|
||||||
#[allow(
|
#[allow(
|
||||||
clippy::too_many_lines,
|
clippy::too_many_lines,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue