Addresses the PR #103 round-3 review: length-delta verification is
defeated by an intercept that rewrites an op to a DIFFERENT
equal-length range, and "replacement text appears at start" is defeated
by one that enlarges `end` by a byte.
The buffer mutators (buf:insert/delete/replace) now RETURN the
effective edit — `(start, end, inserted_len)` of the post-intercept
operation actually applied (they returned nothing before, so no caller
breaks). killring compares those against what it requested:
- C-k / cut: any deviation (shifted range, resized range, nonzero
insertion) means the bytes removed are not the bytes sliced — the
ring and OS clipboard receive nothing, the chain clears, and the
interceptor's result stands. cut now goes through buf:delete (for
the effective edit) with explicit clear_selection + goto_byte.
- M-y: any deviation from (s.start, s.stop, #entry.text) drops the
session — including the end+1 enlargement that silently deleted an
extra byte while passing the old text-at-start check. The redundant
post-replace slice verify is gone; the exact contract replaces it.
Tests (kill_ring_acceptance now 30):
equal_length_shifted_delete_does_not_feed_the_ring (delete shifted +2,
same length — the case a length delta cannot see),
stop_enlarging_replace_ends_the_yank_session (mid-buffer yank so the
enlarged range is valid and the transform path — not range validation —
is what fires; at buffer end the same intercept fails validation and
takes the rejection path, which also drops the session).
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 30;
cua 5; m6_4/m6_5 repl (mutator-heavy) 15/11; git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Arc 2 (docs/kill-ring-framing.md, rev 3 — three review rounds). Kills
accumulate in a ring; consecutive kills append; C-y yanks the head; M-y
right after a yank cycles older entries; C-k (kill-line) exists at last.
The ring is daemon-global (Emacs-daemon model); chains and yank sessions
are per-frontend.
The substrate (Q#KR2): EditorCore.command_history maps FrontendId ->
{this, last} command. Every input path updates it --- the rev-1 design
treated dispatch_key as the only input path and review falsified that
twice:
keybound command dispatch_key Run arm rotate
typed char (round-trip) self-insert fallback rotate
unbound key dispatch_key unbound arm break
GPU optimistic edit handle_remote_crdt_op break
pointer gesture dispatch_mouse + dispatch_pointer break
inbound OS paste unified paste route break
menu item menu_invoke_active rotate
M-x accept pmacs.command.invoke_interactive rotate
invoke_interactive gives Emacs's execute-extended-command semantics
(M-x kill-line then C-k appends; C-k then M-x kill-line does not); the
public pmacs.command.invoke stamps nothing. Wheel scroll deliberately
does NOT break (mwheel-scroll vs mouse-set-point, as in Emacs).
Three shipped bugs fixed en route (Q#KR10):
- Semantic-path Paste was dropped ("no grid-less effect yet"), and the
GPU always negotiates semantic render --- GPU Ctrl-V was a no-op. Paste
is now a dispatcher-level arm serving both attachment kinds.
- That arm keys off the dispatcher's AUTHENTICATED source; the old grid
arm trusted the client-supplied payload frontend_id, letting a forged
id paste into another frontend's active window (unit-tested).
- Paste, M-x-invoked commands, and menu-invoked commands never fired
buffer.after-edit (each runs outside dispatch_key's revision check),
so LSP/syntax/autosave missed those edits. A shared
with_after_edit_check helper now wraps all three sites; scope is
honest --- active-buffer compare, sound for these paths, not a general
any-buffer guarantee (buffer-aware edit epoch deferred).
The ring (killring.lua, Q#KR4-7): entries carry stable monotonic ids.
Append requires last_command in the kill family AND this frontend's
last_kill_id == the head's id --- A-kill/B-kill/A-kill pushes fresh
instead of corrupting B's entry. Yank sessions store {buffer, start,
stop, entry_id, text}: M-y validates last_command + live session + same
buffer + slice(start,stop) == text (out-of-bounds reads as changed ---
pcall'd; an early test caught the guard throwing on an upstream
deletion instead of refusing), rotates by locating the entry_id's
CURRENT position (positions shift under other frontends' pushes; ids
don't), verifies the applied replace (intercepts may alter it; accepted
post-hoc semantics), then goto_byte. Failed kills clear last_kill_id;
failed/refused yanks create no session, so a second invalid M-y cannot
ride the first's name-stamp.
OS clipboard: ring head mirrors to the ACTING frontend's OS clipboard
only (pending_clipboard's existing shape; frontends may be different
machines). External content joins the ring at yank time via the
clipboard_get slot check (an OS copy reaches the daemon only when
pasted). New core seams: clipboard_set(bytes) / clipboard_get.
Lifecycle (Q#KR11): SessionDetached prunes command_history and fires the
new frontend.detached hook (raw id); killring.lua drops that frontend's
tables.
pmacs.killring.max([n]) validated (non-finite rejected --- math.huge
would defeat the cap; shrink trims immediately), default 60.
Deferred, named: word kills (M-d/M-BS/C-BS/C-h/C-DEL discard bytes ---
needs bytes-returning deleters), C-SPC/set-mark, clipboard watching,
ring browser/persistence, C-u C-y / C-M-w, buffer-aware edit epoch,
Lua-visible intercept probe.
Tests: tests/kill_ring_acceptance.rs (24) --- chain mechanics incl. all
break rows, the M-x three-direction matrix, per-frontend interleaving
(A-kill/B-kill/A-kill; stable-id rotation under B's pushes; eviction
mid-session; upstream-edit invalidation), menu Cut via real right-click
+ menu pointer (feeds ring, fires after-edit once, chains with C-k),
external-paste integration, cap validation + shrink-trim, detach
cleanup. Plus daemon unit tests: forged-id paste lands in the
authenticated source's window and leaves the claimed frontend's chain
untouched; optimistic CRDT op breaks only the source's chain.
Gates: fmt + workspace clippy clean; lib 1500; crdt 1672; killring 24;
cua 5; query-replace 16; completion 9; autosave 29; desktop 11;
persistence 5; clobber 6; m4 90; m8 10+15; m10/m11 crdt; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
EditorCore::save() wrote unconditionally via save_atomic and only THEN
recorded the new FileMeta. It never compared the on-disk identity against
the one the buffer read. So another editor's writes, or a `git checkout`,
were destroyed without a word --- the single worst data-loss path in the
editor, on its most-used command.
The comparison seam already existed and no caller used it: FileMeta is
PartialEq (mtime + size, sized so same-second edits still differ) and
file_io::current_meta reads it. The file_io module docstring even said
callers "should" compare before saving. Nobody did.
save() now refuses when writing would destroy content the buffer has
never seen:
* the buffer recorded a meta and the on-disk meta differs --- someone
else wrote the file;
* the buffer recorded NO meta (a `[new file]`, or a path set without
reading) yet a file now exists --- it was created underneath us.
A *missing* file is not a clobber: there is nothing to destroy, so
recreating a deleted file saves normally. An unstattable path falls
through and save_atomic reports the real error.
On refusal the status line says what happened and how to override, the
buffer keeps its unsaved edits, and `buffer.after-save` does not fire.
`M-x buffer.save-anyway` (ed.save_ignoring_disk_changes) overwrites
deliberately and re-syncs the meta, so an ordinary save works again.
Named `buffer.save-anyway`, not `save-buffer-anyway`, for two reasons: it
belongs in the `buffer.` namespace next to `buffer.save`, and the latter
outranked `buffer.save` as an M-x completion for "save" (which a lib test
caught).
This is the bug the autosave arc kept circling: Q#AS5's Fresh/Stale guard
refuses to auto-offer a recovery for an externally-changed file, but
nothing stopped save() from overwriting that same file.
Tests: tests/save_clobber_guard_acceptance.rs (6) --- refuses and leaves
their content intact, after-save does not fire on refusal, save-anyway
overwrites and re-syncs meta, unchanged files save repeatedly (the guard
must not trip on our own writes), a deleted file is recreated not
refused, and a `[new file]` buffer refuses once someone else creates the
file. Verified the tests bite: 4 of 6 fail with the guard disabled.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; clobber 6; m1/m3
/m4 90/m5.8/m7.8/m8 10+15; autosave 29; desktop 11; persistence 5;
query-replace/completion/listview/overlay/cua green; GPU 58;
git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review round 2. Q#AS12's ownership rule guarded the
sweep but not the RELEASE paths, so three doors were still open.
The rule is now total: exactly two things may release an unclaimed
recovery file --- recover-file (which adopts it) and discard-recovery
(explicit user intent). Not a sweep, not a save, not a kill.
- HIGH: buffer.after-save called _discard_buffer unconditionally, which
removed the live buffer's current-path key without checking ownership.
Repro: session 1 autosaves and crashes; session 2 opens the file, does
not recover, then saves --- the crash artifact was deleted. Same door
was open on kill. discard_buffer now removes ONLY keys this session
owns. The unclaimed copy survives (reported Stale, so never
auto-offered, but still recoverable/discardable). The on-disk file holds
the new work; the crash copy holds work never written anywhere, so
deleting it was the same data loss by a different door.
- MEDIUM/LOW: _adopt only recorded the path in `owned`, not an
association with the buffer. A removal callback fires after the buffer
has left the registry, so discard_buffer had no path to read and no
`written` entry to fall back on --- recover-then-kill leaked the copy
and it was offered again. adopt now takes the BUFFER and records a
`written` entry at the revision whose contents the file holds. That is
correct twice over: the skip cache declines to rewrite an identical
copy, and a kill can find and retire it.
- LOW: _discard(path) removed the file and unowned the hash but left
matching `written` entries, so a still-dirty buffer hit the unchanged
(path_hash, revision) fast path and went unprotected until its next
edit. discard_path now clears those entries; the next sweep re-protects
immediately.
Tests (autosave_acceptance now 24):
saving_without_recovering_preserves_unclaimed_crash_data,
killing_without_recovering_preserves_unclaimed_crash_data,
recover_then_kill_retires_the_adopted_recovery,
discard_recovery_lets_the_next_sweep_reprotect_immediately.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 24;
desktop 11; persistence 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #100 review.
- HIGH data loss: sweep could overwrite an existing crash recovery before
the user ran recover-file. Reopen a file after a crash, edit it, and the
next autosave wrote the current buffer over the recovery key --- losing
exactly what autosave exists to protect. New ownership rule (Q#AS12): a
per-session `owned` set records which path hashes THIS session wrote or
adopted. A recovery file at a key we do not own is unclaimed crash data;
the sweep refuses to write that buffer, counts it `blocked`, and says so
("autosave paused for N file(s) with unclaimed recovery"). recover-file
ADOPTS the copy once its contents are in the buffer; discard-recovery
removes it. Either resumes normal autosave. sweep() now returns
(written, blocked).
- MEDIUM cleanup missed paths autosave can write. Kill/save cleanup now
goes through `discard_buffer(BufferId)`, which removes BOTH the buffer's
current-path key and the key its last sweep actually wrote (they differ
after a rename --- an LSP WorkspaceEdit changes the path while the
BufferId stays; a path-captured callback deleted the wrong key). And a
sweep-time GC deletes the recovery of any buffer that left the registry,
which is the backstop for argv `[new file]` buffers: they fire no
after-load, so no removal callback is ever registered for them.
- LOW/MEDIUM recover-file pinned only on the active path. Two buffers can
visit one path (pmacs.buffer.from_file does not dedup), so focus drift
could recover into the wrong buffer. It now captures and compares the
origin buffer handle as well as the path.
- LOW write_private left a pre-existing lax autosave/ directory alone. The
birth-mode only applies to dirs that call creates, so a 0755 autosave/
from an older run still leaked recovery-file names, sizes, and mtimes
despite 0600 contents. It is now tightened to 0700 --- but never `base`
itself, which is shared with history/recentf/desktop and may predate us.
New `state::exists` (an existence check, no read) backs the ownership
gate.
Tests (autosave_acceptance now 20): sweep_never_overwrites_unclaimed_
crash_recovery (blocked, crash copy byte-identical, adopt resumes),
discarding_an_unclaimed_recovery_unblocks_the_sweep,
killing_a_new_file_buffer_gcs_its_recovery,
saving_after_a_rename_removes_the_recovery_written_under_the_old_path,
a_pre_existing_lax_autosave_dir_is_tightened.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 20;
desktop 11; persistence 5; m4 90; m7_8 5; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/autosave-recovery-framing.md (Q#AS1-11). Closes the
persistence arc. Every modified file buffer is periodically written to a
private recovery copy; if pmacs dies, the next session says so and
`M-x recover-file` installs it. Emacs's auto-save-mode + recover-file.
Hybrid, forced by the same two gaps as phase 2: Lua has no per-buffer
path getter and FileMeta is neither Lua-visible nor serde. Rust owns the
sweep and the external-change guard; Lua owns cadence, config, and UX.
src/autosave.rs (new):
- One atomic envelope per recovery: a JSON header line + `\n` + raw
buffer bytes. Split at the FIRST newline, so contents may hold newlines
and non-UTF-8. A crash can never leave a torn header/contents pair.
- `origin` is NULLABLE: a `[new file]` buffer (a path with nothing on
disk) has no FileMeta, and its unsaved contents are exactly the work
most worth recovering.
- status(): Fresh / Stale / Corrupt / None. Only Fresh is announced;
Stale (file changed, deleted, or created underneath us) is never
auto-offered; Corrupt is typed, quiet, and discardable.
- sweep(): all modified file buffers, skipping clean/scratch and those
unchanged since their last copy. The skip cache is keyed
BufferId -> (path_hash, revision), not revision alone: a buffer keeps
its BufferId across a path change (LSP WorkspaceEdit rename), so a
revision-only cache would skip the write and orphan the old key.
- pending(): enumerates ALL open file buffers in Rust, which is what
covers argv `[new file]` buffers -- they fire no hook at all.
Private storage (Q#AS11, a precondition for default-on): autosave stores
unsaved FILE CONTENTS, not metadata. New `file_io::save_atomic_with_mode`
sets the temp's mode BEFORE the rename (a chmod-after-write leaves a
window where the file is 0644), and `state::write_private` creates the
dir 0700 and the file 0600. Plus `state::read_bytes` (state::read is
read_to_string, which non-UTF-8 buffer contents would fail).
builtin/runtime/autosave.lua:
- Cadence is `process.after-tick` + monotonic_ms, NOT workers.sleep: a
long sleep parks one of only `available_parallelism - 1` pool threads,
and re-reading the interval each tick makes it live-reconfigurable.
- pmacs.autosave.interval_ms([ms]) -- validated getter/setter following
the async_config.frame_target_ms shape. Default 30000, floor 1000.
pmacs.autosave.enable(on). On by default.
- Notify, never prompt: `after-load` only raises a flag; the tick emits
ONE aggregate message ("3 files have autosave recovery"). A modal
prompt from after-load would stack N modals during a desktop restore.
- recover-file confirms, pins to the origin buffer, replaces contents,
then explicitly fires `buffer.after-edit` -- the mutators only notify
windows and queue CRDT, and after-edit comes from dispatch_key's
post-command check, which the minibuffer shadow returns before. Without
the explicit fire, LSP didChange and the syntax reparse never see the
recovery. discard-recovery deletes a copy (including a Corrupt one).
- Cleanup: after-save discards; per-buffer on_removed discards on kill
(there is no global kill hook); before-quit does one final synchronous
sweep and never vetoes.
src/hash.rs (new): one pub(crate) sha256_hex, shared by desktop, autosave,
and packages::fetcher -- which had two private duplicates (Q#AS9).
Not daemon-gated (unlike desktop-save): autosave is per-buffer, not
per-frontend, and a daemon holds the unsaved work.
Tests: 8 autosave units + 13 state/hash units + tests/autosave_acceptance
(15): sweep round-trip, non-UTF-8 envelope, [new file] null-origin
Fresh->Stale, 0600/0700 perms, skip clean/scratch/unchanged, path-change
rewrites new key + discards old, save/kill cleanup, Stale not offered,
Corrupt typed+quiet+discardable, recover-file installs + fires after-edit
+ leaves modified, tick aggregation (3 loads -> 1 message, no repeat),
single-file naming, interval validation + live change, enable gate,
before-quit sweeps without vetoing.
Gates: fmt + workspace clippy clean; lib 1499; crdt 1670; autosave 15;
desktop 11; persistence 5; m4 90; m7_8 5; m8 10; GPU 58; git diff --check.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #99 review:
- HIGH daemon local-only was not reliable: run_daemon sets DaemonMode
only after EditorState::new() has run init.lua, so desktop_mode(true)
in init saw is_daemon()==false and the raw bindings were ungated. Now
save_session/restore_session early-return in Rust when the DaemonMode
marker is present — set right after the daemon's new(), so it holds for
every save/restore that can run after startup (before-quit hook, manual
commands, direct binding calls).
- MEDIUM desktop_mode(false) could not unarm startup restore: arm_restore
is now a boolean (arm_restore(on)) that sets/removes the marker, and
desktop_mode(on) calls arm_restore(on). enable-then-disable no longer
restores.
- MEDIUM/LOW same-file multi-pane missed per-window overlays: restore now
fires buffer.after-load once PER LEAF (per window), not once per buffer.
Syntax attaches its overlay to the active window, so each pane gets its
own; LSP attach_buffer is idempotent, so the same file in two panes
attaches LSP once but syntax to both.
- MEDIUM hidden restored buffers: documented as registry-only in v1 (they
are live/openable/in recentf, but do not fire after-load, so they
attach syntax on first visit via after-switch and LSP when next shown).
Full initial attach for hidden buffers is deferred. Noted in the
framing + a code comment.
- LOW trailing whitespace in docs/desktop-save-framing.md.
Tests (desktop_acceptance now 11): same_file_..._fires_per_pane asserts
after-load fires twice for two panes of one file; daemon_mode_disables_
save_and_restore; disabling_desktop_mode_unarms_restore.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 11;
persistence 5; m4 90; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/desktop-save-framing.md (Q#DS1-10). Save the open file
buffers, window layout, and per-window positions on quit; rebuild them
on startup. Emacs desktop.el, opt-in, local-mode only in v1.
All-Rust (Q#DS1) --- the core window enums are not serde and there is
no Lua tree API, so the layout mirror + structural rebuild live in Rust.
Lua adds only the opt-in switch and manual commands.
src/desktop.rs (new):
- Serde mirror (SavedDesktop / SavedBuffer / SavedNode / SavedLeaf /
SavedOrientation): every open file buffer (visible OR hidden, so a
switched-away file survives), the layout tree with orientation +
weights + nesting, per-leaf cursor/view_top, and an active-leaf
preorder index with a nearest-neighbor fallback (Q#DS10).
- session_key: SHA-256, name.<hex> when a socket name is set else
cwd.<hex> (charset-safe for the pmacs.state key).
- save_session / restore_session take the &Lua that carries the
SharedCore / StateDir / LocalInstanceInfo app-data, so they run
identically from a pmacs.session.* binding and the startup trigger.
- restore ordering (Q#DS3): open all buffers; prune EVERY window of the
old LOCAL layout (not just scratch); rebuild the tree; then per leaf
in preorder activate its window and fire buffer.after-load once per
newly-loaded buffer (hooks read active state), and write the exact
cursor/view_top AFTER so desktop wins over saveplace (same file in two
panes keeps distinct positions). A missing file collapses its leaf.
src/editor_core.rs: get_or_load_buffer(path) --- find_by_path else
load fresh, WITHOUT switching the active window; returns (id, newly).
src/lua_bindings: pmacs.session.{save_desktop, restore_desktop,
arm_restore, is_daemon}; DesktopRestoreArmed + DaemonMode markers;
fire_after_load_hook seam.
builtin/runtime/desktop.lua: pmacs.session.desktop_mode(on) wires
before-quit save + arms restore; desktop-save / desktop-restore
commands. No-op under a daemon (Q#DS9).
Startup trigger (Q#DS7): editor::run captures had_file before the match
consumes `file`, and restore_desktop_if_armed runs INSIDE the RunLocal
arm (after attach dispatch) so a hand-off to attach never populates an
EditorState it is about to drop. Daemon marks DaemonMode → desktop
stays local-only.
Tests: src/desktop.rs units (tree collapse, active-leaf fallback,
key/json round-trip) + tests/desktop_acceptance.rs (9): nested weighted
round-trip, hidden-buffer survival, after-load-active probe, same-file
two-pane distinct positions, missing-file collapse + focus fallback, no
orphan windows, name-vs-cwd key scoping, modified warning, startup gate.
Gates: fmt + workspace clippy clean; lib 1487; crdt 1658; desktop 9;
persistence 5; m4 90; m8 daemon 10/15; query-replace/completion/
listview/overlay/cua green; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Addresses the PR #98 review:
- HIGH symlink escape: resolve() did only a lexical starts_with, so a
base/autosave symlink -> /tmp/out let state.write("autosave/x") write
outside the state dir. Now every existing component the key adds under
base is lstat'd and a symlink (live OR broken) is rejected; base itself
may still be a symlink (dotfile-managed ~/.local/state). Unix symlink
escape test added (live + broken + plain-subdir-ok).
- MEDIUM integration-test state leak: the state/history dir wiring moved
out of EditorState::new() into EditorState::install_state_dirs(),
called only by the real entry points (editor::run, run_daemon). Unit
AND integration tests construct EditorState directly, so they never
configure a real dir -> default-on recentf/saveplace write nothing to
~/.local/state/pmacs during cargo test. The inertness test now asserts
a bare new() leaves StateDir unconfigured (direct proof).
- MEDIUM saveplace never recorded view_top: exposed the missing
pmacs.editor.view_top() getter (set_view_top existed but no getter, so
the Lua stored 0). saveplace now records+restores the viewport;
acceptance asserts view_top restores, not just the cursor byte.
- MEDIUM/LOW relative XDG_STATE_HOME / PMACS_STATE_HOME: a relative
value rooted state at a cwd-relative pmacs/... (same footgun class as
the empty case). Both are now required absolute; relative values are
ignored (XDG falls through to HOME). Test added.
- LOW trailing blank line at recentf.lua EOF (git diff --check).
Gates: fmt + workspace clippy clean; lib 1483; crdt 1654; persistence 5;
m4 90; m8_1/m8_2 daemon 10/15; query-replace/completion/listview/overlay/
cua green; GPU 58; git diff --check clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Framing: docs/persistence-framing.md. The four Rust primitives the
Lua-vs-Rust scout said were unavoidable, plus two Lua policy modules.
Rust:
- src/state.rs: state_dir(xdg,home) returning .../pmacs (generalizes
the baked-in history path). Deliberate empty-XDG fix (Q#PS2): a blank
XDG_STATE_HOME fell through to a RELATIVE pmacs/... path (a cwd-write
bug); now treated as absent so it falls to HOME. Confined key->file
store: validate_name rejects absolute / .. / empty / // / control
chars, plus a canonical-prefix belt; read/write/remove go through
file_io::save_atomic, never raw io.open. A PMACS_STATE_HOME override
lets CI / privacy-conscious users / integration harnesses redirect
all state to a scratch dir. History routed through the shared
resolver so it honors the override too.
- pmacs.state.{write,read,remove,path,available}: a no-op when the
state dir is unconfigured (cfg(test) / no HOME), so default-on
builtins write nothing in the lib suite. Configured once at startup
like history_dir, skipped under cfg(test).
- pmacs.editor.goto_byte / set_view_top: byte-exact restore (switch
zeroes the cursor).
Lua (builtin/runtime):
- saveplace.lua: record the active file's cursor+view_top on
before-save / before-quit; restore on after-load. LRU-capped places
state file. On by default; pmacs.saveplace.enable(false).
- recentf.lua: MRU record on after-load AND after-switch (re-visits
refresh the order); deduped/capped recentf file; a recent-files
command bound C-x C-r opens the minibuffer picker.
Tests: state.rs units (validate/resolve/round-trip/empty-XDG),
tests/persistence_acceptance.rs (state round-trip + confinement
rejections, inert-when-unconfigured, recentf MRU/dedup, saveplace
restore-on-reload, disable knob) injecting a tempdir state root. One
describe-hook test made robust to a builtin now subscribing to
buffer.before-save.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Emacs query-replace, built on isearch with zero protocol change
(framing: docs/query-replace-framing.md).
- search.rs: find_first_from (literal) + find_first_regex_from (cached
engine, zero-width-skip) + compile_search_regex (shared smart-case
compile). Q#QR2's forward-scan-past-replacement primitive.
- QueryReplaceSession + core methods (editor_core.rs): begin (invalid
regex refuses, Q#QR2), replace/skip/all/replace-and-quit/finish;
matches run forward from next_from on the LIVE buffer, so offset
shifts and never-re-matching-replacements (a->aa) fall out for free;
current match highlighted via a single-element search_store set
(SearchMatchActive, both frontends free) + cursor reveal; quit keeps
replacements, only nothing-matched restores origin (Q#QR10).
- Dispatcher shadow (editor.rs): QueryReplaceKey (y/SPC, n/DEL, !, .,
q/RET/Esc/C-g) + dispatch_query_replace_key, the 5th modal shadow;
added to dispatch_idle disjunction (GPU round-trips keys) and fires
buffer.after-edit itself (Q#QR1 — a shadow returns before the normal
post-command check; once per !-batch).
- Lua: ed.query_replace_start/query_replace_active; query-replace /
query-replace-regexp commands (chained minibuffer.read, separate
from/to history buckets, empty-from reject / empty-to deletion);
M-% / C-M-% bindings.
- Per-match prompt via core.status → v15 StatusFacts.message band.
Tests: 7 core unit + 11 dispatch_key acceptance (replace/skip/!/./quit,
nothing-matched restore, empty-to deletion, a->aa non-loop, regex incl
invalid, after-edit fires, dispatch_idle gate, explicit M-% AND C-M-%
binding tests) + 5 search unit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Two PR #94 validation findings.
1. (High, blocking) GPU stuck after leaving a panel: the GPU only
swaps its displayed buffer on BufferSnapshot, and the daemon only
sent one on the first CRDT upgrade (F29's ensure returns None for
an already-backed buffer). A panel's q / RET switched the daemon's
active buffer back to the already-known source and sent nothing --
the GPU kept rendering the panel while input targeted the source: a
typing-into-a-buffer-you-can't-see hazard. Fix: the per-tick loop
now FOLLOWS each replica frontend's own active buffer -- when it
differs from the last snapshot sent to that frontend, ship that
buffer's snapshot to that frontend only (the F29 broadcast records
itself so the upgrade tick doesn't double-send). First-tick send
also repairs the attach-time last-snapshot-wins ambiguity. Snapshot
export extracted and shared with the F29 broadcast; per-fid state
cleaned on both detach paths.
2. (High, wider than reported) 'LSP doesn't activate on navigate':
switch_active_buffer clears the window's overlays, and the runtime
dedup tables (highlighted_buffers, styled_buffers,
diag_viewed_buffers) blocked re-attachment -- so EVERY buffer
switch (plain C-x b included, long-latent) permanently stripped
syntax color, LSP semantic style, and diagnostic underlines;
verified: overlay kinds [syntax-highlight, lsp-style, diagnostic]
-> [] after one away-and-back. Fix: a new additive
buffer.after-switch hook, fired by the window.switch_buffer binding
and find_or_open's existing-buffer branch; syntax.lua and lsp.lua
subscribe and re-push their views (the just-cleared window makes
that exactly-once per switch; fresh loads keep firing after-load).
Regression: tests/overlay_reattach_acceptance.rs (double round-trip
counts exactly one highlight overlay; panel q restores styling).
The daemon follow path is validated live (daemon + GPU) -- its unit
seam is the shared export helper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
Seven end-to-end tests through dispatch_key: dabbrev auto-open + TAB
accept, C-n/RET second-candidate accept, Esc dismiss with fall-through
typing (and no same-edit reopen), Home-breaks-anchor validation close,
yank-shaped edits never auto-open (Q#C9), C-M-i below the threshold,
and ctx.uri scoping through the Lua provider surface.
The suite immediately caught a real wiring bug: install_completion
(M4.7, runs at make_lsp_manager time) built pmacs.completion with a
fresh lua.create_table(), clobbering the popup bindings installed at
editor-attach time --- popup_visible was nil at runtime and the driver
hook errored silently into *errors* on every edit. It now merges into
the existing table, the same idiom as install_completion_framework,
so installer order no longer matters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Q#C1/C9: builtin/runtime/completion.lua reconstructs typing intent
from state (buffer.after-edit has no payload): a {buffer, cursor}
snapshot recognizes the single-byte-advance typing signature, so
paste/undo/kill/remote edits never auto-open; prefix >= 2 opens off
the synchronous providers, server trigger chars open a pending session
that materializes when the LSP answer lands; refresh-on-typing
re-derives the prefix from the text; a core-closed popup suppresses
reopen off the same edit (the accept case). completion.at-point on
C-M-i covers deliberate invocation; the driver filters collect() to
score >= 0 (collect keeps non-matches, merely sorted last).
Q#C8: CompletionContext gains uri; the built-in LSP provider scopes to
it (legacy global drain only when absent); Lua providers get uri as a
trailing ninth positional arg; context_for can now express char
triggers + uri. pmacs.lsp.attachment_for_request() exposes the
flushing accessor (attached_for_active) so completion requests answer
against current text, not the debounced didChange backlog.
Q#C2 write path: pmacs.completion.popup_show/popup_hide/popup_visible
publish into the core session (kind tags shared with collect() rows,
so driver code passes rows straight through).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Extend the gutter with the last two of the framed modes (Q#UX4):
- Relative: each line shows its distance from the cursor line (cursor = 0).
- Hybrid: cursor line shows its absolute number, others relative
(Vim number + relativenumber).
`LineNumberMode` gains `Relative`/`Hybrid` + `number_for(line, cursor_line)`
(the per-line displayed value) and `is_on()`. `paint_line_number_gutter`
now derives each number from the mode and the cursor's buffer line
(`text_view.line_at_offset(cursor)`); the TUI re-renders the whole frame on
cursor motion, so relative numbers track the cursor for free. Gutter width
is sized by `digits(line_count)` for every on-mode, so the text never
jitters as the cursor moves.
Mode selection (chosen over a 4-way cycle): `window.toggle-line-numbers`
stays a binary off/absolute toggle; a new `window.set-line-numbers` opens
the minibuffer with an arrow-navigable completion dropdown
(off|absolute|relative|hybrid) to pick a mode directly. `set_line_numbers`
accepts all four; the getter returns them.
No protocol change here — the GPU half (which needs the mode over the wire,
protocol v14) follows. Test: number_for across all modes. fmt + clippy
clean both flavors; 1446 lib tests pass. Needs a TUI eyeball.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Introduce a reserved left gutter column with absolute line numbers in the
TUI/grid frontend — the foundational piece of the UX arc (docs/ux-arc-
framing.md). Default OFF (Emacs tradition), so zero layout/coordinate
change until a window opts in.
- window.rs: LineNumberMode { Off, Absolute } + per-window `line_numbers`
field + `gutter_width()` (digits(line_count) + PAD) + `decimal_digits`.
- editor.rs: the gutter is one viewport shift at the paint site
(cell_origin.col += gutter_w, cell_size.cols -= gutter_w) — every
viewport-relative painter (text, syntax, diag underline, search) stays
gutter-agnostic. The sites that read rect.origin.col directly get a
manual +gutter_w: cursor placement, local selection, mouse hit-test
(a gutter click maps to line start, Q#UX6). paint_line_number_gutter
writes right-aligned dim digits alloc-free.
- overlay_paint.rs: remote-presence cursor/selection shift by gutter_w.
- Lua: pmacs.window.set_line_numbers/line_numbers +
window.toggle-line-numbers command.
No protocol/daemon change (frontend-local, Q#UX1). Tests: gutter render
(right-aligned digits + past-EOF blanks) + decimal_digits.
Validated: fmt clean; clippy --lib clean both flavors; 1439 lib tests pass
both flavors. Needs a human eyeball (coordinate-math change) before the PR.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Review follow-up on the F-016 split. install_diag / install_project_index
/ install_mcp were `pub fn` reachable at crate::lua_bindings::install_* be-
fore the split, but moving them into private child modules dropped those
paths without a re-export — shrinking the public API, which the split is
supposed to preserve. (They take crate-internal handle types so no external
caller can invoke them, and none does, so nothing actually broke — but the
paths should still resolve.)
Re-export all three alongside the factories/handles already re-exported,
restoring the paths for the two already-merged tranches (diag, index) too.
Deliberately narrowing these to pub(crate) is left as a separate change.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Third tranche of the F-016 split. Extract the pmacs.mcp surface (MCP client
bindings) from src/lua_bindings/mod.rs into src/lua_bindings/mcp.rs,
verbatim.
Corrected model (see framing): a helper-hoist is NOT a prerequisite for
most domains. The contamination that stopped parse/theme bites only when a
shared helper is *defined inside* the range being extracted. A domain that
merely *uses* a cross-section helper reaches it via `super::`
(parent-private access). So mcp extracts cleanly: all its items are
self-contained, and it reaches the JSON converters (still in the lsp
section) via super::json_to_lua / lua_to_json, and SharedProcessSupervisor
via super::. The JSON-helper hoist is deferred to the tranche that
extracts lsp itself (where they're defined).
mod.rs declares `mod mcp;` and re-exports make_mcp_manager (external caller
editor.rs) and McpServerIdLua — the latter to preserve its public-API path
crate::lua_bindings::McpServerIdLua (moving it into a private module had
dropped it from the crate surface; the split must not shrink the public
API).
Pure code motion, no behavior change. mod.rs: 14603 → 14020 lines.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
Second tranche of the F-016 split. Extract the pmacs.index surface (the
project symbol-index bindings) from src/lua_bindings/mod.rs into
src/lua_bindings/index.rs, moved verbatim.
index is the one genuinely clean remaining leaf: its private helpers
(symbol_kind_from_lua, lua_symbol_from_table, search_hit_to_lua) are used
only within its own range, and it has zero shared-core coupling — it
depends only on crate::project_index, mlua, and std, reaching one stranded
helper (lua_to_json, still in the lsp section) via `super::`.
mod.rs declares `mod index;` and re-exports `SharedProjectIndexer` +
`make_project_indexer` via `pub use`, so the crate::lua_bindings::… paths
in editor.rs and completion_framework.rs (and an in-file completion-
framework use) stay valid — no external file changes.
Pure code motion, no behavior change. mod.rs: 14986 → 14603 lines.
While vetting the next leaves I found the recon under-counted the
misplaced shared helpers: parse/theme, window, and minibuffer trail off
into shared style/color, caller_source, and command/menu helpers, so a
dedicated helper-hoist tranche must precede them (framing tranche plan
updated). This tranche stops at index rather than force a contaminated
extraction.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under both luajit and lua54.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U
First tranche of the F-016 split of the 15k-line src/lua_bindings.rs.
Deliberately minimal — it validates the mechanics before bulk moves.
- Convert src/lua_bindings.rs → src/lua_bindings/mod.rs (the
`pub mod lua_bindings;` in lib.rs resolves to mod.rs unchanged).
- Extract the pmacs.diag surface (diagnostic_to_lua + install_diag) into
src/lua_bindings/diag.rs, moved verbatim. mod.rs declares `mod diag;`
and its one internal call site is now `diag::install_diag(...)`.
Pure code motion: no logic, signature, or behavior change. diag.rs reaches
shared-core items (BufferIdLua, SharedCore) via `super::` — a child module
can see its ancestors' private items, so no visibility widening was
needed; install_diag's only caller is mod.rs itself, so no re-export
either. The Lua-visible pmacs.diag.* API is byte-for-byte unchanged.
mod.rs: 15202 → 14986 lines. Framing + tranche plan:
docs/lua-bindings-split-framing.md.
Validated: fmt clean; clippy --lib clean under both Lua flavors; full lib
suite 1437 passed / 0 failed under luajit AND lua54 (the tests drive
pmacs.diag.* through the Lua VM — same outcomes, code relocated).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TXbAwk27agwhrNNrhLi2U