feat(panels): listview module, Q#P6 round-trip seam, references panel

Arc 1b phase 1 (framing: docs/lsp-panels-framing.md).

Q#P6 (the one Rust change): EditorCore.round_trip_buffers +
pmacs.buffer.set_round_trip_input(buf, on); dispatch_idle() reports
false while a marked buffer is active, so semantic frontends'
optimistic-apply stays off -- RET reaches a panel's buffer-local visit
binding instead of locally inserting a newline, and typing dispatches
into the edit path where the read-only intercept rejects it (a CRDT
import would bypass the intercept chain entirely). Pruned on kill.

Q#P1/P2/P3: builtin/runtime/listview.lua generalizes the *buffer-list*
idiom -- pmacs.listview.open{name, header, rows, on_visit, on_refresh}
owns ensure-buffer (recreates if user-killed), wholesale render with
bypass_intercept, line->item map, buffer-local RET/SPC/n/p/g/q keymap,
previous-buffer capture + q restore (never another panel; scratch
fallback), cursor re-seat after render, the read-only intercept, and
the Q#P6 mark. Panels are buffers: both frontends render them with
zero protocol change.

Q#P4: lsp.find-references (M-?) opens *references* -- one row per
location, paths shortened against the project root, RET visits via the
shared SP-4 template (jump ring, find_or_open, cursor walk; extracted
as visit_location for the phase-2 outline to reuse).

Acceptance: tests/listview_acceptance.rs -- open/seat/visit, header
non-visitable, q restore, read-only rejection, dispatch_idle gate,
refresh re-render + re-seat.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Levi Neuwirth 2026-07-07 20:15:46 -04:00
parent d1a7683c2b
commit b25b47334d
6 changed files with 430 additions and 8 deletions

View File

@ -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,
}

View File

@ -1214,6 +1214,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 +1271,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

View File

@ -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

View File

@ -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*") {

View File

@ -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

View File

@ -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");
}