Merge pull request #94 from levineuwirth/session-lsp-panels-p1
feat(panels): listview substrate + references panel — Arc 1b phase 1
This commit is contained in:
commit
0702fd8dc4
|
|
@ -44,6 +44,16 @@ define {
|
|||
kind = "all-must-succeed",
|
||||
}
|
||||
|
||||
define {
|
||||
name = "buffer.after-switch",
|
||||
description = "Fired after the active window switches to a different, " ..
|
||||
"already-open buffer (C-x b, panel visits, find_or_open of " ..
|
||||
"an open file). Switching clears the window's overlays; " ..
|
||||
"syntax/LSP subscribers re-attach theirs here. Fresh loads " ..
|
||||
"fire buffer.after-load instead.",
|
||||
kind = "all-must-succeed",
|
||||
}
|
||||
|
||||
define {
|
||||
name = "buffer.after-save",
|
||||
description = "Fired after a successful save. LSP did_save and " ..
|
||||
|
|
|
|||
|
|
@ -0,0 +1,169 @@
|
|||
-- listview.lua --- reusable read-only list panels (Arc 1b, Q#P1).
|
||||
--
|
||||
-- Generalizes the *buffer-list* idiom (builtin/commands/default.lua)
|
||||
-- into `pmacs.listview.open{...}`: a persistent named buffer,
|
||||
-- wholesale re-render, buffer-local RET/n/p/g/q keymap, a
|
||||
-- line->item map, previous-buffer capture + `q` restore, and the two
|
||||
-- disciplines the hand-rolled original lacks --- a read-only
|
||||
-- intercept (Q#P3; the panel's own renders write with
|
||||
-- bypass_intercept) and the Q#P6 round-trip-input mark, so a
|
||||
-- semantic frontend's RET dispatches into the visit binding instead
|
||||
-- of optimistically inserting a newline.
|
||||
--
|
||||
-- Panels are buffers, so both frontends render them with zero
|
||||
-- protocol change (Q#P2: switch-in-place; the GPU cannot show
|
||||
-- splits). Framing: docs/lsp-panels-framing.md.
|
||||
--
|
||||
-- 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, -- RET/SPC (optional)
|
||||
-- on_refresh = function() return rows end, -- g (optional)
|
||||
-- }
|
||||
|
||||
pmacs.listview = pmacs.listview or {}
|
||||
|
||||
-- name -> { buffer, prev, header, line_to_item, on_visit, on_refresh }
|
||||
local panels = {}
|
||||
|
||||
local function find_buffer_by_name(name)
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
local ok, d = pcall(pmacs.describe.buffer, id)
|
||||
if ok and d and d.name == name then return id end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
-- The panel record whose buffer the active window shows, or nil.
|
||||
local function panel_for_current_buffer()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return nil end
|
||||
local ok, d = pcall(pmacs.describe.buffer, buf)
|
||||
if not (ok and d) then return nil end
|
||||
return panels[d.name]
|
||||
end
|
||||
|
||||
-- Wholesale re-render: header + one line per row, rebuilding the
|
||||
-- line->item map (data lines are 1-based; the header is line 0).
|
||||
-- Panel writes bypass the read-only intercept.
|
||||
local function render(p, rows)
|
||||
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
|
||||
local body = table.concat(lines, "\n")
|
||||
local buf = p.buffer
|
||||
local len = buf:len()
|
||||
if len > 0 then buf:delete(0, len, { bypass_intercept = true }) end
|
||||
if #body > 0 then buf:insert(0, body, { bypass_intercept = true }) end
|
||||
end
|
||||
|
||||
-- Re-seat the cursor on data line `line` (1-based, clamped).
|
||||
-- `switch_active_buffer` zeroes the window cursor, so a fresh switch
|
||||
-- puts us on the header; walk down from there.
|
||||
local function seat_cursor(p, line)
|
||||
local count = #p.line_to_item
|
||||
if count == 0 then return end
|
||||
local target = math.max(1, math.min(line or 1, count))
|
||||
for _ = 1, target do
|
||||
pmacs.editor.move_down()
|
||||
end
|
||||
end
|
||||
|
||||
local function bind_local_keymap(buf)
|
||||
local function bind(seq, command)
|
||||
pmacs.keymap.bind { scope = "buffer", buffer = buf, sequence = seq, command = command }
|
||||
end
|
||||
bind("RET", "listview.visit")
|
||||
bind("SPC", "listview.visit")
|
||||
bind("n", "cursor.down")
|
||||
bind("<down>", "cursor.down")
|
||||
bind("p", "cursor.up")
|
||||
bind("<up>", "cursor.up")
|
||||
bind("g", "listview.refresh")
|
||||
bind("q", "listview.quit")
|
||||
end
|
||||
|
||||
-- Build (or adopt) the persistent panel record for `name`. Handles a
|
||||
-- user-killed panel buffer by recreating it.
|
||||
local function ensure_panel(name)
|
||||
local p = panels[name]
|
||||
if p and p.buffer:is_valid() then return p end
|
||||
local buf = find_buffer_by_name(name) or pmacs.buffer.create(name)
|
||||
p = { buffer = buf, line_to_item = {} }
|
||||
panels[name] = p
|
||||
-- Read-only (Q#P3): every non-bypass edit is rejected. The
|
||||
-- intercept lives as long as the buffer; no teardown (the
|
||||
-- buffer-list precedent for its keymap).
|
||||
pmacs.buffer.add_intercept(buf, function()
|
||||
error(name .. " is read-only")
|
||||
end)
|
||||
-- Q#P6: semantic frontends must round-trip keys while this panel
|
||||
-- is focused (RET = visit, not an optimistic newline).
|
||||
pmacs.buffer.set_round_trip_input(buf, true)
|
||||
bind_local_keymap(buf)
|
||||
return p
|
||||
end
|
||||
|
||||
function pmacs.listview.open(spec)
|
||||
assert(type(spec) == "table" and type(spec.name) == "string",
|
||||
"listview.open: spec.name (string) required")
|
||||
local p = ensure_panel(spec.name)
|
||||
p.header = spec.header or spec.name
|
||||
p.on_visit = spec.on_visit
|
||||
p.on_refresh = spec.on_refresh
|
||||
-- Remember where to return on `q` --- but never another panel
|
||||
-- (chained panels would trap `q` in a loop; restore targets the
|
||||
-- last real buffer).
|
||||
local active = pmacs.window.buffer()
|
||||
if active and not panel_for_current_buffer() then
|
||||
p.prev = active
|
||||
end
|
||||
render(p, spec.rows or {})
|
||||
pmacs.window.switch_buffer(p.buffer)
|
||||
seat_cursor(p, 1)
|
||||
end
|
||||
|
||||
pmacs.command.define {
|
||||
name = "listview.visit",
|
||||
description = "Visit the list-panel item under the cursor.",
|
||||
fn = function()
|
||||
local p = panel_for_current_buffer()
|
||||
if not p then return end
|
||||
local item = p.line_to_item[pmacs.editor.cursor_line()]
|
||||
if item ~= nil and p.on_visit then p.on_visit(item) end
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "listview.refresh",
|
||||
description = "Re-run the list panel's data source and re-render.",
|
||||
fn = function()
|
||||
local p = panel_for_current_buffer()
|
||||
if not (p and p.on_refresh) then return end
|
||||
local saved = pmacs.editor.cursor_line()
|
||||
local rows = p.on_refresh() or {}
|
||||
render(p, rows)
|
||||
-- The wholesale rewrite leaves the window cursor at a stale byte
|
||||
-- offset; re-enter the buffer to reset, then re-seat.
|
||||
pmacs.window.switch_buffer(p.buffer)
|
||||
seat_cursor(p, saved)
|
||||
end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "listview.quit",
|
||||
description = "Leave the list panel, restoring the previous buffer.",
|
||||
fn = function()
|
||||
local p = panel_for_current_buffer()
|
||||
if not p then return end
|
||||
local target = p.prev
|
||||
if not (target and target:is_valid()) then
|
||||
target = find_buffer_by_name("*scratch*") or pmacs.buffer.create("*scratch*")
|
||||
end
|
||||
pmacs.window.switch_buffer(target)
|
||||
end,
|
||||
}
|
||||
|
|
@ -569,6 +569,26 @@ pmacs.hook.add("buffer.after-load", function()
|
|||
pcall(attach_buffer, pmacs.window.buffer())
|
||||
end)
|
||||
|
||||
pmacs.hook.add("buffer.after-switch", function()
|
||||
-- Arc 1b: switching buffers clears the window's overlays, and
|
||||
-- `attach_buffer` early-returns for a live attachment without
|
||||
-- touching views — so a switch back to an attached buffer must
|
||||
-- re-push the LSP style + diagnostic views itself. The just-
|
||||
-- cleared window makes this exactly-once per switch; the dedup
|
||||
-- tables keep gating the after-load path only. Without this,
|
||||
-- navigating between attached buffers looked like "the LSP
|
||||
-- deactivated" (no semantic color, no underlines).
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
local key = tostring(buf)
|
||||
local rec = attachments[key]
|
||||
if not rec then return end
|
||||
local ok_s, attached_s = pcall(pmacs.lsp._attach_style, buf)
|
||||
if ok_s and attached_s then styled_buffers[key] = true end
|
||||
local ok_d, attached_d = pcall(pmacs.diag._attach_view, buf, rec.uri)
|
||||
if ok_d and attached_d then diag_viewed_buffers[key] = true end
|
||||
end)
|
||||
|
||||
pmacs.hook.add("buffer.after-edit", function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
|
|
@ -1214,6 +1234,41 @@ function pmacs.lsp.go_to_definition()
|
|||
end)
|
||||
end
|
||||
|
||||
-- Visit one LSP location (Arc 1b): the SP-4 cross-file template ---
|
||||
-- jump ring, find-or-open, cursor walk. Same-buffer hits skip the
|
||||
-- open. Shared by the references panel (and the outline in phase 2).
|
||||
local function visit_location(loc)
|
||||
local path = pmacs.lsp.path_for_uri(loc.uri)
|
||||
if not path then
|
||||
pmacs.editor.set_status("LSP: cannot decode target uri " .. tostring(loc.uri))
|
||||
return
|
||||
end
|
||||
pmacs.editor.push_jump()
|
||||
local ok, err = pcall(pmacs.buffer.find_or_open, path)
|
||||
if not ok then
|
||||
-- Open failed: drop the origin we just pushed so M-, isn't left
|
||||
-- pointing at a jump that never happened.
|
||||
pmacs.editor.jump_back()
|
||||
pmacs.editor.set_status("LSP: failed to open " .. path .. ": " .. tostring(err))
|
||||
return
|
||||
end
|
||||
move_active_cursor_to(loc.line, loc.col)
|
||||
end
|
||||
|
||||
-- Shorten `path` against the project root of `relative_to` (a path in
|
||||
-- the same project) for panel display; falls back to the full path.
|
||||
local function display_path(path, relative_to)
|
||||
local ok, proj = pcall(pmacs.project.detect, relative_to or path)
|
||||
if ok and proj and proj.root then
|
||||
local root = proj.root
|
||||
if root:sub(-1) ~= "/" then root = root .. "/" end
|
||||
if path:sub(1, #root) == root then
|
||||
return path:sub(#root + 1)
|
||||
end
|
||||
end
|
||||
return path
|
||||
end
|
||||
|
||||
function pmacs.lsp.find_references()
|
||||
local rec = attached_for_active()
|
||||
if not rec then
|
||||
|
|
@ -1236,13 +1291,27 @@ function pmacs.lsp.find_references()
|
|||
pmacs.editor.set_status("LSP: no references found")
|
||||
return
|
||||
end
|
||||
-- v1 surfaces a modeline summary (count + first hit); a
|
||||
-- references list buffer is future UX work, like the hover panel.
|
||||
local first = locs[1]
|
||||
-- Arc 1b: a browsable *references* panel. RET visits (jump ring
|
||||
-- included, so M-, returns); q restores this buffer.
|
||||
local here = pmacs.lsp.path_for_uri(rec.uri)
|
||||
local rows = {}
|
||||
for _, loc in ipairs(locs) do
|
||||
local path = pmacs.lsp.path_for_uri(loc.uri) or loc.uri
|
||||
rows[#rows + 1] = {
|
||||
text = string.format("%s:%d:%d", display_path(path, here), loc.line + 1, loc.col + 1),
|
||||
item = loc,
|
||||
}
|
||||
end
|
||||
pmacs.listview.open {
|
||||
name = "*references*",
|
||||
header = string.format(
|
||||
"%d reference%s RET visit n/p move q quit",
|
||||
#locs, (#locs == 1 and "" or "s")),
|
||||
rows = rows,
|
||||
on_visit = visit_location,
|
||||
}
|
||||
pmacs.editor.set_status(string.format(
|
||||
"LSP: %d reference%s; first at %s:%d:%d",
|
||||
#locs, (#locs == 1 and "" or "s"),
|
||||
first.uri, first.line + 1, first.col + 1))
|
||||
"LSP: %d reference%s", #locs, (#locs == 1 and "" or "s")))
|
||||
end)
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,25 @@ pmacs.hook.add("buffer.after-load", function()
|
|||
end
|
||||
end)
|
||||
|
||||
pmacs.hook.add("buffer.after-switch", function()
|
||||
-- Arc 1b: switching buffers clears the window's overlays
|
||||
-- (`switch_active_buffer` resets window view state), so the
|
||||
-- highlight view must be re-pushed for the now-active buffer.
|
||||
-- Dropping the `highlighted_buffers` entry first lets
|
||||
-- `attach_for_active_buffer` re-attach; the just-cleared window
|
||||
-- makes that exactly-once per switch. Without this, C-x b /
|
||||
-- panel navigation permanently stripped syntax color.
|
||||
local ok, err = pcall(function()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
highlighted_buffers[tostring(buf)] = nil
|
||||
attach_for_active_buffer()
|
||||
end)
|
||||
if not ok and pmacs.error then
|
||||
pmacs.error("syntax.after-switch: " .. tostring(err))
|
||||
end
|
||||
end)
|
||||
|
||||
local function reparse_active_buffer_after_edit()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then return end
|
||||
|
|
|
|||
|
|
@ -0,0 +1,187 @@
|
|||
# LSP panels — framing (Arc 1b)
|
||||
|
||||
pmacs's LSP data layer answers references, document symbols, code
|
||||
actions, and hover in full — and then renders one line in the
|
||||
modeline. `lsp.find-references` reports "12 references" and throws
|
||||
eleven away; `lsp.code-actions` applies `acts[1]` blind;
|
||||
`lsp.document-symbols` prints a count; multi-line hover shows its
|
||||
first line. Every one of these is flagged "future UX work" in
|
||||
`builtin/runtime/lsp.lua`. This arc builds that UX — as **buffers**,
|
||||
not new UI surfaces, so both frontends get it for free.
|
||||
|
||||
Roadmap context: `docs/roadmap-2026-07.md` Arc 1b (also the panel
|
||||
substrate DAP will reuse). Direct trigger: the PR #93 validation note
|
||||
("we probably want a more fine grained UI for this sort of stuff and
|
||||
error checking").
|
||||
|
||||
## What already exists (verified)
|
||||
|
||||
- **The `*buffer-list*` idiom** (`builtin/commands/default.lua:320-545`)
|
||||
is a working panel: a named persistent buffer, wholesale
|
||||
re-rendered (`buf:delete` + `buf:insert`), buffer-local keymap
|
||||
(`scope = "buffer"` binds, installed once, never torn down), a
|
||||
`line_to_buffer` row→item map read via `pmacs.editor.cursor_line()`,
|
||||
`prev_buffer_id` capture + `q` restore, and a refresh that manually
|
||||
re-seats the cursor (because `switch_active_buffer` zeroes
|
||||
cursor/overlays — `editor_core.rs:2052-2071`). Gaps: no read-only
|
||||
enforcement, logic not reusable.
|
||||
- **Read surfaces are uniform** `pmacs.<domain>.{getter, clear}(sid,
|
||||
uri)` over Rust stores: references = `pmacs.references.locations`
|
||||
(rows `{uri, line, col}`, 0-based, the byte==UTF-16 v0.1 caveat);
|
||||
outline = `pmacs.document_symbol.symbols` (**flat** rows with
|
||||
`depth` + optional `container` — indent, don't recurse); actions =
|
||||
`pmacs.code_action.actions` (the full ordered array; each item
|
||||
carries its own `edit`/`command`, so applying by index is pure Lua);
|
||||
hover = `pmacs.hover.current` (`contents` is the full multi-line
|
||||
string).
|
||||
- **The cross-file visit template** is `go_to_definition`'s SP-4 path
|
||||
(`lsp.lua:1187-1213`): `push_jump` → `path_for_uri` →
|
||||
`find_or_open` → `move_active_cursor_to`.
|
||||
- **The picker substrate** is `pmacs.minibuffer.read { source =
|
||||
function() return {...} end, on_accept }` — the
|
||||
`window.set-line-numbers` shape (`default.lua:231-246`), already
|
||||
dual-frontend since protocol v12.
|
||||
- **Read-only is intercept-only** (no buffer flag exists): the REPL's
|
||||
`add_intercept` + `error()` + self-write bypass
|
||||
(`repl/init.lua:686-726`) is the reference implementation.
|
||||
|
||||
## Decisions
|
||||
|
||||
### Q#P1 — Panels are buffers; one shared `listview` runtime module
|
||||
|
||||
`builtin/runtime/listview.lua`:
|
||||
|
||||
```lua
|
||||
listview.open {
|
||||
name = "*references*", -- persistent buffer, found-or-created
|
||||
header = "12 references to `foo` RET visit n/p move g refresh q quit",
|
||||
rows = { { text = "...", item = <any> }, ... },
|
||||
on_visit = function(item) ... end, -- RET/SPC
|
||||
on_refresh = function() return rows end, -- g (optional)
|
||||
}
|
||||
```
|
||||
|
||||
It owns everything the buffer-list hand-rolls: ensure-buffer,
|
||||
wholesale render, `line→item` map, buffer-local keymap (RET/SPC
|
||||
visit, n/p + arrows move, g refresh, q quit), prev-buffer capture +
|
||||
restore, cursor re-seat after render, and the read-only intercept
|
||||
(Q#P3). Bindings install once per panel buffer and persist — the
|
||||
buffer-list precedent; per-open teardown buys nothing for persistent
|
||||
buffers. `*buffer-list*` itself is NOT migrated in this arc (working
|
||||
code, separate risk); named as a follow-up.
|
||||
|
||||
### Q#P2 — Presentation: switch-in-place, `q` restores
|
||||
|
||||
Panels open in the current window via `pmacs.window.switch_buffer`;
|
||||
`q` restores the saved previous buffer (scratch fallback if killed).
|
||||
This is the only presentation that behaves identically in both
|
||||
frontends today — the GPU renders exactly one buffer and cannot show
|
||||
splits. TUI split placement (`display-buffer`-style) is a named
|
||||
deferral, not a v1 behavior fork.
|
||||
|
||||
### Q#P3 — Read-only via intercept; its honest limits
|
||||
|
||||
Each panel buffer gets an `add_intercept` that `error("read-only
|
||||
panel")`s every op; `listview`'s own renders write with
|
||||
`{ bypass_intercept = true }`. Two recorded limits: (a) the intercept
|
||||
guards the daemon's command/edit path — a CRDT-import write (a
|
||||
semantic frontend's optimistic op) does not run the intercept chain;
|
||||
Q#P6's round-trip seam is what actually keeps GPU typing out of
|
||||
panels. (b) A real `read_only` buffer flag (enforced in both edit
|
||||
paths, shipped to frontends) is deferred — it's the proper fix for
|
||||
this AND the REPL's identical exposure, and deserves its own small
|
||||
arc.
|
||||
|
||||
### Q#P6 — Panel input routing: round-trip buffers (the load-bearing catch)
|
||||
|
||||
RET on a panel row must *visit*, but in the GPU, Enter is
|
||||
optimistic-eligible — it would locally insert `\n` into the panel
|
||||
mirror and ship a CrdtOp, never reaching the buffer-local RET
|
||||
binding. Same for plain chars (vandalizing the panel around the
|
||||
intercept, per Q#P3a).
|
||||
|
||||
Fix, wire-free: a core-side set of **round-trip buffers**.
|
||||
`pmacs.buffer.set_round_trip_input(buf, true)` marks a buffer;
|
||||
`EditorState::dispatch_idle()` returns `false` while the active
|
||||
buffer is marked. `DispatchIdle { idle: false }` already gates every
|
||||
semantic frontend's optimistic path (M11.6), so with a panel focused,
|
||||
every GPU key round-trips: RET dispatches into the buffer-local
|
||||
binding (visit works), typing dispatches to self-insert and the
|
||||
intercept rejects it cleanly. No protocol change, no GPU change.
|
||||
`listview` marks every panel it creates. (The REPL can adopt the same
|
||||
flag later for its GPU story — named follow-up.)
|
||||
|
||||
### Q#P4 — References list (`*references*`, the flagship)
|
||||
|
||||
`lsp.find-references` (`M-?`) keeps its request/store flow, then
|
||||
opens a panel: one row per location, `path:line+1:col+1` with the
|
||||
path shortened relative to the project root when possible. RET =
|
||||
the SP-4 visit template (jump ring included), so `M-,` returns.
|
||||
Modeline summary stays (it's now the panel's byline). Row *snippets*
|
||||
(the referenced line's text) require reading target files that may
|
||||
not be open — deferred, named.
|
||||
|
||||
### Q#P5 — Outline (`*outline*`) + code-action picker + hover doc
|
||||
|
||||
- **Outline**: `lsp.document-symbols` (`C-c o`) → panel rows indented
|
||||
`2 × depth` with a kind tag; RET restores the source buffer and
|
||||
moves to the symbol (same-file visit: `switch_buffer(prev)` +
|
||||
`move_active_cursor_to`).
|
||||
- **Code actions**: no panel needed — `lsp.code-actions` (`C-c a`)
|
||||
opens `pmacs.minibuffer.read` with `"N: title"` candidates (index
|
||||
prefix disambiguates duplicate titles); `on_accept` applies the
|
||||
*chosen* item's `edit`/`command` through the existing apply branch.
|
||||
One action still auto-applies without prompting? No — always prompt
|
||||
when more than one; a single action applies directly (today's
|
||||
behavior, now correct instead of lucky).
|
||||
- **Hover doc**: `lsp.hover` (`C-c h`) unchanged (first line in the
|
||||
echo area). New `lsp.hover-doc` (`C-c H`) renders the full
|
||||
`contents` into a `*lsp-help*` panel (rows non-visitable; q quits).
|
||||
|
||||
### Q#P7 — Coordinates
|
||||
|
||||
Panels inherit the v0.1 byte==UTF-16 assumption exactly as
|
||||
`go_to_definition` does today (`lsp.lua:658-662`); the v0.2
|
||||
position-encoding hardening remains one deferral, not four new ones.
|
||||
|
||||
## Phasing (each phase green + user-validated)
|
||||
|
||||
1. **`listview` module + Q#P6 round-trip seam + references list.**
|
||||
The seam is the only Rust change (core set + one binding +
|
||||
`dispatch_idle` clause + tests). Validate in TUI *and* GPU —
|
||||
phase 1's GPU validation is what scores bet #3.
|
||||
2. **Outline + code-action picker + hover doc.** Pure Lua on the
|
||||
phase-1 substrate.
|
||||
3. **As-built notes** + deferral ledger update.
|
||||
|
||||
Arc 2 interleave point (query-replace) after phase 2.
|
||||
|
||||
## Categorical bets (score at close)
|
||||
|
||||
1. **The listview module covers all three panels without per-panel
|
||||
Rust.** The whole arc lands with one small core seam (Q#P6) and
|
||||
zero protocol change.
|
||||
2. **Cursor-zeroing bites once.** `switch_active_buffer` clears
|
||||
cursor/overlays; some flow (refresh, visit-then-return) will land
|
||||
the cursor somewhere surprising before the re-seat discipline is
|
||||
applied everywhere.
|
||||
3. **GPU panels render via the existing BufferSnapshot/F29 path with
|
||||
no GPU changes.** The mechanism exists (mid-session CRDT upgrade +
|
||||
snapshot push); a panel buffer exercising it from a `switch_buffer`
|
||||
is the untested claim in this arc.
|
||||
4. **Round-trip routing has one gap somewhere** — a key that neither
|
||||
round-trips nor falls through correctly while a panel is focused
|
||||
(the Q#C6-class finding of this arc).
|
||||
|
||||
## Deferred (named, not silently dropped)
|
||||
|
||||
- A real `read_only` buffer flag enforced on both edit paths and
|
||||
shipped to frontends (fixes panels + REPL properly; Q#P3).
|
||||
- Migrating `*buffer-list*` onto `listview`.
|
||||
- TUI split placement for panels (`display-buffer`-style).
|
||||
- Reference row snippets (needs off-buffer file reads).
|
||||
- Fuzzy filtering inside panels (type-to-narrow).
|
||||
- Peek/preview on n/p (echo the target line without visiting).
|
||||
- Call-hierarchy / workspace-symbols panels (data partially present;
|
||||
same idiom when wanted).
|
||||
- REPL adopting the Q#P6 round-trip flag for its GPU story.
|
||||
121
src/daemon.rs
121
src/daemon.rs
|
|
@ -842,6 +842,16 @@ fn dispatcher_loop(
|
|||
// attach emits an initial `DispatchIdle` so the frontend starts
|
||||
// from a known idle state (its default is pessimistic-`false`).
|
||||
let mut last_dispatch_idle_sent: HashMap<FrontendId, bool> = HashMap::new();
|
||||
// Arc 1b — the buffer each replica frontend last received a
|
||||
// `BufferSnapshot` for via the active-buffer-follow path. Absence
|
||||
// means "never sent": the first tick after attach ships the
|
||||
// frontend its own active buffer, which also repairs the
|
||||
// attach-time last-snapshot-wins ambiguity (the initial
|
||||
// `send_buffer_snapshots` sweep sends every buffer; the display
|
||||
// follows whichever arrived last, not necessarily the active one).
|
||||
// Declared for both flavors (the follow path is crdt-gated; the
|
||||
// detach cleanup isn't).
|
||||
let mut last_active_buffer_sent: HashMap<FrontendId, crate::buffer::BufferId> = HashMap::new();
|
||||
let mut session_registry = SessionRegistry::new();
|
||||
// T M10.11 Q8 — jitter PRNG, seeded once so the
|
||||
// convergence-under-jitter scenario is deterministically
|
||||
|
|
@ -966,6 +976,45 @@ fn dispatcher_loop(
|
|||
&session_registry,
|
||||
&mut streams,
|
||||
);
|
||||
// The broadcast just delivered this buffer to this
|
||||
// frontend too; record it so the follow check below
|
||||
// doesn't send a duplicate on the same tick.
|
||||
last_active_buffer_sent.insert(*fid, upgraded);
|
||||
}
|
||||
|
||||
// Arc 1b — follow this frontend's active buffer. The
|
||||
// F29 push above only fires on the *upgrade* tick;
|
||||
// switching to an already-CRDT-backed buffer (a
|
||||
// panel's `q`, `find_or_open` of an open file, plain
|
||||
// `C-x b`) previously sent nothing, so a semantic
|
||||
// frontend kept rendering the old buffer while
|
||||
// daemon-side input targeted the new one — a
|
||||
// typing-into-a-buffer-you-can't-see hazard. Ship the
|
||||
// now-active buffer's snapshot to THIS frontend only
|
||||
// (its own view changed; nobody else's did).
|
||||
//
|
||||
// SEMANTIC sessions only: display-follows-snapshot is
|
||||
// a grid-less-frontend concept, and the GPU rebuilds
|
||||
// its replica wholesale on every snapshot. The grid
|
||||
// TUI renders via CellDelta and its `BufferMirror` is
|
||||
// init-once — a follow send there is a guaranteed
|
||||
// duplicate that errors ("already has a CRDT snapshot
|
||||
// applied") on every attach and every buffer switch
|
||||
// (the PR #94 round-2 startup regression).
|
||||
if session_registry
|
||||
.session_state(*fid)
|
||||
.is_some_and(|s| s.negotiated_capabilities.semantic_render)
|
||||
{
|
||||
let active_now = {
|
||||
let core = editor.core.borrow();
|
||||
core.active_window_for(*fid).map(|w| w.buffer_id)
|
||||
};
|
||||
if let Some(active_now) = active_now
|
||||
&& last_active_buffer_sent.get(fid) != Some(&active_now)
|
||||
{
|
||||
send_buffer_snapshot_to_frontend(editor, active_now, *fid, &mut streams);
|
||||
last_active_buffer_sent.insert(*fid, active_now);
|
||||
}
|
||||
}
|
||||
}
|
||||
#[cfg(not(feature = "crdt"))]
|
||||
|
|
@ -1181,6 +1230,7 @@ fn dispatcher_loop(
|
|||
semantic_states.remove(fid);
|
||||
term_sizes.remove(fid);
|
||||
last_dispatch_idle_sent.remove(fid);
|
||||
last_active_buffer_sent.remove(fid);
|
||||
session_registry.unregister_session(*fid);
|
||||
editor.core.borrow_mut().unregister_frontend_view(*fid);
|
||||
}
|
||||
|
|
@ -1221,6 +1271,7 @@ fn dispatcher_loop(
|
|||
&mut streams,
|
||||
&mut term_sizes,
|
||||
&mut last_dispatch_idle_sent,
|
||||
&mut last_active_buffer_sent,
|
||||
&mut session_registry,
|
||||
);
|
||||
// Drain a burst of immediately-available events to
|
||||
|
|
@ -1236,6 +1287,7 @@ fn dispatcher_loop(
|
|||
&mut streams,
|
||||
&mut term_sizes,
|
||||
&mut last_dispatch_idle_sent,
|
||||
&mut last_active_buffer_sent,
|
||||
&mut session_registry,
|
||||
);
|
||||
}
|
||||
|
|
@ -1341,6 +1393,7 @@ fn handle_dispatcher_event(
|
|||
streams: &mut HashMap<FrontendId, UnixStream>,
|
||||
term_sizes: &mut HashMap<FrontendId, CellSize>,
|
||||
last_dispatch_idle_sent: &mut HashMap<FrontendId, bool>,
|
||||
last_active_buffer_sent: &mut HashMap<FrontendId, crate::buffer::BufferId>,
|
||||
session_registry: &mut SessionRegistry,
|
||||
) {
|
||||
match event {
|
||||
|
|
@ -1515,6 +1568,7 @@ fn handle_dispatcher_event(
|
|||
streams.remove(&frontend_id);
|
||||
term_sizes.remove(&frontend_id);
|
||||
last_dispatch_idle_sent.remove(&frontend_id);
|
||||
last_active_buffer_sent.remove(&frontend_id);
|
||||
session_registry.unregister_session(frontend_id);
|
||||
editor
|
||||
.core
|
||||
|
|
@ -1691,6 +1745,55 @@ fn ensure_active_buffer_crdt_backed(
|
|||
/// send is small (snapshot bytes for the upgrade-instant state,
|
||||
/// which is the empty / freshly-loaded buffer content the replica
|
||||
/// already has) and only fires on the actual upgrade tick.
|
||||
/// Export `buffer_id`'s CRDT snapshot bytes, or `None` (logged) when
|
||||
/// the buffer is missing, not CRDT-backed, or the export fails.
|
||||
#[cfg(feature = "crdt")]
|
||||
fn export_buffer_snapshot(
|
||||
editor: &EditorState,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
) -> Option<Vec<u8>> {
|
||||
let core = editor.core.borrow();
|
||||
let registry = core.registry.borrow();
|
||||
let buf = registry.get(buffer_id).ok()?;
|
||||
let crdt = buf.crdt_state()?;
|
||||
match crdt.export_snapshot() {
|
||||
Ok(bytes) => Some(bytes),
|
||||
Err(e) => {
|
||||
eprintln!("pmacs: export_snapshot for {buffer_id:?} failed: {e:?}");
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Arc 1b — send `buffer_id`'s snapshot to ONE frontend. The
|
||||
/// active-buffer-follow path (see the per-tick loop) uses this when a
|
||||
/// semantic frontend's own active buffer changes to an
|
||||
/// already-CRDT-backed buffer: the F29 broadcast only fires on the
|
||||
/// upgrade tick, so without this a frontend that switched *back* to a
|
||||
/// known buffer (a panel's `q`, `find_or_open` of an open file) kept
|
||||
/// displaying the old buffer while daemon-side input targeted the new
|
||||
/// one.
|
||||
#[cfg(feature = "crdt")]
|
||||
fn send_buffer_snapshot_to_frontend(
|
||||
editor: &EditorState,
|
||||
buffer_id: crate::buffer::BufferId,
|
||||
fid: FrontendId,
|
||||
streams: &mut HashMap<FrontendId, UnixStream>,
|
||||
) {
|
||||
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
|
||||
return;
|
||||
};
|
||||
let msg = InstanceMessage::BufferSnapshot {
|
||||
buffer_id,
|
||||
crdt_snapshot: snapshot_bytes,
|
||||
};
|
||||
if let Some(stream) = streams.get_mut(&fid)
|
||||
&& let Err(e) = write_message(stream, &msg)
|
||||
{
|
||||
eprintln!("pmacs: send BufferSnapshot for {buffer_id:?} to {fid:?} failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(feature = "crdt")]
|
||||
fn broadcast_buffer_snapshot_to_replicas(
|
||||
editor: &EditorState,
|
||||
|
|
@ -1698,22 +1801,8 @@ fn broadcast_buffer_snapshot_to_replicas(
|
|||
session_registry: &SessionRegistry,
|
||||
streams: &mut HashMap<FrontendId, UnixStream>,
|
||||
) {
|
||||
let snapshot_bytes = {
|
||||
let core = editor.core.borrow();
|
||||
let registry = core.registry.borrow();
|
||||
let Ok(buf) = registry.get(buffer_id) else {
|
||||
return;
|
||||
};
|
||||
let Some(crdt) = buf.crdt_state() else {
|
||||
return;
|
||||
};
|
||||
match crdt.export_snapshot() {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) => {
|
||||
eprintln!("pmacs: F29 export_snapshot for {buffer_id:?} failed: {e:?}");
|
||||
return;
|
||||
}
|
||||
}
|
||||
let Some(snapshot_bytes) = export_buffer_snapshot(editor, buffer_id) else {
|
||||
return;
|
||||
};
|
||||
let msg = InstanceMessage::BufferSnapshot {
|
||||
buffer_id,
|
||||
|
|
|
|||
|
|
@ -276,6 +276,15 @@ impl EditorState {
|
|||
// auto-attach buffer hooks, key-bound commands. Loaded last so
|
||||
// every dependency table (`pmacs.lsp`, `pmacs.parse`,
|
||||
// `pmacs.window`, etc.) already exists.
|
||||
// Arc 1b: the reusable list-panel module. Loaded before
|
||||
// lsp.lua, whose panel commands (references, outline) call
|
||||
// `pmacs.listview.open`.
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/listview.lua"),
|
||||
include_str!("../builtin/runtime/listview.lua"),
|
||||
)
|
||||
.expect("load listview builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/lsp.lua"),
|
||||
|
|
@ -508,8 +517,15 @@ impl EditorState {
|
|||
let core = self.core.borrow();
|
||||
// A live context menu shadows the keymap too (Q#CM1): keys must
|
||||
// round-trip so the daemon's `dispatch_menu_key` drives the menu
|
||||
// rather than the frontend self-inserting.
|
||||
!core.minibuffer.is_active() && !core.search_active() && !core.menu_is_open()
|
||||
// rather than the frontend self-inserting. A round-trip buffer
|
||||
// (Arc 1b Q#P6 — a focused panel) is the buffer-shaped member of
|
||||
// the same family: RET must reach its buffer-local bindings and
|
||||
// typing must reach its read-only intercept, neither of which an
|
||||
// optimistic local edit would do.
|
||||
!core.minibuffer.is_active()
|
||||
&& !core.search_active()
|
||||
&& !core.menu_is_open()
|
||||
&& !core.active_buffer_round_trips()
|
||||
}
|
||||
|
||||
/// `frontend_id` records which frontend produced the event. v0.1
|
||||
|
|
|
|||
|
|
@ -193,6 +193,16 @@ pub struct EditorCore {
|
|||
/// same state the dispatch path navigates and the Lua driver
|
||||
/// publishes into — the completion twin of `menu`.
|
||||
pub completion_popup: crate::completion::SharedCompletionPopup,
|
||||
/// Buffers whose input must round-trip (Arc 1b, Q#P6). While one
|
||||
/// of these is the active buffer,
|
||||
/// [`crate::editor::EditorState::dispatch_idle`] reports `false`,
|
||||
/// so semantic frontends' optimistic-apply stays off: RET reaches
|
||||
/// buffer-local bindings (a panel's visit) instead of locally
|
||||
/// inserting `\n`, and plain typing dispatches into the edit path
|
||||
/// where a read-only intercept can reject it — a CRDT-import
|
||||
/// write would bypass the intercept chain entirely. Marked from
|
||||
/// Lua via `pmacs.buffer.set_round_trip_input`; pruned on kill.
|
||||
round_trip_buffers: std::collections::HashSet<BufferId>,
|
||||
}
|
||||
|
||||
impl EditorCore {
|
||||
|
|
@ -233,6 +243,7 @@ impl EditorCore {
|
|||
pending_clipboard: None,
|
||||
menu: crate::menu::make_shared_menu(),
|
||||
completion_popup: crate::completion::make_shared_popup(),
|
||||
round_trip_buffers: std::collections::HashSet::new(),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1980,6 +1991,25 @@ impl EditorCore {
|
|||
true
|
||||
}
|
||||
|
||||
// ---- round-trip input buffers (Arc 1b, Q#P6) ----------------------------
|
||||
|
||||
/// Mark (or unmark) `buffer_id` as requiring round-trip input.
|
||||
/// See the field doc on `round_trip_buffers` for the semantics.
|
||||
pub fn set_round_trip_input(&mut self, buffer_id: BufferId, on: bool) {
|
||||
if on {
|
||||
self.round_trip_buffers.insert(buffer_id);
|
||||
} else {
|
||||
self.round_trip_buffers.remove(&buffer_id);
|
||||
}
|
||||
}
|
||||
|
||||
/// True while the active buffer requires round-trip input (a
|
||||
/// panel or other buffer-local-keymap surface is focused).
|
||||
#[must_use]
|
||||
pub fn active_buffer_round_trips(&self) -> bool {
|
||||
self.round_trip_buffers.contains(&self.active_buffer_id())
|
||||
}
|
||||
|
||||
/// Ensure the active window carries a
|
||||
/// [`crate::completion::CompletionView`] overlay (deduped by kind).
|
||||
/// The view reads the shared popup, so one instance suffices; it
|
||||
|
|
@ -2012,6 +2042,7 @@ impl EditorCore {
|
|||
return Err("cannot kill the last remaining buffer".into());
|
||||
}
|
||||
}
|
||||
self.round_trip_buffers.remove(&buffer_id);
|
||||
let fallback = {
|
||||
let mut reg = self.registry.borrow_mut();
|
||||
match reg.find_by_name("*scratch*") {
|
||||
|
|
|
|||
|
|
@ -2366,6 +2366,25 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// Arc 1b Q#P6: mark (or unmark) a buffer as requiring
|
||||
// round-trip input. While a marked buffer is active,
|
||||
// `dispatch_idle` reports false, so semantic frontends'
|
||||
// optimistic-apply stays off — RET reaches the buffer-local
|
||||
// bindings (a panel's visit) and typing reaches the read-only
|
||||
// intercept instead of landing as a CRDT import that bypasses
|
||||
// it. `pmacs.listview` marks every panel it creates.
|
||||
buffer.set(
|
||||
"set_round_trip_input",
|
||||
lua.create_function(move |lua, (id, on): (BufferIdLua, bool)| {
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
core.borrow_mut().set_round_trip_input(id.0, on);
|
||||
}
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// T M4.5 L1: find-or-open. If a buffer is already bound to
|
||||
// `path`, switch to it (preserving unsaved edits — no
|
||||
|
|
@ -2383,6 +2402,11 @@ fn install_buffer_module(lua: &Lua, registry: &SharedRegistry) -> mlua::Result<T
|
|||
.switch_active_buffer(existing)
|
||||
.map_err(mlua::Error::external)?;
|
||||
}
|
||||
// Arc 1b: switching clears the window's overlays;
|
||||
// subscribers (syntax highlight, LSP style/diag
|
||||
// views) re-attach theirs. The fresh-load branch
|
||||
// below fires `buffer.after-load` instead.
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
return Ok(BufferIdLua(existing));
|
||||
}
|
||||
let (bytes, meta) = crate::file_io::load_file(&path_buf).map_err(|source| {
|
||||
|
|
@ -10329,10 +10353,17 @@ fn install_window_module(lua: &Lua, core: &SharedCore) -> mlua::Result<Table> {
|
|||
let cc = core.clone();
|
||||
win.set(
|
||||
"switch_buffer",
|
||||
lua.create_function(move |_, id: BufferIdLua| -> mlua::Result<()> {
|
||||
lua.create_function(move |lua, id: BufferIdLua| -> mlua::Result<()> {
|
||||
cc.borrow_mut()
|
||||
.switch_active_buffer(id.0)
|
||||
.map_err(mlua::Error::external)
|
||||
.map_err(mlua::Error::external)?;
|
||||
// Arc 1b: switching clears the window's overlays;
|
||||
// subscribers (syntax highlight, LSP style/diag views)
|
||||
// re-attach theirs here — without this, C-x b / panel
|
||||
// navigation permanently stripped styling from the
|
||||
// session.
|
||||
run_hook_if_defined(lua, "buffer.after-switch", mlua::MultiValue::new());
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,138 @@
|
|||
//! List-panel acceptance (Arc 1b phase 1) --- the `pmacs.listview`
|
||||
//! module end-to-end through `dispatch_key`: open/navigate/visit,
|
||||
//! `q` restore, the Q#P3 read-only intercept, the Q#P6 round-trip
|
||||
//! gate (`dispatch_idle` false while a panel is focused), and
|
||||
//! refresh. The references panel itself needs a live LSP and is
|
||||
//! validated manually / via the m4 harness; these tests drive the
|
||||
//! substrate hermetically.
|
||||
//!
|
||||
//! Framing: docs/lsp-panels-framing.md.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
use pmacs::protocol::FrontendId;
|
||||
|
||||
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
|
||||
KeyEvent {
|
||||
code,
|
||||
modifiers: mods,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::empty(),
|
||||
}
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
/// Open a three-row test panel whose visits record into `_G.VISITED`.
|
||||
fn open_test_panel(s: &mut EditorState) {
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r#"
|
||||
_G.VISITED = nil
|
||||
pmacs.listview.open {
|
||||
name = "*test-panel*",
|
||||
header = "3 items RET visit q quit",
|
||||
rows = {
|
||||
{ text = "alpha", item = "A" },
|
||||
{ text = "beta", item = "B" },
|
||||
{ text = "gamma", item = "C" },
|
||||
},
|
||||
on_visit = function(item) _G.VISITED = item end,
|
||||
on_refresh = function()
|
||||
return { { text = "delta", item = "D" } }
|
||||
end,
|
||||
}
|
||||
"#,
|
||||
)
|
||||
.exec()
|
||||
.expect("open test panel");
|
||||
}
|
||||
|
||||
/// `(active buffer name, buffer text, cursor line, visited)` probed
|
||||
/// through the Lua surface.
|
||||
fn probe(s: &EditorState) -> (String, String, i64, Option<String>) {
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r"
|
||||
local b = pmacs.window.buffer()
|
||||
local d = pmacs.describe.buffer(b)
|
||||
return d.name, b:slice(0, b:len()), pmacs.editor.cursor_line(), _G.VISITED
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("probe panel state")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn open_seats_cursor_and_ret_visits_the_row() {
|
||||
let mut s = EditorState::new();
|
||||
open_test_panel(&mut s);
|
||||
let (name, text, line, _) = probe(&s);
|
||||
assert_eq!(name, "*test-panel*");
|
||||
assert!(text.starts_with("3 items"), "header renders first");
|
||||
assert_eq!(line, 1, "the cursor opens on the first data row");
|
||||
|
||||
press(&mut s, KeyCode::Char('n')); // buffer-local: cursor.down
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let (_, _, _, visited) = probe(&s);
|
||||
assert_eq!(visited.as_deref(), Some("B"), "RET visits the second row");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn header_row_is_not_visitable() {
|
||||
let mut s = EditorState::new();
|
||||
open_test_panel(&mut s);
|
||||
press(&mut s, KeyCode::Char('p')); // up onto the header
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let (_, _, _, visited) = probe(&s);
|
||||
assert_eq!(visited, None, "the header maps to no item");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn q_restores_the_previous_buffer() {
|
||||
let mut s = EditorState::new();
|
||||
open_test_panel(&mut s);
|
||||
press(&mut s, KeyCode::Char('q'));
|
||||
let (name, _, _, _) = probe(&s);
|
||||
assert_eq!(name, "*scratch*", "q returns to the buffer we came from");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_rejects_typing() {
|
||||
let mut s = EditorState::new();
|
||||
open_test_panel(&mut s);
|
||||
let (_, before, _, _) = probe(&s);
|
||||
press(&mut s, KeyCode::Char('z')); // unbound printable → self-insert → intercept rejects
|
||||
let (_, after, _, _) = probe(&s);
|
||||
assert_eq!(before, after, "the read-only intercept rejects self-insert");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dispatch_idle_is_false_while_a_panel_is_focused() {
|
||||
// Q#P6: while the panel is the active buffer, semantic frontends
|
||||
// must round-trip every key (RET = visit, not an optimistic \n).
|
||||
let mut s = EditorState::new();
|
||||
assert!(s.dispatch_idle(), "scratch buffer: idle");
|
||||
open_test_panel(&mut s);
|
||||
assert!(!s.dispatch_idle(), "panel focused: keys must round-trip");
|
||||
press(&mut s, KeyCode::Char('q'));
|
||||
assert!(s.dispatch_idle(), "restored buffer: idle again");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_reruns_the_source_and_reseats() {
|
||||
let mut s = EditorState::new();
|
||||
open_test_panel(&mut s);
|
||||
press(&mut s, KeyCode::Char('g'));
|
||||
let (_, text, line, _) = probe(&s);
|
||||
assert!(text.contains("delta"), "g re-renders from on_refresh");
|
||||
assert!(!text.contains("alpha"), "old rows are gone");
|
||||
assert_eq!(line, 1, "cursor re-seats on a data row after refresh");
|
||||
press(&mut s, KeyCode::Enter);
|
||||
let (_, _, _, visited) = probe(&s);
|
||||
assert_eq!(visited.as_deref(), Some("D"), "the refreshed row visits");
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
//! Overlay re-attach on buffer switch (Arc 1b, PR #94 validation
|
||||
//! finding 2): `switch_active_buffer` clears the window's overlays,
|
||||
//! and the runtime's dedup tables blocked re-attachment — so plain
|
||||
//! `C-x b`, buffer-list visits, and panel navigation permanently
|
||||
//! stripped syntax/LSP styling ("the LSP doesn't activate if I
|
||||
//! navigate to a reference"). The `buffer.after-switch` hook now
|
||||
//! re-pushes the views.
|
||||
//!
|
||||
//! Hermetic: only the tree-sitter `syntax-highlight` overlay is
|
||||
//! asserted (always available for `.rs`); the LSP style/diag views
|
||||
//! ride the same hook but need a live server.
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers};
|
||||
use pmacs::editor::EditorState;
|
||||
|
||||
fn open_probe_file(_s: &EditorState) -> String {
|
||||
let dir = std::env::temp_dir().join(format!("pmacs-ovl-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("mk tempdir");
|
||||
let file = dir.join("probe.rs");
|
||||
std::fs::write(&file, "fn main() {}\n").expect("write probe file");
|
||||
file.display().to_string()
|
||||
}
|
||||
|
||||
/// `(overlay kinds on the active window, active buffer name)`.
|
||||
fn kinds(s: &EditorState) -> (Vec<String>, String) {
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r"
|
||||
local d = pmacs.describe.buffer(pmacs.window.buffer())
|
||||
return pmacs.window._overlay_kinds(), d.name
|
||||
",
|
||||
)
|
||||
.eval()
|
||||
.expect("probe overlay kinds")
|
||||
}
|
||||
|
||||
fn count_of(kinds: &[String], kind: &str) -> usize {
|
||||
kinds.iter().filter(|k| *k == kind).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn switch_away_and_back_reattaches_syntax_overlay_exactly_once() {
|
||||
let s = EditorState::new();
|
||||
let path = open_probe_file(&s);
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
r#"
|
||||
_G.TARGET = pmacs.buffer.find_or_open("{path}")
|
||||
for _, id in ipairs(pmacs.buffer.list()) do
|
||||
if pmacs.describe.buffer(id).name == "*scratch*" then _G.SCRATCH = id end
|
||||
end
|
||||
"#
|
||||
))
|
||||
.exec()
|
||||
.expect("open probe + find scratch");
|
||||
let (before, _) = kinds(&s);
|
||||
assert_eq!(
|
||||
count_of(&before, "syntax-highlight"),
|
||||
1,
|
||||
"fresh open attaches the highlight overlay once (got {before:?})"
|
||||
);
|
||||
|
||||
// Away and back — twice, so stacking would show as a count > 1.
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(
|
||||
r"
|
||||
pmacs.window.switch_buffer(_G.SCRATCH)
|
||||
pmacs.window.switch_buffer(_G.TARGET)
|
||||
pmacs.window.switch_buffer(_G.SCRATCH)
|
||||
pmacs.window.switch_buffer(_G.TARGET)
|
||||
",
|
||||
)
|
||||
.exec()
|
||||
.expect("switch away and back twice");
|
||||
let (after, name) = kinds(&s);
|
||||
assert!(name.ends_with("probe.rs"), "back on the probe file");
|
||||
assert_eq!(
|
||||
count_of(&after, "syntax-highlight"),
|
||||
1,
|
||||
"the switch re-attaches exactly one highlight overlay (got {after:?})"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn panel_quit_restores_overlays_on_the_source_buffer() {
|
||||
let s = EditorState::new();
|
||||
let path = open_probe_file(&s);
|
||||
s.lua_host
|
||||
.lua()
|
||||
.load(format!(
|
||||
r#"
|
||||
pmacs.buffer.find_or_open("{path}")
|
||||
pmacs.listview.open {{
|
||||
name = "*ovl-panel*",
|
||||
header = "h",
|
||||
rows = {{ {{ text = "row", item = 1 }} }},
|
||||
}}
|
||||
"#
|
||||
))
|
||||
.exec()
|
||||
.expect("open probe + panel");
|
||||
// q leaves the panel back to the source file.
|
||||
let mut s = s;
|
||||
s.dispatch_key(
|
||||
pmacs::protocol::FrontendId::LOCAL,
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
modifiers: KeyModifiers::NONE,
|
||||
kind: KeyEventKind::Press,
|
||||
state: KeyEventState::empty(),
|
||||
},
|
||||
);
|
||||
let (after, name) = kinds(&s);
|
||||
assert!(name.ends_with("probe.rs"), "q restored the source buffer");
|
||||
assert_eq!(
|
||||
count_of(&after, "syntax-highlight"),
|
||||
1,
|
||||
"leaving a panel restores the source buffer's styling (got {after:?})"
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue