diff --git a/builtin/runtime/dired.lua b/builtin/runtime/dired.lua
index c8054fe..11d78ce 100644
--- a/builtin/runtime/dired.lua
+++ b/builtin/runtime/dired.lua
@@ -364,11 +364,23 @@ local function render_text(handle)
return table.concat(lines, "\n")
end
--- Dired's own writes are the only ones that reach the buffer: the
--- read-only intercept rejects everything else, and this bypasses it.
+-- Dired's own writes are the only ones that reach the buffer, and this
+-- is the one authorized door (Q#GB1,
+-- docs/generated-buffer-immutability-framing.md).
+--
+-- `set_generated_contents` lifts the rope's `read_only`, replaces the
+-- whole buffer skipping intercepts, discards the resulting history and
+-- re-asserts the lock --- all inside one registry borrow, so the buffer
+-- is never observably unlocked. The erroring intercept this replaces a
+-- bypass write beside is KEPT: it guards the edit path with a named
+-- error, but `Buffer::undo` reaches the rope through `ensure_writable`
+-- and never consults the intercept chain, so a listing protected by an
+-- intercept alone was emptied by a bare `C-/` --- dired rebinds no undo
+-- chord --- and by `M-x buffer.undo`, which no rebinding can remove.
+-- Only rope-level `read_only` closes that, and only the pairing keeps
+-- this repaint working after it.
local function paint(handle)
- local text = render_text(handle)
- handle.buf:replace(0, handle.buf:len(), text, { bypass_intercept = true })
+ pmacs.buffer.set_generated_contents(handle.buf, render_text(handle))
end
-- ---------------------------------------------------------------------------
diff --git a/builtin/runtime/listview.lua b/builtin/runtime/listview.lua
index 6a6d717..49f19e5 100644
--- a/builtin/runtime/listview.lua
+++ b/builtin/runtime/listview.lua
@@ -5,11 +5,16 @@
-- 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
+-- intercept (Q#P3) 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.
--
+-- Generated-buffer immutability (Q#GB1, docs/generated-buffer-immutability-framing.md):
+-- a panel's rope is genuinely read-only, and `render` is its owner's one
+-- authorized door through the lock. The intercept alone protected the
+-- edit path and left the history path open, so `C-/` emptied a panel.
+-- Ownership is the `panels` table, never a name match (Q#GB13/Q#GB18).
+--
-- 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.
@@ -24,9 +29,37 @@
pmacs.listview = pmacs.listview or {}
--- name -> { buffer, prev, header, line_to_item, on_visit, on_refresh }
+-- panels: array of
+-- { requested_name, buffer, prev, header, line_to_item, on_visit, on_refresh }
+--
+-- A LIST scanned by identity, not a name-keyed map (Q#GB18). `panels`
+-- used to be written under the name the CALLER asked for and read back
+-- under the buffer's ACTUAL name; those are the same string only while
+-- `ensure_panel` adopts whatever buffer already carries the name. Once
+-- ownership disambiguates a collision to `*references*<2>` (Q#GB13), a
+-- name-keyed lookup can never find its own record, and every consumer
+-- below fails: `RET`, `g` and `q` fail closed and silently, while
+-- `open`'s capture guard fails OPEN and captures a panel as its own `q`
+-- target --- the chained-panel loop its comment says it prevents.
+--
+-- Keyed by linear scan over `BufferIdLua.__eq` rather than by table key
+-- for the same reason dired's `handles` is (dired.lua:120-140): two
+-- BufferIdLua values for the same buffer are distinct userdata, so a
+-- `panels[buf]` lookup would miss. `compile.lua`'s `slot_for_buffer`
+-- is the third instance of this shape; listview adopts it rather than
+-- inventing a fourth.
+--
+-- Dead panels are compacted out on every scan. A map held at most one
+-- entry per name and self-limited; a list does not, so killing and
+-- reopening `*references*` ten times would otherwise leave nine dead
+-- records for every scan to walk.
local panels = {}
+-- How far the `<2>`, `<3>`, ... disambiguation walks before giving up.
+-- dired.lua:474's constant, same value, same give-up-rather-than-adopt
+-- rule.
+local NAME_VARIANT_LIMIT = 99
+
local function find_buffer_by_name(name)
for _, id in ipairs(pmacs.buffer.list()) do
local ok, d = pcall(pmacs.describe.buffer, id)
@@ -35,18 +68,52 @@ local function find_buffer_by_name(name)
return nil
end
+local function live_panels()
+ local live = {}
+ for _, p in ipairs(panels) do
+ local ok, valid = pcall(p.buffer.is_valid, p.buffer)
+ if ok and valid then live[#live + 1] = p end
+ end
+ panels = live
+ return live
+end
+
+-- The record for the panel `spec.name` asked for. Stable across
+-- disambiguation: a repeated `listview.open{ name = "*references*" }`
+-- must reach the same panel even when its buffer is called
+-- `*references*<2>`.
+local function panel_for_requested_name(name)
+ for _, p in ipairs(live_panels()) do
+ if p.requested_name == name then return p end
+ end
+ return nil
+end
+
+-- The record that owns `buf`, or nil. This is the identity question
+-- every command below actually asks.
+local function panel_for_buffer(buf)
+ if buf == nil then return nil end
+ for _, p in ipairs(live_panels()) do
+ if p.buffer == buf then return p 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]
+local function active_panel()
+ return panel_for_buffer(pmacs.window.buffer())
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.
+--
+-- One `set_generated_contents` (the owner-authorized write) rather than
+-- a delete-all + insert-all pair through `bypass_intercept`. The
+-- intercept guarded the edit path and left the HISTORY path open, so a
+-- bare `C-/` --- listview rebinds no undo chord --- emptied the panel;
+-- `M-x buffer.undo` did too, and no rebinding can remove that. The
+-- primitive lifts the rope lock, writes, discards the history and
+-- re-asserts the lock, all inside one registry borrow.
local function render(p, rows)
local lines = { p.header }
p.line_to_item = {}
@@ -54,11 +121,7 @@ local function render(p, rows)
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
+ pmacs.buffer.set_generated_contents(p.buffer, table.concat(lines, "\n"))
end
-- Re-seat the cursor on data line `line` (1-based, clamped).
@@ -87,19 +150,53 @@ local function bind_local_keymap(buf)
bind("q", "listview.quit")
end
--- Build (or adopt) the persistent panel record for `name`. Handles a
--- user-killed panel buffer by recreating it.
+-- Build the persistent panel record for `name`. A user-killed panel
+-- buffer is compacted out by `live_panels`, so the next `open` builds a
+-- fresh record rather than resurrecting a dead one.
+--
+-- Q#GB13: found-by-name is NOT adoption. `pmacs.buffer.create` takes any
+-- caller-chosen name, so a foreign buffer may already be called
+-- `*references*`; this used to adopt it, clobber the user's bytes, and
+-- install an erroring intercept whose handle it discarded --- leaving
+-- the user's buffer permanently un-editable. Rendering through
+-- `set_generated_contents` would additionally lock its rope and clear
+-- the history, removing the `M-x buffer.undo` that is currently the only
+-- way back. So ownership is "this buffer is in `panels`", a name
+-- collision disambiguates `<2>`..`<99>`, and exhausting the limit raises
+-- rather than adopting --- the rule terminal.lua:300-305 states and
+-- dired.lua:476-504 already implements.
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).
+ local p = panel_for_requested_name(name)
+ if p then return p end
+
+ local actual = name
+ if find_buffer_by_name(actual) then
+ local unique = nil
+ for i = 2, NAME_VARIANT_LIMIT do
+ local candidate = string.format("%s<%d>", name, i)
+ if find_buffer_by_name(candidate) == nil then
+ unique = candidate
+ break
+ end
+ end
+ if unique == nil then
+ error(string.format("listview: %s is taken and no free variant remains", name))
+ end
+ actual = unique
+ end
+
+ local buf = pmacs.buffer.create(actual)
+ p = { requested_name = name, buffer = buf, line_to_item = {} }
+ panels[#panels + 1] = p
+ -- Read-only (Q#P3): every non-bypass edit is rejected, with a NAMED
+ -- error. Kept beside the rope lock, not replaced by it: the layering
+ -- at terminal.lua:351-366 --- the rope lock protects the daemon copy,
+ -- this and the round-trip mark protect a semantic frontend's own
+ -- mirror, and neither substitutes for the other. 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")
+ error(actual .. " is read-only")
end)
-- Q#P6: semantic frontends must round-trip keys while this panel
-- is focused (RET = visit, not an optimistic newline).
@@ -119,7 +216,7 @@ function pmacs.listview.open(spec)
-- (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
+ if active and not panel_for_buffer(active) then
p.prev = active
end
render(p, spec.rows or {})
@@ -147,7 +244,7 @@ pmacs.command.define {
name = "listview.visit",
description = "Visit the list-panel item under the cursor.",
fn = function()
- local p = panel_for_current_buffer()
+ local p = active_panel()
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
@@ -158,7 +255,7 @@ 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()
+ local p = active_panel()
if not (p and p.on_refresh) then return end
local saved = pmacs.editor.cursor_line()
local rows = p.on_refresh() or {}
@@ -174,7 +271,7 @@ pmacs.command.define {
name = "listview.quit",
description = "Leave the list panel, restoring the previous buffer.",
fn = function()
- local p = panel_for_current_buffer()
+ local p = active_panel()
if not p then return end
-- Bottom-panel arc (Q#BP11b): `q` keeps its name and its
-- user-visible behavior, delegating to `window.quit` only when the
diff --git a/src/editor_core.rs b/src/editor_core.rs
index 661b767..28fa0f4 100644
--- a/src/editor_core.rs
+++ b/src/editor_core.rs
@@ -1833,18 +1833,39 @@ impl EditorCore {
/// and translate the live origin — otherwise accepted-match
/// highlights and the session origin survive at pre-edit offsets
/// for every Lua mutator edit and applied CRDT op.
+ ///
+ /// Q#GB6: each window coordinate is also clamped against **its own**
+ /// post-edit bound, which [`Self::rebuild_views_for`] already does
+ /// (`:1853-1857`) and this path did not. A generated refresh that
+ /// shrinks its buffer otherwise leaves `cursor` past the end of the
+ /// rope indefinitely — neither paint nor a motion command recovers
+ /// it, because motion is computed from the stale value. The two
+ /// coordinates fail on different axes and are therefore clamped
+ /// separately and **unconditionally**: `cursor` is a byte position
+ /// bounded by [`Buffer::len`], while `view_top` is a line index
+ /// bounded by [`TextView::line_count`]. A replace can grow in bytes
+ /// while collapsing many lines into one, so "the buffer shrank" is
+ /// not a usable trigger for the second.
pub fn notify_buffer_edit(&mut self, buffer_id: BufferId, edit: &Edit) {
self.search_invalidate_for_edit(buffer_id, edit);
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
return;
};
+ let len = buffer.len();
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
let _ = win.text_view.on_edit(buffer, edit);
for overlay in &mut win.overlays {
let _ = overlay.on_edit(buffer, edit);
}
+ if win.cursor > len {
+ win.cursor = len;
+ }
+ let max_top = win.text_view.line_count().saturating_sub(1);
+ if win.view_top > max_top {
+ win.view_top = max_top;
+ }
}
}
}
diff --git a/src/lua_bindings/fold.rs b/src/lua_bindings/fold.rs
index 0d4664c..1faf654 100644
--- a/src/lua_bindings/fold.rs
+++ b/src/lua_bindings/fold.rs
@@ -65,7 +65,13 @@ pub fn install_fold(lua: &Lua, fold_registry: &SharedFoldRegistry) -> mlua::Resu
let id = buf.id();
let requested = range_from_table(&range)?;
let Some(bytes) = document_bytes(lua, id)? else {
- set_status(lua, "fold rejected: not a document buffer");
+ // Q#GB16(a): the guard is spelled `is_read_only()`,
+ // so this is the message it can actually justify.
+ // Its author meant "terminal"; generated-buffer
+ // immutability makes dired listings and listview
+ // panels read-only too, and "not a document buffer"
+ // would be a false explanation for those.
+ set_status(lua, "fold rejected: buffer is read-only");
return Ok(false);
};
if requested.start > bytes.len() as u64
@@ -307,6 +313,16 @@ fn range_to_table(lua: &Lua, r: ByteRange) -> mlua::Result
{
/// The buffer's bytes if it is a normal document buffer, or `None` if it is
/// read-only (a terminal identity buffer or other non-document buffer —
/// the Q#FD11 "normal document buffer" guard).
+///
+/// Q#GB16: the guard's author meant "terminal", and `read_only` is what
+/// they had. Generated-buffer immutability widens the flag's population
+/// — a dired listing and a listview panel are read-only from their first
+/// paint — so fold **creation** is now refused on those families too.
+/// That is accepted rather than worked around (option (a)): a generated
+/// buffer's contents are replaced wholesale on every refresh, which
+/// invalidates any stored range anyway. What is *not* accepted is
+/// explaining the refusal with a sentence that is no longer true, hence
+/// the status text at the `fold` call site.
fn document_bytes(lua: &Lua, buf: BufferId) -> mlua::Result