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..3c83119 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,14 +255,18 @@ 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 {}
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)
+ -- `set_generated_contents` has already refreshed this window's
+ -- TextView. Re-seat through the editor primitives instead of
+ -- switching to the buffer it already shows: that redundant switch
+ -- rebuilt the TextView and hid a missing edit notification.
+ pmacs.editor.clear_selection()
+ pmacs.editor.set_view_top(0)
+ pmacs.editor.move_to_line(0)
seat_cursor(p, saved)
end,
}
@@ -174,7 +275,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/docs/active-work.md b/docs/active-work.md
index 87592b4..1486e81 100644
--- a/docs/active-work.md
+++ b/docs/active-work.md
@@ -245,6 +245,139 @@ If it does not, stop and repair the remote/fetch configuration.
never been enforced. Any CI job that compiles the `crdt` targets has to
fix them first or it will be red on arrival.
+## Generated-buffer immutability lane (Arc: workbench primitives) — STAGE 1 OPEN
+
+**Framing: [PR #188](https://github.com/levineuwirth/pmacs/pull/188),
+revision 7, approved and merged to `main` as `27b1185`. #188 owns the
+acceptance contract; this lane adopts it.** On 2026-07-29 the user
+directed #191 to fold its review corrections into this branch and then
+merged #188, settling the implementation authority and merge ordering.
+The contract is now
+`docs/generated-buffer-immutability-framing.md` on canonical `main`.
+
+- **Branch `generated-buffer-immutability-stage1`**, worktree
+ `../pmacs-gbi-stage1`. `githubsucks/main` is integrated into it.
+ Measured when this line was written:
+
+ ```
+ $ git rev-parse --short githubsucks/main
+ 27b1185
+ $ git log --oneline -1 githubsucks/main
+ 27b1185 Merge pull request #188 from levineuwirth/generated-buffer-immutability
+ $ git merge-base --is-ancestor githubsucks/main HEAD && echo "main IS integrated"
+ main IS integrated
+ ```
+
+ **That is a reading, not a constant, and it went stale inside this
+ lane's own review round.** `main` moved four times while the lane was
+ open: #187 -> #192 -> #193 -> #188. An earlier revision of this bullet pasted
+ the same three commands with `64883eb` and the same `main IS
+ integrated` line, and #193 merged between writing it and pushing it ---
+ so the pasted output was false in the tree that carried it. Pasting
+ command output is necessary and **not sufficient**: re-measure at push
+ time, and treat any base SHA in this file as expired on sight.
+- **What Stage 1 ships.** `dired.lua`'s `paint` and `listview.lua`'s
+ `render` write through `pmacs.buffer.set_generated_contents` (zero
+ `bypass_intercept` writes remain in either file); `listview` gains
+ Q#GB13 ownership-by-handle with `<2>`..`<99>` disambiguation and
+ Q#GB18's identity-routed `panels` list in the **same** commit;
+ Q#GB6's cursor/view-top clamp plus selection clamp-or-clear in both
+ `EditorCore::notify_buffer_edit` and `rebuild_views_for`;
+ listview refresh reseating through the already-notified view rather
+ than a redundant same-buffer switch; and Q#GB16(a)'s corrected fold
+ status string. No protocol change, no new Lua surface, no new
+ interaction island.
+- **Why these two families first, and it is not "the cheap half".**
+ `compile.lua:219` and `builtin/commands/default.lua:855` rebind all
+ seven undo chords to a no-op; `dired.lua` and `listview.lua` rebind
+ **nothing**, so a bare `C-/` emptied a listing and a panel. Stage 1
+ closes the only two families reachable without `M-x`.
+- **Review round 1 found the stale selection anchor and four acceptance
+ contract mismatches.** Its provisional drop-on-stale fix stopped the
+ crash but intentionally waited on #188 to decide the selection rule;
+ criteria 5 and 7 likewise recorded evidence without claiming to
+ replace the framing. That evidence produced #188 revision 7.
+- **Review round 2 closes both remaining P1 findings against revision
+ 7.**
+ - **Q#GB6 now matches at both sites.** Cursor and anchor clamp to the
+ new extent; a selection survives shortened unless an endpoint
+ movement collapses it, in which case it clears. `acc16h` and
+ `acc16i` each drive a real caller and assert both the surviving
+ region and collapsed case. Unconditional drop and bare clamp are
+ separately falsified.
+ - **The Stage 1 criteria are adopted without local substitutes.**
+ Criterion 5 has the exact rope-refusal + byte-identity half and the
+ Rust-lifted named-intercept half for both adopters. Criterion 7 now
+ bites the named fan-out mutation for both adopters: listview refresh
+ no longer rebuilds the view with a redundant same-buffer switch.
+ Criteria 11 and 12 carry the framing's `[main]` classification and
+ also record where its narrower Q#GB13-without-Q#GB18 pre-image
+ fails.
+- **Stage 2 still owes everything with new Rust in it**, per the
+ framing's cut: `Buffer::apply_generated_edit` + `GeneratedOutcome` +
+ the `{ generated = true }` option + its own `run_buffer_edit` arm;
+ `set_generated_contents` reimplemented over it; Q#GB10's path-backed
+ refusal and `mark_clean`; Q#GB15's `identity_protected`; Q#GB13/GB18
+ for `compile.lua` and the search panel; Q#GB5's `ensure_slot` lock;
+ conversion of the remaining 13 write sites; and the three
+ `compile_mode_acceptance` intruder tests converted per Q#GB12.
+- **Verification at code checkpoint `5d92348`.** The ledger commit on
+ top is docs-only; `cargo fmt --check` and `git diff --check` are
+ re-run after it.
+ `cargo fmt --check` clean; `cargo clippy --workspace --all-targets --
+ -D warnings` clean; library **1,863 passed + 3 ignored** default and
+ **2,048 passed + 4 ignored** CRDT; `listview_acceptance` **17**,
+ `dired_acceptance` **31**, `folding_acceptance` **21**,
+ `terminal_copy_mode_acceptance` **18** default and **19** with
+ `--features crdt` — judge that step by the count, because `acc16e` is
+ `#[cfg(feature = "crdt")]` and a default run never compiles it; M4
+ **121 passed + 3 ignored + 1 filtered** with `--skip basedpyright`;
+ required GPU **202/202**. The first GPU attempt inside the tool
+ sandbox failed three managed-attach socket tests and left the
+ closed-outbox reader blocked; the authoritative rerun outside that
+ socket sandbox passed all 202. `git diff --check` clean.
+- **The dired 200 ms perf test is load-sensitive, and the conversion
+ costs it nothing.** Review saw `dired_renders_10k_entries_within_200ms`
+ take 241 ms in a combined run and pass alone. Measured here: 0.09 s
+ isolated over five runs, and the whole 31-test suite finishes in
+ 0.12 s, so 241 ms was contention rather than a regression. Measured
+ against the pre-image as well, by swapping in `main`'s `dired.lua`
+ (the `bypass_intercept` paint): **0.09 s either way over five runs
+ each**. A whole-buffer `set_generated_contents` costs the same as the
+ bypass replace it replaces, which discharges Q#GB4's measurement
+ obligation for the whole-buffer case only — the streaming case is
+ Stage 2's and is not touched here.
+- **Bites, re-run under `scripts/bite`'s positive control (#192).**
+ A bare `bite: OK` from the pre-#192 script is weaker than it looks, so
+ every result below is from the current script or from a mutation
+ harness carrying the same control (named tests must pass on the
+ working tree and at least one must have run).
+ - **Falsified by revert, all `OK (assertion)` — not `OK (COMPILE)`:**
+ `builtin/runtime/listview.lua` for criteria 1, 2, 9 and 10;
+ `builtin/runtime/dired.lua` for criteria 3 and 13a;
+ `src/lua_bindings/fold.rs` for 13b; `src/editor_core.rs` for 8, 8b
+ and both selection-normalization pins.
+ - **Falsified by a named mutation, each observed to fail:** the
+ fan-out drop in the `set_generated_contents` binding (criterion 7);
+ deleting `self.read_only = false` (criterion 4, both adopters);
+ deleting `add_intercept` and `set_round_trip_input` at each adopter
+ (criteria 5 and 6); the name-keyed `panel_for_buffer` (criteria 11
+ and 12); adopting at the variant limit (criterion 10); the old fold
+ status string (13b); deleting each clamp (8, 8b); deleting the
+ selection helper from either site; unconditionally dropping a stale
+ anchor; and retaining a selection that an endpoint clamp collapsed.
+ Criterion 7's fan-out drop now fails by assertion in **both**
+ listview and dired.
+- **Recovery:**
+
+ ```sh
+ git fetch githubsucks
+ git worktree add ../pmacs-gbi-stage1 generated-buffer-immutability-stage1
+ cd ../pmacs-gbi-stage1
+ cargo test --test listview_acceptance --test dired_acceptance
+ cargo test --test terminal_copy_mode_acceptance --features crdt
+ ```
+
## Bottom-panel lane (Arc 7) — 2B-2 MERGED; 2B-3 IS NEXT
Stage 1, the Stage 2 framing, Stage 2A, Stage 2B-1, and **Stage 2B-2 are
@@ -826,21 +959,22 @@ has **no branch and no framing yet**.
`git fetch githubsucks && git worktree add ../pmacs-dired-s2
-b dired-stage2-impl githubsucks/dired-stage2-impl`.
-## Generated-buffer immutability framing lane — PR #188 OPEN, PROPOSED
+## Generated-buffer immutability framing lane — MERGED AS PR #188
- Portable branch: `githubsucks/generated-buffer-immutability`; worktree
- `../pmacs-generated-immutability`. **PR #188**, base `main`, forked from
- `githubsucks/main` @ `ad41cf1`, **integrated through `5e186c7`** —
+ `../pmacs-generated-immutability`. **PR #188 landed on `main` as
+ `27b1185` on 2026-07-29**, after forking from
+ `githubsucks/main` @ `ad41cf1` and integrating through `5e186c7` —
#189 (clean), then #186 and #171 (`docs/active-work.md` conflict),
then #187 (the same file again, after it removed the two landed
framing lanes), #192 at merge commit `76cfaac`, and #193
(`docs/active-work.md` conflict again) after revision 7's first push.
Revision 6 was reviewed at head `55c3061`; revision 7 closes that
- round. Framing only —
+ round. The retained branch is provenance only. Framing only —
`docs/generated-buffer-immutability-framing.md`, revision 7, plus this
lane. **No runtime code, no protocol change.**
-- **PROPOSED — six review rounds closed (thirty-two findings,
- twenty-two P1, ten P2). Not approved. Do not implement, do not merge.**
+- **APPROVED and merged after six review rounds** (thirty-two findings,
+ twenty-two P1, ten P2). Revision 7 is the governing contract.
- **Stage 1 implementation is PR #191, open. The boundary is explicit
and has already been needed twice:** #188 owns the **acceptance
contract**; #191 **adopts** criteria and may not restate, narrow, or
@@ -917,7 +1051,7 @@ has **no branch and no framing yet**.
panel's four, compile/search ownership + routing, the path-backed
refusal plus `mark_clean`, and the terminal-only
`identity_protected` guard. **No Lua unlock ships.**
-- **Nine facts from this lane that other lanes need before it merges:**
+- **Nine facts this lane landed for other lanes:**
- **`bypass_intercept` is the wrong inventory key.** It misses
`*buffer-list*`, `*help*` and `*workers*`, which are generated with
plain writes and no intercept at all. `docs/agent-handoff.md` §4's
diff --git a/docs/generated-buffer-immutability-framing.md b/docs/generated-buffer-immutability-framing.md
index c8c6b63..71439cb 100644
--- a/docs/generated-buffer-immutability-framing.md
+++ b/docs/generated-buffer-immutability-framing.md
@@ -1,7 +1,7 @@
# Generated-buffer immutability
-**PROPOSED — needs explicit user approval before implementation. DO NOT
-implement, DO NOT merge.**
+**APPROVED and MERGED as PR #188** (`main` @ `27b1185`, 2026-07-29).
+Stage 1 implementation is PR #191.
**Revision 7 — answers review round 6 on `55c3061`, authored against
canonical `githubsucks/main` @ `64883eb` and integrated through
diff --git a/src/editor_core.rs b/src/editor_core.rs
index 9b8b5d3..6a8ec90 100644
--- a/src/editor_core.rs
+++ b/src/editor_core.rs
@@ -1845,22 +1845,66 @@ 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.
+ ///
+ /// The selection anchor is a **third** coordinate. It is clamped to
+ /// the buffer extent, and the selection is cleared only when moving
+ /// an endpoint collapses it — see
+ /// [`Self::clamp_cursor_and_selection`].
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);
}
+ Self::clamp_cursor_and_selection(win, len);
+ let max_top = win.text_view.line_count().saturating_sub(1);
+ if win.view_top > max_top {
+ win.view_top = max_top;
+ }
}
}
}
+ /// Clamp `win`'s cursor and selection anchor to `len`, clearing the
+ /// selection only when the clamp collapses it.
+ ///
+ /// This mirrors terminal selection normalization: a surviving,
+ /// shortened region remains selected, while a region whose content
+ /// disappeared does not become an accidental active-but-empty
+ /// selection. Looking at whether either endpoint moved distinguishes
+ /// that case from a zero-width selection the user created.
+ fn clamp_cursor_and_selection(win: &mut Window, len: Position) {
+ let old_cursor = win.cursor;
+ win.cursor = win.cursor.min(len);
+ win.selection = win.selection.and_then(|mut selection| {
+ let old_anchor = selection.anchor;
+ selection.anchor = selection.anchor.min(len);
+ let collapsed_by_clamp = selection.anchor == win.cursor
+ && (selection.anchor != old_anchor || win.cursor != old_cursor);
+ (!collapsed_by_clamp).then_some(selection)
+ });
+ }
+
/// Force every window currently showing `buffer_id` to rebuild
/// its [`TextView`] from scratch.
///
@@ -1874,6 +1918,8 @@ impl EditorCore {
///
/// Cursor and `view_top` are clamped to the new buffer extent so
/// they don't dangle past the end after a shrinking rewrite.
+ /// Selection normalization uses the same clamp-or-clear rule as
+ /// [`Self::notify_buffer_edit`].
pub fn rebuild_views_for(&mut self, buffer_id: BufferId) {
let reg = self.registry.borrow();
let Ok(buffer) = reg.get(buffer_id) else {
@@ -1883,9 +1929,7 @@ impl EditorCore {
for win in self.windows.values_mut() {
if win.buffer_id == buffer_id {
win.text_view = TextView::new(buffer);
- if win.cursor > len {
- win.cursor = len;
- }
+ Self::clamp_cursor_and_selection(win, 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