feat(edit): kill ring + yank-pop on a per-frontend command-boundary substrate
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>
This commit is contained in:
parent
2383b2e3ba
commit
04314ce132
|
|
@ -200,21 +200,19 @@ cmd { name = "region.cancel",
|
|||
-- bracketed paste also refreshes. The default bindings are the Emacs
|
||||
-- kill/yank set (M-w / C-w / C-y, C-x h), which were all free.
|
||||
|
||||
-- The cut/copy/paste trio delegates to the kill ring (Arc 2): kills
|
||||
-- accumulate, C-y yanks the head, M-y cycles older entries. Resolution
|
||||
-- happens at invoke time, so chunk load order doesn't matter; the
|
||||
-- context menu invokes these by name and inherits the ring for free.
|
||||
cmd { name = "edit.copy",
|
||||
description = "Copy the active region to the clipboard.",
|
||||
fn = function()
|
||||
if not ed.clipboard_copy() then ed.set_status("no region") end
|
||||
end }
|
||||
description = "Save the active region to the kill ring (and OS clipboard).",
|
||||
fn = function() pmacs.killring.copy() end }
|
||||
cmd { name = "edit.cut",
|
||||
description = "Cut the active region to the clipboard.",
|
||||
fn = function()
|
||||
if not ed.clipboard_cut() then ed.set_status("no region") end
|
||||
end }
|
||||
description = "Kill the active region into the kill ring (and OS clipboard).",
|
||||
fn = function() pmacs.killring.cut() end }
|
||||
cmd { name = "edit.paste",
|
||||
description = "Paste the clipboard at the cursor, replacing any region.",
|
||||
fn = function()
|
||||
if not ed.clipboard_paste() then ed.set_status("clipboard empty") end
|
||||
end }
|
||||
description = "Yank the most recent kill at the cursor, replacing any region.",
|
||||
fn = function() pmacs.killring.yank() end }
|
||||
cmd { name = "edit.select-all",
|
||||
description = "Select the whole buffer.",
|
||||
fn = function() ed.select_all() end }
|
||||
|
|
@ -628,7 +626,11 @@ cmd { name = "editor.execute-command",
|
|||
history = "command",
|
||||
on_accept = function(name)
|
||||
if name == nil or name == "" then return end
|
||||
local ok, err = pcall(pmacs.command.invoke, name)
|
||||
-- invoke_interactive records the command boundary (kill
|
||||
-- ring Q#KR2), so chain-sensitive commands behave as under
|
||||
-- Emacs's execute-extended-command: M-x kill-line then C-k
|
||||
-- appends; C-k then M-x kill-line does not.
|
||||
local ok, err = pcall(pmacs.command.invoke_interactive, name)
|
||||
if not ok then
|
||||
-- mlua's `tostring(err)` includes a Lua stack traceback
|
||||
-- separated by newlines. The status line is one row;
|
||||
|
|
|
|||
|
|
@ -67,6 +67,15 @@ define {
|
|||
kind = "short-circuit",
|
||||
}
|
||||
|
||||
define {
|
||||
name = "frontend.detached",
|
||||
description = "Fired when an attached frontend's session ends. " ..
|
||||
"Receives the raw frontend id (integer). Modules keying " ..
|
||||
"state by pmacs.frontend.id() (kill-ring sessions) drop " ..
|
||||
"that id's entries here (Q#KR11).",
|
||||
kind = "all-must-succeed",
|
||||
}
|
||||
|
||||
define {
|
||||
name = "process.after-tick",
|
||||
description = "Fired once per editor frame, immediately after the process " ..
|
||||
|
|
|
|||
|
|
@ -0,0 +1,317 @@
|
|||
-- killring.lua --- the Emacs kill ring (Arc 2, kill-ring framing).
|
||||
--
|
||||
-- Kills accumulate here instead of overwriting the one clipboard slot:
|
||||
-- consecutive kills append into one entry, `C-y` yanks the head, `M-y`
|
||||
-- right after a yank cycles older entries. The ring is daemon-global
|
||||
-- (shared across attached frontends, like the Emacs daemon); kill
|
||||
-- chains and yank sessions are per-frontend, keyed by
|
||||
-- `pmacs.frontend.id()` and checked against stable ring-entry ids so
|
||||
-- one frontend's activity can never corrupt another's (Q#KR4/6/7).
|
||||
--
|
||||
-- The Rust substrate this rides on (Q#KR2): every input path either
|
||||
-- rotates the per-frontend command boundary (commands) or breaks it
|
||||
-- (optimistic CRDT edits, pointer gestures, pastes, unbound keys), so
|
||||
-- `pmacs.editor.last_command()` is trustworthy on both frontends.
|
||||
--
|
||||
-- OS clipboard: the ring head is mirrored to the *acting frontend's*
|
||||
-- OS clipboard on every kill/append (`ed.clipboard_set`); external
|
||||
-- content joins the ring at yank time via the slot check (an OS copy
|
||||
-- only reaches the daemon when pasted). `M-y` never touches the slot.
|
||||
--
|
||||
-- Framing: docs/kill-ring-framing.md.
|
||||
|
||||
pmacs.killring = pmacs.killring or {}
|
||||
|
||||
local ed = pmacs.editor
|
||||
|
||||
local DEFAULT_MAX = 60
|
||||
|
||||
local ring = {} -- array of { id, text }, most-recent first (shared)
|
||||
local next_id = 1
|
||||
local max_entries = DEFAULT_MAX
|
||||
|
||||
-- Per-frontend state (Q#KR4/KR6). Keyed by pmacs.frontend.id().
|
||||
local last_kill_id = {} -- fid -> ring-entry id of that frontend's last kill
|
||||
local sessions = {} -- fid -> { buffer, start, stop, entry_id, text }
|
||||
|
||||
-- Commands whose success may extend a kill chain (Q#KR4).
|
||||
local KILL_CHAIN = { ["edit.kill-line"] = true, ["edit.cut"] = true }
|
||||
|
||||
local function trim()
|
||||
while #ring > max_entries do
|
||||
table.remove(ring)
|
||||
end
|
||||
end
|
||||
|
||||
-- max([n]) --- getter when nil; validated setter otherwise. Rejects
|
||||
-- non-numbers, NaN, and non-finite values (math.huge would defeat the
|
||||
-- cap); floors; lowering the cap trims existing entries immediately.
|
||||
function pmacs.killring.max(n)
|
||||
if n == nil then return max_entries end
|
||||
if type(n) ~= "number" or n ~= n or n == math.huge or n < 1 then
|
||||
error("pmacs.killring.max: expected a finite number >= 1")
|
||||
end
|
||||
max_entries = math.floor(n)
|
||||
trim()
|
||||
return max_entries
|
||||
end
|
||||
|
||||
-- The ring's texts, most-recent first (introspection / tests).
|
||||
function pmacs.killring.list()
|
||||
local out = {}
|
||||
for i, e in ipairs(ring) do out[i] = e.text end
|
||||
return out
|
||||
end
|
||||
|
||||
-- Test/debug seam (Q#KR11 lifecycle assertions).
|
||||
function pmacs.killring._debug_state(fid)
|
||||
return { session = sessions[fid], last_kill_id = last_kill_id[fid] }
|
||||
end
|
||||
|
||||
-- Push `text` as a fresh entry (duplicate-of-head collapses, keeping
|
||||
-- the existing id). Returns the head entry.
|
||||
local function push_entry(text)
|
||||
if ring[1] and ring[1].text == text then return ring[1] end
|
||||
table.insert(ring, 1, { id = next_id, text = text })
|
||||
next_id = next_id + 1
|
||||
trim()
|
||||
return ring[1]
|
||||
end
|
||||
|
||||
-- A kill-family command failed or was a no-op: it must not leave a
|
||||
-- live chain for the next kill to append to (Q#KR4).
|
||||
local function fail_kill(fid)
|
||||
last_kill_id[fid] = nil
|
||||
end
|
||||
|
||||
-- Chain-aware kill (Q#KR4): append to the head iff the previous
|
||||
-- command was a chain kill AND this frontend's last kill IS the
|
||||
-- current head (another frontend's push in between means the head is
|
||||
-- not ours — append would corrupt their entry). Mirrors the head to
|
||||
-- the acting frontend's OS clipboard either way.
|
||||
local function kill_push(fid, text)
|
||||
local chained = KILL_CHAIN[ed.last_command() or ""]
|
||||
and last_kill_id[fid] ~= nil
|
||||
and ring[1] ~= nil
|
||||
and ring[1].id == last_kill_id[fid]
|
||||
local head
|
||||
if chained then
|
||||
ring[1].text = ring[1].text .. text
|
||||
head = ring[1]
|
||||
else
|
||||
head = push_entry(text)
|
||||
end
|
||||
last_kill_id[fid] = head.id
|
||||
ed.clipboard_set(head.text)
|
||||
return head
|
||||
end
|
||||
|
||||
-- edit.cut body (C-w): kill the active region into the ring.
|
||||
function pmacs.killring.cut()
|
||||
local fid = pmacs.frontend.id()
|
||||
local region = ed.region()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not region or not buf then
|
||||
fail_kill(fid)
|
||||
ed.set_status("no region")
|
||||
return false
|
||||
end
|
||||
local text = buf:slice(region.start, region["end"])
|
||||
if not ed.delete_region() then
|
||||
fail_kill(fid)
|
||||
ed.set_status("no region")
|
||||
return false
|
||||
end
|
||||
kill_push(fid, text)
|
||||
return true
|
||||
end
|
||||
|
||||
-- edit.copy body (M-w): save the region to the ring without deleting.
|
||||
-- Not a chain command (Q#KR4's family is kill-line + cut): a copy
|
||||
-- pushes fresh (duplicate-of-head collapses) and neither extends nor
|
||||
-- starts an append chain.
|
||||
function pmacs.killring.copy()
|
||||
local fid = pmacs.frontend.id()
|
||||
local region = ed.region()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not region or not buf then
|
||||
fail_kill(fid)
|
||||
ed.set_status("no region")
|
||||
return false
|
||||
end
|
||||
local text = buf:slice(region.start, region["end"])
|
||||
push_entry(text)
|
||||
ed.clipboard_set(text)
|
||||
fail_kill(fid) -- a copy is not an appendable kill
|
||||
return true
|
||||
end
|
||||
|
||||
-- edit.kill-line body (C-k): kill from the cursor to end of line; at
|
||||
-- the newline itself, kill the newline (Emacs kill-line with
|
||||
-- kill-whole-line nil). Consecutive C-k's append (Q#KR4), so
|
||||
-- C-k C-k C-k builds one multi-line entry.
|
||||
function pmacs.killring.kill_line()
|
||||
local fid = pmacs.frontend.id()
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf then
|
||||
fail_kill(fid)
|
||||
return false
|
||||
end
|
||||
local cursor = ed.cursor()
|
||||
local len = buf:len()
|
||||
if cursor >= len then
|
||||
fail_kill(fid)
|
||||
ed.set_status("end of buffer")
|
||||
return false
|
||||
end
|
||||
-- Find the next newline by chunked scan (lines are almost always
|
||||
-- shorter than one chunk; a chunk loop keeps giant lines safe).
|
||||
local eol = nil
|
||||
local p = cursor
|
||||
while p < len do
|
||||
local chunk_to = math.min(p + 4096, len)
|
||||
local chunk = buf:slice(p, chunk_to)
|
||||
local nl = chunk:find("\n", 1, true)
|
||||
if nl then
|
||||
eol = p + nl - 1
|
||||
break
|
||||
end
|
||||
p = chunk_to
|
||||
end
|
||||
local kill_to
|
||||
if eol == cursor then
|
||||
kill_to = cursor + 1 -- at the newline: kill the newline itself
|
||||
else
|
||||
kill_to = eol or len -- rest of the line (or of a final bare line)
|
||||
end
|
||||
local text = buf:slice(cursor, kill_to)
|
||||
buf:delete(cursor, kill_to)
|
||||
kill_push(fid, text)
|
||||
return true
|
||||
end
|
||||
|
||||
-- Drop a frontend's yank session (invalid M-y must not leave state a
|
||||
-- second M-y could ride, Q#KR7).
|
||||
local function drop_session(fid, msg)
|
||||
sessions[fid] = nil
|
||||
if msg then ed.set_status(msg) end
|
||||
end
|
||||
|
||||
-- edit.paste body (C-y): yank the ring head (Q#KR6).
|
||||
function pmacs.killring.yank()
|
||||
local fid = pmacs.frontend.id()
|
||||
-- Slot check: content that arrived via an OS paste (paste_inbound
|
||||
-- refreshes the slot) joins the ring the first time it is yanked.
|
||||
local slot = ed.clipboard_get()
|
||||
if slot and slot ~= "" and (not ring[1] or ring[1].text ~= slot) then
|
||||
push_entry(slot)
|
||||
end
|
||||
local head = ring[1]
|
||||
if not head then
|
||||
drop_session(fid, "kill ring empty")
|
||||
return false
|
||||
end
|
||||
local region = ed.region()
|
||||
local start = region and region.start or ed.cursor()
|
||||
if not slot or slot ~= head.text then
|
||||
ed.clipboard_set(head.text)
|
||||
end
|
||||
local ok = ed.clipboard_paste()
|
||||
if not ok then
|
||||
drop_session(fid) -- failed paste creates no session (Q#KR6)
|
||||
return false
|
||||
end
|
||||
local buf = pmacs.window.buffer()
|
||||
sessions[fid] = {
|
||||
buffer = buf and tostring(buf) or "",
|
||||
start = start,
|
||||
stop = ed.cursor(),
|
||||
entry_id = head.id,
|
||||
text = head.text,
|
||||
}
|
||||
return true
|
||||
end
|
||||
|
||||
-- edit.yank-pop body (M-y): replace the just-yanked text with the
|
||||
-- next-older ring entry (Q#KR7). Valid only immediately after a yank
|
||||
-- or another pop, with a live, still-verifiable session.
|
||||
function pmacs.killring.yank_pop()
|
||||
local fid = pmacs.frontend.id()
|
||||
local lc = ed.last_command()
|
||||
local s = sessions[fid]
|
||||
if not (lc == "edit.paste" or lc == "edit.yank-pop") or not s then
|
||||
drop_session(fid, "previous command was not a yank")
|
||||
return false
|
||||
end
|
||||
local buf = pmacs.window.buffer()
|
||||
if not buf or tostring(buf) ~= s.buffer then
|
||||
drop_session(fid, "yank was in another buffer")
|
||||
return false
|
||||
end
|
||||
-- Invalidation guard: the remembered range must still hold exactly
|
||||
-- the text this session yanked. A concurrent edit (another
|
||||
-- frontend, a hook) that moved or altered it fails here — refuse
|
||||
-- rather than splice garbage. The slice is pcall'd: an upstream
|
||||
-- deletion can shrink the buffer below `stop`, and an out-of-bounds
|
||||
-- range must read as "changed", not throw.
|
||||
local ok, current = pcall(function() return buf:slice(s.start, s.stop) end)
|
||||
if not ok or current ~= s.text then
|
||||
drop_session(fid, "buffer changed since the yank")
|
||||
return false
|
||||
end
|
||||
-- Stable-id rotation: find where this session's entry sits NOW
|
||||
-- (other frontends' pushes shift positions, not ids) and step to
|
||||
-- the next older, wrapping. An evicted id invalidates.
|
||||
local pos = nil
|
||||
for i, e in ipairs(ring) do
|
||||
if e.id == s.entry_id then
|
||||
pos = i
|
||||
break
|
||||
end
|
||||
end
|
||||
if not pos then
|
||||
drop_session(fid, "kill ring entry expired")
|
||||
return false
|
||||
end
|
||||
local entry = ring[pos % #ring + 1]
|
||||
buf:replace(s.start, s.stop, entry.text)
|
||||
-- Verify the applied edit: buffer intercepts may alter or reject a
|
||||
-- replace. Accepted post-hoc semantics (Q#KR7): on mismatch the
|
||||
-- interceptor's result stands, the session ends, and we say so.
|
||||
-- pcall'd for the same reason as the guard above: an intercept that
|
||||
-- shrank the buffer must invalidate, not throw.
|
||||
local vok, applied = pcall(function()
|
||||
return buf:slice(s.start, s.start + #entry.text)
|
||||
end)
|
||||
if not vok or applied ~= entry.text then
|
||||
drop_session(fid, "yank-pop altered by buffer intercept; stopped")
|
||||
return false
|
||||
end
|
||||
ed.goto_byte(s.start + #entry.text)
|
||||
s.stop = s.start + #entry.text
|
||||
s.entry_id = entry.id
|
||||
s.text = entry.text
|
||||
return true
|
||||
end
|
||||
|
||||
-- Q#KR11: a detached frontend's chain/session state must not outlive
|
||||
-- it (ids are monotonic; these tables would grow forever).
|
||||
pmacs.hook.add("frontend.detached", function(fid)
|
||||
sessions[fid] = nil
|
||||
last_kill_id[fid] = nil
|
||||
end)
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.kill-line",
|
||||
description = "Kill from the cursor to the end of the line (into the kill ring).",
|
||||
fn = function() pmacs.killring.kill_line() end,
|
||||
}
|
||||
|
||||
pmacs.command.define {
|
||||
name = "edit.yank-pop",
|
||||
description = "Replace the just-yanked text with the previous kill (after C-y).",
|
||||
fn = function() pmacs.killring.yank_pop() end,
|
||||
}
|
||||
|
||||
pmacs.keymap.bind { scope = "global", sequence = "C-k", command = "edit.kill-line" }
|
||||
pmacs.keymap.bind { scope = "global", sequence = "M-y", command = "edit.yank-pop" }
|
||||
288
src/daemon.rs
288
src/daemon.rs
|
|
@ -1540,6 +1540,30 @@ fn handle_dispatcher_event(
|
|||
editor.dispatch_menu_pointer(source, index, invoke);
|
||||
}
|
||||
}
|
||||
FrontendEvent::Paste {
|
||||
frontend_id: claimed_fid,
|
||||
data,
|
||||
} => {
|
||||
// Kill ring Q#KR10a — the unified paste route, for
|
||||
// BOTH attachment kinds. Handled here (not in
|
||||
// `apply_event`) for two reasons:
|
||||
//
|
||||
// 1. The semantic input dispatcher used to drop
|
||||
// `Paste` entirely, so GPU Ctrl-V was a no-op
|
||||
// (pmacs-gpu always negotiates semantic render).
|
||||
// 2. The authenticated `source` is in scope. The
|
||||
// event's `claimed_fid` is client-supplied and
|
||||
// not trusted (the CrdtOp / Viewport / Pointer
|
||||
// source-trust rule); the old grid arm set
|
||||
// `active_frontend` from it, letting a forged id
|
||||
// paste into another frontend's active window.
|
||||
//
|
||||
// The paste is a non-command edit, so it breaks the
|
||||
// source's command chain (Q#KR2), and it fires
|
||||
// `buffer.after-edit` like any other edit (Q#KR10b)
|
||||
// — previously it never did, so LSP missed pastes.
|
||||
handle_inbound_paste(editor, source, claimed_fid, &data);
|
||||
}
|
||||
_ => {
|
||||
let term_size = *term_sizes
|
||||
.get(&source)
|
||||
|
|
@ -1579,10 +1603,21 @@ fn handle_dispatcher_event(
|
|||
last_dispatch_idle_sent.remove(&frontend_id);
|
||||
last_active_buffer_sent.remove(&frontend_id);
|
||||
session_registry.unregister_session(frontend_id);
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.unregister_frontend_view(frontend_id);
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.unregister_frontend_view(frontend_id);
|
||||
// Kill ring Q#KR11: frontend ids are monotonic, so
|
||||
// per-frontend state must not outlive the session.
|
||||
core.command_history.remove(&frontend_id);
|
||||
}
|
||||
// Q#KR11: let Lua modules holding per-frontend tables
|
||||
// (killring sessions / kill flags) drop this id's entries.
|
||||
// The first frontend-lifecycle hook; carries the raw id.
|
||||
let mut args = mlua::MultiValue::new();
|
||||
args.push_back(mlua::Value::Integer(
|
||||
i64::try_from(frontend_id.0).unwrap_or(i64::MAX),
|
||||
));
|
||||
editor.lua_host.run_hook("frontend.detached", args);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1914,6 +1949,35 @@ fn validate_remote_crdt_op(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
/// The unified inbound-paste route (kill ring Q#KR10a) — one handler
|
||||
/// for grid *and* semantic sessions, keyed by the dispatcher's
|
||||
/// authenticated `source`. `claimed` is the event payload's
|
||||
/// client-supplied id: never trusted (a forged id must not paste into
|
||||
/// another frontend's active window), only logged on mismatch. The
|
||||
/// paste breaks the source's command chain (a non-command edit, Q#KR2)
|
||||
/// and fires `buffer.after-edit` when the buffer changed (Q#KR10b).
|
||||
fn handle_inbound_paste(
|
||||
editor: &mut EditorState,
|
||||
source: FrontendId,
|
||||
claimed: FrontendId,
|
||||
data: &[u8],
|
||||
) {
|
||||
if claimed != source {
|
||||
eprintln!(
|
||||
"pmacs daemon: Paste claimed {claimed:?} but came \
|
||||
from {source:?}; using the authenticated source"
|
||||
);
|
||||
}
|
||||
editor.core.borrow_mut().active_frontend = source;
|
||||
editor.with_after_edit_check(|state| {
|
||||
let mut core = state.core.borrow_mut();
|
||||
core.break_command_chain(source);
|
||||
if let Err(e) = core.paste_inbound(data) {
|
||||
eprintln!("pmacs: inbound paste failed: {e}");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// T M10.10 (post-audit) — apply a *pre-validated*
|
||||
/// `FrontendEvent::CrdtOp`. Identity, capability, and scope checks
|
||||
/// happen upstream in `validate_remote_crdt_op`; this function trusts
|
||||
|
|
@ -1944,6 +2008,12 @@ fn handle_remote_crdt_op(
|
|||
buffer_id: crate::buffer::BufferId,
|
||||
op: crate::rope::CrdtOp,
|
||||
) {
|
||||
// Kill ring Q#KR2: an optimistic edit is non-command input — GPU
|
||||
// typing, Enter/Tab, Backspace/Delete all arrive here without ever
|
||||
// touching dispatch_key. It must break the source frontend's
|
||||
// command chain, or `C-k x C-k` on the GPU would append across the
|
||||
// typed character.
|
||||
editor.core.borrow_mut().break_command_chain(source);
|
||||
// Effect 1: apply to buffer's CRDT + rope. Capture the Edit
|
||||
// (or `None` for an op that imported cleanly but produced no
|
||||
// text delta — F17).
|
||||
|
|
@ -2193,9 +2263,10 @@ fn build_presence_snapshot(editor: &EditorState, frontend_id: FrontendId) -> Pre
|
|||
/// arms of [`apply_event`] but takes no `RenderState` — a semantic
|
||||
/// frontend lays out locally, so the only state these events touch is
|
||||
/// the editor core (cursor, buffer, commands), which `dispatch_key` /
|
||||
/// `dispatch_mouse` operate on directly. `Resize` / `Paste` / `Focus`
|
||||
/// have no grid-less effect yet and are dropped; `Viewport` / `CrdtOp`
|
||||
/// are handled in their own dispatcher arms and never reach here.
|
||||
/// `dispatch_mouse` operate on directly. `Resize` / `Focus` have no
|
||||
/// grid-less effect yet and are dropped; `Viewport` / `CrdtOp` /
|
||||
/// `Paste` (Q#KR10a) are handled in their own dispatcher arms and
|
||||
/// never reach here.
|
||||
#[allow(clippy::needless_pass_by_value)] // consumes the event, mirroring `apply_event`.
|
||||
fn apply_semantic_input_event(editor: &mut EditorState, ev: FrontendEvent, term_size: CellSize) {
|
||||
match ev {
|
||||
|
|
@ -2238,20 +2309,13 @@ fn apply_event(
|
|||
render_state.resize(size);
|
||||
*term_size = size;
|
||||
}
|
||||
FrontendEvent::Paste { frontend_id, data } => {
|
||||
// Q#CM6 — inbound OS paste (terminal bracketed paste, or
|
||||
// GPU Ctrl-V reading `arboard`). Insert at the cursor of the
|
||||
// originating frontend's active window, replacing any region,
|
||||
// and refresh the clipboard slot so a later in-app paste
|
||||
// repeats the same text. (Previously dropped: pmacs had
|
||||
// never honored a paste.)
|
||||
let mut core = editor.core.borrow_mut();
|
||||
core.active_frontend = frontend_id;
|
||||
if let Err(e) = core.paste_inbound(&data) {
|
||||
eprintln!("pmacs: inbound paste failed: {e}");
|
||||
}
|
||||
}
|
||||
FrontendEvent::FocusGained(_)
|
||||
// Q#KR10a — Paste is handled in the dispatcher's own
|
||||
// `FrontendEvent::Paste` arm (unified for grid and semantic
|
||||
// sessions, keyed by the authenticated source), and never
|
||||
// reaches here. Listed explicitly so a future reshuffle can't
|
||||
// silently re-route it through this payload-trusting path.
|
||||
FrontendEvent::Paste { .. }
|
||||
| FrontendEvent::FocusGained(_)
|
||||
| FrontendEvent::FocusLost(_)
|
||||
// T M11.1: the semantic-frontend viewport declaration. Its
|
||||
// consumer is the instance-side projection seam
|
||||
|
|
@ -2447,6 +2511,188 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// Kill ring Q#KR2 — an optimistic edit is non-command input: GPU
|
||||
/// typing arrives here without touching dispatch_key, so it must
|
||||
/// break the source frontend's command chain or `C-k x C-k` on the
|
||||
/// GPU would append across the typed character.
|
||||
#[cfg(feature = "crdt")]
|
||||
#[test]
|
||||
fn handle_remote_crdt_op_breaks_the_source_command_chain() {
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::FrontendId;
|
||||
|
||||
let mut editor = EditorState::new();
|
||||
let source = FrontendId(7);
|
||||
// A live kill chain for the source frontend...
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.rotate_command(source, "edit.kill-line");
|
||||
// ...and one for a bystander that must survive.
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.rotate_command(FrontendId::LOCAL, "edit.kill-line");
|
||||
|
||||
let buffer_id = editor.core.borrow().active_window().buffer_id;
|
||||
{
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.get_mut(buffer_id)
|
||||
.expect("active buffer")
|
||||
.upgrade_to_crdt(2)
|
||||
.expect("upgrade to crdt");
|
||||
}
|
||||
let snapshot_bytes = {
|
||||
let core = editor.core.borrow();
|
||||
let reg = core.registry.borrow();
|
||||
reg.get(buffer_id)
|
||||
.expect("buffer")
|
||||
.crdt_state()
|
||||
.expect("crdt-backed")
|
||||
.export_snapshot()
|
||||
.expect("export snapshot")
|
||||
};
|
||||
let peer = loro::LoroDoc::new();
|
||||
peer.set_peer_id(7).expect("set peer id");
|
||||
peer.import(&snapshot_bytes).expect("import snapshot");
|
||||
let v_before = peer.oplog_vv();
|
||||
peer.get_text("body").insert(0, "x").expect("peer insert");
|
||||
let op_bytes = peer
|
||||
.export(loro::ExportMode::updates(&v_before))
|
||||
.expect("export op");
|
||||
|
||||
handle_remote_crdt_op(
|
||||
&mut editor,
|
||||
source,
|
||||
buffer_id,
|
||||
crate::rope::CrdtOp {
|
||||
peer_id: 7,
|
||||
bytes: op_bytes,
|
||||
},
|
||||
);
|
||||
|
||||
let core = editor.core.borrow();
|
||||
assert!(
|
||||
core.command_history
|
||||
.get(&source)
|
||||
.is_none_or(|b| b.this.is_none()),
|
||||
"the optimistic edit must break the source's chain"
|
||||
);
|
||||
assert_eq!(
|
||||
core.command_history
|
||||
.get(&FrontendId::LOCAL)
|
||||
.and_then(|b| b.this.as_deref()),
|
||||
Some("edit.kill-line"),
|
||||
"a bystander frontend's chain is untouched"
|
||||
);
|
||||
}
|
||||
|
||||
/// Kill ring Q#KR10a — the unified paste route trusts only the
|
||||
/// dispatcher's authenticated source. A forged payload id must not
|
||||
/// paste into another frontend's active window, and the paste
|
||||
/// breaks the SOURCE's chain (not the claimed frontend's) and
|
||||
/// fires `buffer.after-edit` exactly once.
|
||||
#[test]
|
||||
fn inbound_paste_uses_authenticated_source_not_the_claimed_id() {
|
||||
use crate::editor::EditorState;
|
||||
use crate::protocol::FrontendId;
|
||||
use crate::text_view::TextView;
|
||||
use crate::window::{FrontendView, Layout, Window, WindowId};
|
||||
|
||||
let mut editor = EditorState::new();
|
||||
editor
|
||||
.lua_host
|
||||
.eval(
|
||||
Some("test"),
|
||||
r#"
|
||||
_G.PASTE_AFTER_EDIT = 0
|
||||
pmacs.hook.add("buffer.after-edit", function()
|
||||
_G.PASTE_AFTER_EDIT = _G.PASTE_AFTER_EDIT + 1
|
||||
end)
|
||||
"#,
|
||||
)
|
||||
.expect("install after-edit hook");
|
||||
|
||||
// Give the attacker frontend its OWN view onto its own buffer,
|
||||
// so "which window did the text land in" is observable.
|
||||
let source = FrontendId(7);
|
||||
let victim = FrontendId::LOCAL;
|
||||
let attacker_buf = {
|
||||
let core = editor.core.borrow();
|
||||
let mut reg = core.registry.borrow_mut();
|
||||
reg.create("attacker-buffer")
|
||||
};
|
||||
{
|
||||
let mut core = editor.core.borrow_mut();
|
||||
let tv = {
|
||||
let reg = core.registry.borrow();
|
||||
TextView::new(reg.get(attacker_buf).expect("attacker buffer"))
|
||||
};
|
||||
let wid = WindowId::next();
|
||||
core.windows.insert(wid, Window::new(wid, attacker_buf, tv));
|
||||
core.register_frontend_view(
|
||||
source,
|
||||
FrontendView {
|
||||
layout: Layout::single(wid),
|
||||
active: wid,
|
||||
},
|
||||
);
|
||||
}
|
||||
let victim_buf = editor.core.borrow().active_window().buffer_id;
|
||||
// Seed a live chain on the victim: the forged paste must not
|
||||
// break it (only the authenticated source's chain breaks).
|
||||
editor
|
||||
.core
|
||||
.borrow_mut()
|
||||
.rotate_command(victim, "edit.kill-line");
|
||||
|
||||
// The payload CLAIMS to be the victim.
|
||||
handle_inbound_paste(&mut editor, source, victim, b"FORGED");
|
||||
|
||||
let core = editor.core.borrow();
|
||||
let text_of = |id| {
|
||||
let reg = core.registry.borrow();
|
||||
let buf = reg.get(id).expect("buffer");
|
||||
let len = buf.len();
|
||||
let mut out = vec![0u8; usize::try_from(len).unwrap_or(0)];
|
||||
if len > 0 {
|
||||
buf.snapshot_rope().slice(0, len, &mut out);
|
||||
}
|
||||
String::from_utf8_lossy(&out).into_owned()
|
||||
};
|
||||
assert!(
|
||||
text_of(attacker_buf).contains("FORGED"),
|
||||
"the paste lands in the AUTHENTICATED source's active window"
|
||||
);
|
||||
assert!(
|
||||
!text_of(victim_buf).contains("FORGED"),
|
||||
"a forged payload id must not paste into the claimed frontend's window"
|
||||
);
|
||||
assert!(
|
||||
core.command_history
|
||||
.get(&source)
|
||||
.is_none_or(|b| b.this.is_none()),
|
||||
"the paste breaks the source's chain"
|
||||
);
|
||||
assert_eq!(
|
||||
core.command_history
|
||||
.get(&victim)
|
||||
.and_then(|b| b.this.as_deref()),
|
||||
Some("edit.kill-line"),
|
||||
"the claimed frontend's chain is untouched"
|
||||
);
|
||||
drop(core);
|
||||
let count = editor
|
||||
.lua_host
|
||||
.eval(Some("test-readback"), "return _G.PASTE_AFTER_EDIT")
|
||||
.expect("read counter");
|
||||
assert!(
|
||||
matches!(count, mlua::Value::Integer(1)),
|
||||
"paste fires buffer.after-edit exactly once, got {count:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// v15 regression: an optimistic-path edit (the bulk of plain-char
|
||||
/// typing from a semantic frontend) must clear the transient
|
||||
/// status message, exactly as `dispatch_key`'s entry clear does
|
||||
|
|
|
|||
117
src/editor.rs
117
src/editor.rs
|
|
@ -327,6 +327,12 @@ impl EditorState {
|
|||
include_str!("../builtin/runtime/autosave.lua"),
|
||||
)
|
||||
.expect("load autosave builtin chunk");
|
||||
lua_host
|
||||
.eval(
|
||||
Some("@pmacs/builtin/runtime/killring.lua"),
|
||||
include_str!("../builtin/runtime/killring.lua"),
|
||||
)
|
||||
.expect("load killring builtin chunk");
|
||||
// T M7.11 bundled-package bootstrap. Through M7.10 the REPL
|
||||
// was loaded directly via `eval(include_str!(...))`; the
|
||||
// M7.11 deliverable migrates it to the package system so it
|
||||
|
|
@ -706,6 +712,10 @@ impl EditorState {
|
|||
|
||||
match action {
|
||||
Action::Run { command, .. } => {
|
||||
// Kill ring Q#KR2: record the command boundary before the
|
||||
// body runs, so the body's own `ed.last_command()` reads
|
||||
// its *predecessor* (Emacs `last-command` semantics).
|
||||
self.core.borrow_mut().rotate_command(frontend_id, &command);
|
||||
if let Err(e) = self
|
||||
.lua_host
|
||||
.invoke_command(&command, mlua::MultiValue::new())
|
||||
|
|
@ -723,20 +733,28 @@ impl EditorState {
|
|||
// shadow instead of the dispatcher.
|
||||
self.core.borrow_mut().completion_popup_close();
|
||||
}
|
||||
Action::Unbound { sequence } => match printable_char(&sequence) {
|
||||
Some(ch) => {
|
||||
Action::Unbound { sequence } => {
|
||||
if let Some(ch) = printable_char(&sequence) {
|
||||
// Typing a character is a command too (Q#KR2): it
|
||||
// must break a kill chain — `C-k x C-k` is two ring
|
||||
// entries, not an append.
|
||||
self.core
|
||||
.borrow_mut()
|
||||
.rotate_command(frontend_id, "buffer.self-insert");
|
||||
let mut args = mlua::MultiValue::new();
|
||||
args.push_back(mlua::Value::Integer(ch as i64));
|
||||
if let Err(e) = self.lua_host.invoke_command("buffer.self-insert", args) {
|
||||
self.core.borrow_mut().status =
|
||||
format!("self-insert failed: {}", first_line(&e.to_string()));
|
||||
}
|
||||
}
|
||||
None => {
|
||||
} else {
|
||||
// An unbound key still breaks the chain (Q#KR2) —
|
||||
// Emacs's `undefined` runs as a command.
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
self.core.borrow_mut().status =
|
||||
format!("{}: not bound", display_sequence(&sequence));
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let post_revision = self.active_buffer_revision();
|
||||
|
|
@ -761,6 +779,32 @@ impl EditorState {
|
|||
self.buffer_revision(id)
|
||||
}
|
||||
|
||||
/// Run `f`, then fire `buffer.after-edit` if the active buffer's
|
||||
/// revision changed — the same compare `dispatch_key` performs
|
||||
/// after a keybound command (kill ring Q#KR10b).
|
||||
///
|
||||
/// For call sites that execute edits *outside* `dispatch_key`'s
|
||||
/// post-command check: the minibuffer accept callback (`M-x`), the
|
||||
/// menu invoke, and the unified paste route. Without it, those
|
||||
/// edits are invisible to LSP `didChange`, the syntax reparse, and
|
||||
/// autosave's observers.
|
||||
///
|
||||
/// Scope, honestly: the *active-buffer* before/after compare is
|
||||
/// sound for these paths (all edit the active buffer and stay
|
||||
/// there) but is not a general any-buffer guarantee — a callback
|
||||
/// that edits buffer A then switches to B evades it. The general
|
||||
/// fix is a buffer-aware edit epoch; deferred, named in the
|
||||
/// kill-ring framing.
|
||||
pub(crate) fn with_after_edit_check(&mut self, f: impl FnOnce(&mut Self)) {
|
||||
let pre = self.active_buffer_revision();
|
||||
f(self);
|
||||
let post = self.active_buffer_revision();
|
||||
if pre != post {
|
||||
self.lua_host
|
||||
.run_hook("buffer.after-edit", mlua::MultiValue::new());
|
||||
}
|
||||
}
|
||||
|
||||
/// Edit revision of a specific buffer, or `None` if the registry no
|
||||
/// longer knows it. Used by the query-replace shadow to compare the
|
||||
/// *edited* (origin) buffer, not whichever is active.
|
||||
|
|
@ -946,13 +990,28 @@ impl EditorState {
|
|||
fn menu_invoke_active(&mut self) {
|
||||
let command = self.core.borrow().menu_active_command();
|
||||
self.core.borrow_mut().menu_close();
|
||||
if let Some(command) = command
|
||||
&& let Err(e) = self
|
||||
.lua_host
|
||||
.invoke_command(&command, mlua::MultiValue::new())
|
||||
{
|
||||
self.core.borrow_mut().status =
|
||||
format!("error in {command}: {}", first_line(&e.to_string()));
|
||||
if let Some(command) = command {
|
||||
// A menu item is an interactive command (kill ring Q#KR2):
|
||||
// rotate the boundary so a menu Cut chains like a keybound
|
||||
// one. The invoke below bypasses dispatch_key, which would
|
||||
// otherwise leave the boundary stale.
|
||||
{
|
||||
let mut core = self.core.borrow_mut();
|
||||
let fid = core.active_frontend;
|
||||
core.rotate_command(fid, &command);
|
||||
}
|
||||
// Q#KR10b: menu invocation bypasses dispatch_key's
|
||||
// revision check — a menu Cut's edit must still fire
|
||||
// `buffer.after-edit`.
|
||||
self.with_after_edit_check(|state| {
|
||||
if let Err(e) = state
|
||||
.lua_host
|
||||
.invoke_command(&command, mlua::MultiValue::new())
|
||||
{
|
||||
state.core.borrow_mut().status =
|
||||
format!("error in {command}: {}", first_line(&e.to_string()));
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1062,12 +1121,18 @@ impl EditorState {
|
|||
.create_string(&contents)
|
||||
.expect("Lua VM out of memory while building minibuffer callback args"),
|
||||
));
|
||||
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
|
||||
self.core.borrow_mut().status = format!(
|
||||
"minibuffer on_accept failed: {}",
|
||||
first_line(&e.to_string())
|
||||
);
|
||||
}
|
||||
// Q#KR10b: the accept callback runs outside dispatch_key's
|
||||
// post-command revision check (the minibuffer interception
|
||||
// returns before it), so an M-x'd editing command would never
|
||||
// fire `buffer.after-edit` without this wrapper.
|
||||
self.with_after_edit_check(|state| {
|
||||
if let Err(e) = on_accept.call::<mlua::MultiValue>(args) {
|
||||
state.core.borrow_mut().status = format!(
|
||||
"minibuffer on_accept failed: {}",
|
||||
first_line(&e.to_string())
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn minibuffer_cancel(&mut self) {
|
||||
|
|
@ -1159,6 +1224,12 @@ impl EditorState {
|
|||
self.mouse_click = None;
|
||||
return; // Mode-line click: reserved.
|
||||
}
|
||||
// Point moves: break the command chain (kill ring
|
||||
// Q#KR2). Scroll arms below deliberately do NOT — a
|
||||
// wheel that only moves the viewport preserves a kill
|
||||
// chain, as in Emacs (`mwheel-scroll` vs
|
||||
// `mouse-set-point`).
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
let click_cell = CellCoord::new(cell_row, cell_col);
|
||||
let is_double_click = self.is_double_click(frontend_id, win_id, click_cell);
|
||||
self.activate_and_position(win_id, local_row, local_col);
|
||||
|
|
@ -1181,10 +1252,12 @@ impl EditorState {
|
|||
if local_row >= inner_rows {
|
||||
return;
|
||||
}
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
self.activate_and_position(win_id, local_row, local_col);
|
||||
}
|
||||
MouseEventKind::Up(MouseButton::Left) => {
|
||||
let mut core = self.core.borrow_mut();
|
||||
core.break_command_chain(frontend_id);
|
||||
if let Some(sel) = core.active_window().selection
|
||||
&& sel.anchor == core.cursor()
|
||||
{
|
||||
|
|
@ -1197,6 +1270,8 @@ impl EditorState {
|
|||
return; // Mode-line right-click: reserved.
|
||||
}
|
||||
self.mouse_click = None;
|
||||
// Opening the menu is a pointer gesture too (Q#KR2).
|
||||
self.core.borrow_mut().break_command_chain(frontend_id);
|
||||
self.open_context_menu(win_id, local_row, local_col, (cell_row, cell_col));
|
||||
}
|
||||
MouseEventKind::ScrollUp => {
|
||||
|
|
@ -1271,6 +1346,12 @@ impl EditorState {
|
|||
return;
|
||||
}
|
||||
core.set_active_window_id(win_id);
|
||||
// Every PointerKind moves point or changes the selection (the
|
||||
// GPU scrolls locally via Viewport, which never reaches here),
|
||||
// so any pointer gesture breaks the frontend's command chain
|
||||
// (kill ring Q#KR2) — clicking away and killing again must not
|
||||
// append, and M-y after a click must refuse.
|
||||
core.break_command_chain(frontend_id);
|
||||
let byte = {
|
||||
let registry = core.registry.clone();
|
||||
let reg = registry.borrow();
|
||||
|
|
|
|||
|
|
@ -123,6 +123,25 @@ pub struct QueryReplaceSession {
|
|||
found_any: bool,
|
||||
}
|
||||
|
||||
/// One frontend's command boundary — Emacs's `this-command` /
|
||||
/// `last-command` pair (kill ring, Q#KR2).
|
||||
///
|
||||
/// `this` is the command currently (or most recently) executing for the
|
||||
/// frontend; `last` is the one before it. A chain-sensitive command
|
||||
/// (kill append, `M-y`) reads `last` *while it runs* — its own rotation
|
||||
/// already moved its predecessor there. `this = None` is a broken
|
||||
/// chain: some non-command input (an optimistic CRDT edit, a pointer
|
||||
/// gesture, a paste, an unbound key) intervened, so the next rotation
|
||||
/// makes `last = None` and every chain check fails.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct CommandBoundary {
|
||||
/// The command executing now / most recently, or `None` after a
|
||||
/// non-command input.
|
||||
pub this: Option<String>,
|
||||
/// The command before `this`.
|
||||
pub last: Option<String>,
|
||||
}
|
||||
|
||||
/// The world state mutated by editor commands.
|
||||
pub struct EditorCore {
|
||||
/// Shared buffer registry. The registry is the canonical owner
|
||||
|
|
@ -217,6 +236,15 @@ pub struct EditorCore {
|
|||
/// frontend, which writes the OS clipboard (OSC 52 in the TUI,
|
||||
/// `arboard` in the GPU). Drained per-tick like `pending_crdt_ops`.
|
||||
pending_clipboard: Option<(FrontendId, Vec<u8>)>,
|
||||
/// Per-frontend command boundaries (kill ring, Q#KR2) — Emacs's
|
||||
/// `this-command` / `last-command`, tracked **per frontend**: two
|
||||
/// attached frontends interleave their own command streams, and a
|
||||
/// kill chain or yank session on frontend A must not survive into
|
||||
/// frontend B's checks. Every input path updates this — commands
|
||||
/// rotate; non-command inputs (optimistic CRDT edits, pointer
|
||||
/// gestures, pastes, unbound keys) break the chain. Entries are
|
||||
/// pruned on `SessionDetached` (Q#KR11).
|
||||
pub command_history: HashMap<FrontendId, CommandBoundary>,
|
||||
/// Open context menu (Q#CM1), or `None` when closed. Shared
|
||||
/// `Arc<Mutex>` so the TUI [`crate::menu::MenuView`] overlay renders
|
||||
/// from the same state the dispatch path mutates — the menu twin of
|
||||
|
|
@ -280,6 +308,7 @@ impl EditorCore {
|
|||
search: None,
|
||||
clipboard_slot: Vec::new(),
|
||||
pending_clipboard: None,
|
||||
command_history: HashMap::new(),
|
||||
menu: crate::menu::make_shared_menu(),
|
||||
completion_popup: crate::completion::make_shared_popup(),
|
||||
round_trip_buffers: std::collections::HashSet::new(),
|
||||
|
|
@ -2043,6 +2072,38 @@ impl EditorCore {
|
|||
Some(out)
|
||||
}
|
||||
|
||||
// ---- command boundaries (kill ring, Q#KR2) --------------------------
|
||||
|
||||
/// Record `name` as `fid`'s executing command: `last = this;
|
||||
/// this = name`. Called once per interactive command dispatch —
|
||||
/// keybound commands, the self-insert fallback, menu items, and
|
||||
/// `pmacs.command.invoke_interactive` (`M-x`).
|
||||
pub fn rotate_command(&mut self, fid: FrontendId, name: &str) {
|
||||
let entry = self.command_history.entry(fid).or_default();
|
||||
entry.last = entry.this.take();
|
||||
entry.this = Some(name.to_owned());
|
||||
}
|
||||
|
||||
/// Break `fid`'s command chain: a non-command input happened (an
|
||||
/// optimistic CRDT edit, a point-moving pointer gesture, an inbound
|
||||
/// paste, an unbound key). Sets `this = None`, so the next
|
||||
/// rotation yields `last = None` and every chain-sensitive check
|
||||
/// (kill append, `M-y`) fails.
|
||||
pub fn break_command_chain(&mut self, fid: FrontendId) {
|
||||
self.command_history.entry(fid).or_default().this = None;
|
||||
}
|
||||
|
||||
/// The active frontend's previous command — Emacs's `last-command`
|
||||
/// as observed *from inside* the currently-running command (its own
|
||||
/// rotation already happened).
|
||||
#[must_use]
|
||||
pub fn last_command(&self) -> Option<&str> {
|
||||
self.command_history
|
||||
.get(&self.active_frontend)?
|
||||
.last
|
||||
.as_deref()
|
||||
}
|
||||
|
||||
/// Copy the active region into the clipboard slot and queue an
|
||||
/// outbound OS-clipboard publish to the originating frontend.
|
||||
/// Returns `false` (a no-op) when there is no region.
|
||||
|
|
@ -2069,6 +2130,28 @@ impl EditorCore {
|
|||
Ok(true)
|
||||
}
|
||||
|
||||
/// Set the clipboard slot to arbitrary bytes and queue the
|
||||
/// OS-clipboard publish to the acting frontend (kill ring Q#KR1).
|
||||
/// The ring's kills have no region for [`Self::clipboard_copy`] to
|
||||
/// read (`C-k`'s killed line, an appended chain), so the Lua ring
|
||||
/// pushes the exact bytes here.
|
||||
pub fn clipboard_set(&mut self, bytes: Vec<u8>) {
|
||||
self.clipboard_slot.clone_from(&bytes);
|
||||
self.pending_clipboard = Some((self.active_frontend, bytes));
|
||||
}
|
||||
|
||||
/// The clipboard slot's current bytes, or `None` when empty (kill
|
||||
/// ring Q#KR6 — the yank-time "did external content arrive via a
|
||||
/// paste since our last kill" check).
|
||||
#[must_use]
|
||||
pub fn clipboard_get(&self) -> Option<&[u8]> {
|
||||
if self.clipboard_slot.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(&self.clipboard_slot)
|
||||
}
|
||||
}
|
||||
|
||||
/// Paste the clipboard slot at the cursor, replacing the active
|
||||
/// region if one exists (one undo step, like CUA type-over).
|
||||
/// Returns `false` when the slot is empty.
|
||||
|
|
|
|||
|
|
@ -4652,6 +4652,42 @@ fn install_command_module(lua: &Lua, commands: &SharedCommandRegistry) -> mlua::
|
|||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
// invoke_interactive(name, ...): like `invoke`, but records a
|
||||
// command boundary first (kill ring Q#KR2) — `last = this;
|
||||
// this = name` for the active frontend. Used by
|
||||
// `editor.execute-command` (M-x) so the invoked command's
|
||||
// chain semantics match Emacs's `execute-extended-command`
|
||||
// (which sets `this-command`): `M-x edit.kill-line` then `C-k`
|
||||
// appends, while `C-k` then `M-x edit.kill-line` does not.
|
||||
//
|
||||
// Plain `invoke` deliberately stamps NOTHING: it is a public
|
||||
// programmatic API called from wrappers, hooks, and async
|
||||
// callbacks, and must never pollute interactive command
|
||||
// history.
|
||||
let cmds = commands.clone();
|
||||
command.set(
|
||||
"invoke_interactive",
|
||||
lua.create_function(move |lua, (name, args): (String, Variadic<Value>)| {
|
||||
if let Some(core) = lua.app_data_ref::<SharedCore>() {
|
||||
let mut core = core.borrow_mut();
|
||||
let fid = core.active_frontend;
|
||||
core.rotate_command(fid, &name);
|
||||
}
|
||||
let body = {
|
||||
let r = cmds.borrow();
|
||||
r.get(&name)
|
||||
.ok_or_else(|| {
|
||||
mlua::Error::external(CommandError::NotFound { name: name.clone() })
|
||||
})?
|
||||
.body
|
||||
.clone()
|
||||
};
|
||||
body.call::<Variadic<Value>>(args)
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
|
||||
{
|
||||
let cmds = commands.clone();
|
||||
command.set(
|
||||
|
|
@ -10935,6 +10971,18 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// last_command(): the active frontend's previous interactive
|
||||
// command — Emacs's `last-command` as seen from inside the
|
||||
// running command (kill ring Q#KR2). `nil` after a non-command
|
||||
// input (optimistic edit, pointer gesture, paste, unbound key)
|
||||
// broke the chain.
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"last_command",
|
||||
lua.create_function(move |_, ()| Ok(cc.borrow().last_command().map(str::to_owned)))?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// view_top(): the active window's first visible source line.
|
||||
// The saveplace getter (Arc 3) — pairs with set_view_top so a
|
||||
|
|
@ -11107,6 +11155,35 @@ fn install_session(editor: &Table, lua: &Lua, core: &SharedCore) -> mlua::Result
|
|||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// clipboard_set(bytes): set the slot + queue the OS publish to
|
||||
// the acting frontend (kill ring Q#KR1). The ring's kills have
|
||||
// no region for clipboard_copy to read.
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"clipboard_set",
|
||||
lua.create_function(move |_, bytes: mlua::String| {
|
||||
cc.borrow_mut().clipboard_set(bytes.as_bytes().to_vec());
|
||||
Ok(())
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
// clipboard_get() -> string?: the slot's bytes (kill ring
|
||||
// Q#KR6 — yank's "did external content arrive via a paste
|
||||
// since our last kill" check). nil when empty.
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
"clipboard_get",
|
||||
lua.create_function(move |lua, ()| {
|
||||
let cc = cc.borrow();
|
||||
match cc.clipboard_get() {
|
||||
Some(bytes) => Ok(Some(lua.create_string(bytes)?)),
|
||||
None => Ok(None),
|
||||
}
|
||||
})?,
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let cc = core.clone();
|
||||
editor.set(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,660 @@
|
|||
//! Kill-ring acceptance (Arc 2, docs/kill-ring-framing.md rev 3).
|
||||
//!
|
||||
//! Drives the real dispatch surfaces: `dispatch_key` for chords (both
|
||||
//! "frontends" via distinct `FrontendId`s — an unregistered id falls
|
||||
//! back to LOCAL's view, sharing the buffer while keeping its own
|
||||
//! command boundary, which is exactly the shared-ring interleaving
|
||||
//! shape), `dispatch_mouse` / `dispatch_pointer` for the pointer
|
||||
//! boundary rows, and the real minibuffer for `M-x`.
|
||||
//!
|
||||
//! The daemon-side rows (optimistic CRDT edits, the unified
|
||||
//! authenticated-source paste) are covered by unit tests next to
|
||||
//! `handle_remote_crdt_op` / `handle_inbound_paste` in `src/daemon.rs`.
|
||||
|
||||
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::NONE,
|
||||
}
|
||||
}
|
||||
|
||||
fn ctrl(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(c), KeyModifiers::CONTROL),
|
||||
);
|
||||
}
|
||||
|
||||
fn alt(s: &mut EditorState, c: char) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(KeyCode::Char(c), KeyModifiers::ALT));
|
||||
}
|
||||
|
||||
fn press(s: &mut EditorState, code: KeyCode) {
|
||||
s.dispatch_key(FrontendId::LOCAL, key(code, KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
fn type_str(s: &mut EditorState, text: &str) {
|
||||
for ch in text.chars() {
|
||||
s.dispatch_key(
|
||||
FrontendId::LOCAL,
|
||||
key(KeyCode::Char(ch), KeyModifiers::NONE),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn exec(s: &EditorState, src: &str) {
|
||||
s.lua_host.lua().load(src.to_string()).exec().unwrap();
|
||||
}
|
||||
|
||||
fn eval<T: mlua::FromLuaMulti>(s: &EditorState, src: &str) -> T {
|
||||
s.lua_host.lua().load(src.to_string()).eval().unwrap()
|
||||
}
|
||||
|
||||
fn ring(s: &EditorState) -> Vec<String> {
|
||||
eval(s, "return pmacs.killring.list()")
|
||||
}
|
||||
|
||||
fn buffer_text(s: &EditorState) -> String {
|
||||
let b: mlua::String = eval(
|
||||
s,
|
||||
"local b = pmacs.window.buffer(); return b:slice(0, b:len())",
|
||||
);
|
||||
String::from_utf8_lossy(&b.as_bytes()).into_owned()
|
||||
}
|
||||
|
||||
fn status(s: &EditorState) -> String {
|
||||
s.core.borrow().status.clone()
|
||||
}
|
||||
|
||||
/// Fresh editor whose scratch buffer holds `text`, cursor at 0.
|
||||
fn editor_with(text: &str) -> EditorState {
|
||||
let mut s = EditorState::new();
|
||||
type_str(&mut s, text);
|
||||
exec(&s, "pmacs.editor.goto_byte(0)");
|
||||
s
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Chain mechanics
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn kill_line_kills_to_eol_and_the_newline_separately() {
|
||||
let mut s = editor_with("hello\nworld");
|
||||
ctrl(&mut s, 'k'); // kills "hello"
|
||||
assert_eq!(buffer_text(&s), "\nworld");
|
||||
assert_eq!(ring(&s), vec!["hello"]);
|
||||
// Cursor now sits at the newline: C-k kills the newline itself —
|
||||
// and, being consecutive, APPENDS.
|
||||
ctrl(&mut s, 'k');
|
||||
assert_eq!(buffer_text(&s), "world");
|
||||
assert_eq!(ring(&s), vec!["hello\n"], "consecutive C-k appends");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn consecutive_kills_build_one_entry_and_sync_the_clipboard() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // "one"
|
||||
ctrl(&mut s, 'k'); // "\n"
|
||||
ctrl(&mut s, 'k'); // "two"
|
||||
assert_eq!(ring(&s), vec!["one\ntwo"]);
|
||||
// OS slot mirrors the appended head (Q#KR4).
|
||||
let slot: String = eval(&s, "return pmacs.editor.clipboard_get()");
|
||||
assert_eq!(slot, "one\ntwo");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn movement_breaks_the_kill_chain() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k'); // "one"; buffer now "\ntwo\n"
|
||||
press(&mut s, KeyCode::Down); // a cursor command: chain broken
|
||||
exec(&s, "pmacs.editor.goto_byte(1)"); // start of "two"
|
||||
ctrl(&mut s, 'k'); // "two"
|
||||
assert_eq!(ring(&s), vec!["two", "one"], "two entries, no append");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn self_insert_breaks_the_kill_chain() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k'); // "one" (line now "\ntwo\n", cursor 0)
|
||||
type_str(&mut s, "x"); // buffer "x\ntwo\n"
|
||||
ctrl(&mut s, 'k'); // kills "" ... wait: cursor after 'x' is 1, at "\n"
|
||||
// cursor sits at the newline → kills it.
|
||||
assert_eq!(ring(&s), vec!["\n", "one"], "self-insert broke the chain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unbound_key_breaks_the_kill_chain() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k');
|
||||
press(&mut s, KeyCode::F(12)); // unbound, not printable
|
||||
assert!(status(&s).contains("not bound"));
|
||||
exec(&s, "pmacs.editor.goto_byte(1)");
|
||||
ctrl(&mut s, 'k');
|
||||
assert_eq!(ring(&s).len(), 2, "unbound key broke the chain");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn failed_kill_does_not_leave_an_appendable_chain() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k'); // ring: ["one"]
|
||||
ctrl(&mut s, 'w'); // no region → fails, clears last_kill_id
|
||||
assert!(status(&s).contains("no region"));
|
||||
assert_eq!(ring(&s), vec!["one"], "failed kill pushed nothing");
|
||||
ctrl(&mut s, 'k'); // kills "\n"
|
||||
assert_eq!(
|
||||
ring(&s),
|
||||
vec!["\n", "one"],
|
||||
"a kill after a FAILED kill pushes fresh (no stale append)"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// M-x semantics (the three-direction matrix, Q#KR2)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn m_x(s: &mut EditorState, name: &str) {
|
||||
alt(s, 'x');
|
||||
type_str(s, name);
|
||||
press(s, KeyCode::Enter);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m_x_kill_after_keybound_kill_does_not_append() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // "one"
|
||||
m_x(&mut s, "edit.kill-line"); // kills "\n" — but must NOT append
|
||||
assert_eq!(
|
||||
ring(&s),
|
||||
vec!["\n", "one"],
|
||||
"the minibuffer interaction breaks the chain (Emacs semantics)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keybound_kill_after_m_x_kill_appends() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
m_x(&mut s, "edit.kill-line"); // "one" — invoke_interactive stamps it
|
||||
ctrl(&mut s, 'k'); // "\n" — last_command is edit.kill-line → append
|
||||
assert_eq!(
|
||||
ring(&s),
|
||||
vec!["one\n"],
|
||||
"execute-extended-command sets this-command (Emacs semantics)"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m_x_kill_twice_does_not_append() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
m_x(&mut s, "edit.kill-line");
|
||||
m_x(&mut s, "edit.kill-line");
|
||||
assert_eq!(ring(&s).len(), 2, "each M-x interposes execute-command");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Yank / yank-pop
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn yank_inserts_the_head_and_yank_pop_cycles_and_wraps() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // "one"
|
||||
press(&mut s, KeyCode::Down); // break chain
|
||||
exec(&s, "pmacs.editor.goto_byte(1)");
|
||||
ctrl(&mut s, 'k'); // rest of "two"-line from byte 1: "wo"... careful
|
||||
// (buffer was "\ntwo\nthree\n"; byte 1 = 't'; kills "two"[1..] = "wo")
|
||||
let r = ring(&s);
|
||||
assert_eq!(r.len(), 2);
|
||||
let newest = r[0].clone(); // "wo"
|
||||
let oldest = r[1].clone(); // "one"
|
||||
|
||||
// Yank at the end of the buffer.
|
||||
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
|
||||
ctrl(&mut s, 'y');
|
||||
assert!(buffer_text(&s).ends_with(&newest), "C-y yanks the head");
|
||||
let cursor_after_yank: i64 = eval(&s, "return pmacs.editor.cursor()");
|
||||
assert_eq!(
|
||||
cursor_after_yank,
|
||||
len + i64::try_from(newest.len()).unwrap()
|
||||
);
|
||||
|
||||
// M-y replaces with the older entry...
|
||||
alt(&mut s, 'y');
|
||||
assert!(buffer_text(&s).ends_with(&oldest), "M-y rotates to older");
|
||||
let cursor: i64 = eval(&s, "return pmacs.editor.cursor()");
|
||||
assert_eq!(
|
||||
cursor,
|
||||
len + i64::try_from(oldest.len()).unwrap(),
|
||||
"cursor at end of rotation"
|
||||
);
|
||||
// ...and wraps back to the newest.
|
||||
alt(&mut s, 'y');
|
||||
assert!(buffer_text(&s).ends_with(&newest), "M-y wraps");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_pop_without_a_yank_refuses_and_stays_refused() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k');
|
||||
let before = buffer_text(&s);
|
||||
alt(&mut s, 'y');
|
||||
assert!(status(&s).contains("not a yank"));
|
||||
assert_eq!(buffer_text(&s), before, "no edit on refusal");
|
||||
// A second M-y must not ride the first one's name-stamp (Q#KR7):
|
||||
// last_command IS edit.yank-pop now, but no session exists.
|
||||
alt(&mut s, 'y');
|
||||
assert!(status(&s).contains("not a yank"));
|
||||
assert_eq!(buffer_text(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pointer_click_breaks_kill_chain_and_yank_session() {
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
let term = pmacs::cell::CellSize::new(24, 80);
|
||||
let click = |s: &mut EditorState, kind| {
|
||||
s.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
MouseEvent {
|
||||
kind,
|
||||
column: 1,
|
||||
row: 1,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
term,
|
||||
);
|
||||
};
|
||||
|
||||
// Kill side: C-k, click, C-k → two entries.
|
||||
ctrl(&mut s, 'k');
|
||||
click(&mut s, MouseEventKind::Down(MouseButton::Left));
|
||||
click(&mut s, MouseEventKind::Up(MouseButton::Left));
|
||||
ctrl(&mut s, 'k');
|
||||
assert_eq!(ring(&s).len(), 2, "a click must break the kill chain");
|
||||
|
||||
// Yank side: C-y, click, M-y → refused.
|
||||
ctrl(&mut s, 'y');
|
||||
click(&mut s, MouseEventKind::Down(MouseButton::Left));
|
||||
click(&mut s, MouseEventKind::Up(MouseButton::Left));
|
||||
let before = buffer_text(&s);
|
||||
alt(&mut s, 'y');
|
||||
assert!(
|
||||
status(&s).contains("not a yank"),
|
||||
"click invalidates the yank"
|
||||
);
|
||||
assert_eq!(buffer_text(&s), before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wheel_scroll_does_not_break_the_kill_chain() {
|
||||
use crossterm::event::{MouseEvent, MouseEventKind};
|
||||
let term = pmacs::cell::CellSize::new(24, 80);
|
||||
let scroll = |s: &mut EditorState, kind| {
|
||||
s.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
MouseEvent {
|
||||
kind,
|
||||
column: 1,
|
||||
row: 1,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
term,
|
||||
);
|
||||
};
|
||||
|
||||
// Case 1 — a boundary-clamped scroll (ScrollUp at the top) moves
|
||||
// nothing at all: the chain holds and the next C-k appends in
|
||||
// place.
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // "one"
|
||||
scroll(&mut s, MouseEventKind::ScrollUp);
|
||||
ctrl(&mut s, 'k'); // the newline
|
||||
assert_eq!(ring(&s), vec!["one\n"], "no-op scroll preserves the chain");
|
||||
|
||||
// Case 2 — pmacs scrolling is cursor-follows-view, so a mid-buffer
|
||||
// wheel moves point too. The chain STILL holds (Emacs: mwheel
|
||||
// preserves last-command) and the next kill appends from the NEW
|
||||
// point — one entry, not two.
|
||||
let mut s = editor_with("one\ntwo\nthree\nfour\nfive\nsix\n");
|
||||
ctrl(&mut s, 'k'); // "one"
|
||||
scroll(&mut s, MouseEventKind::ScrollDown);
|
||||
ctrl(&mut s, 'k');
|
||||
assert_eq!(
|
||||
ring(&s).len(),
|
||||
1,
|
||||
"a cursor-following scroll still preserves the chain: {:?}",
|
||||
ring(&s)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn semantic_pointer_gesture_breaks_the_chain() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k');
|
||||
let buf_id = s.core.borrow().active_window().buffer_id;
|
||||
s.dispatch_pointer(
|
||||
FrontendId::LOCAL,
|
||||
buf_id,
|
||||
5,
|
||||
pmacs::protocol::PointerKind::Down,
|
||||
pmacs::protocol::Modifiers::NONE,
|
||||
);
|
||||
exec(&s, "pmacs.editor.goto_byte(1)");
|
||||
ctrl(&mut s, 'k');
|
||||
assert_eq!(
|
||||
ring(&s).len(),
|
||||
2,
|
||||
"a semantic pointer Down breaks the chain"
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Region kills, selection yank
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn cut_and_copy_feed_the_ring_and_yank_replaces_a_selection() {
|
||||
let mut s = editor_with("alpha beta\n");
|
||||
// Select "alpha" (bytes 0..5).
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
|
||||
);
|
||||
alt(&mut s, 'w'); // copy
|
||||
assert_eq!(ring(&s), vec!["alpha"]);
|
||||
assert_eq!(buffer_text(&s), "alpha beta\n", "copy does not delete");
|
||||
|
||||
// Cut " beta" (bytes 5..10).
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.goto_byte(10); pmacs.editor.begin_selection(5)",
|
||||
);
|
||||
ctrl(&mut s, 'w');
|
||||
assert_eq!(buffer_text(&s), "alpha\n");
|
||||
assert_eq!(ring(&s), vec![" beta", "alpha"]);
|
||||
|
||||
// Yank over a selection replaces it, and M-y rotates against the
|
||||
// replaced range.
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
|
||||
);
|
||||
ctrl(&mut s, 'y'); // "alpha" → " beta"
|
||||
assert_eq!(buffer_text(&s), " beta\n");
|
||||
alt(&mut s, 'y'); // rotate to "alpha"
|
||||
assert_eq!(buffer_text(&s), "alpha\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared-ring interleaving (two frontends)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A second "frontend": an unregistered id shares LOCAL's view (the
|
||||
/// `active_view` fallback) but has its own command boundary and killring
|
||||
/// session — the exact shape of the Q#KR4/KR7 interleaving blockers.
|
||||
const B: FrontendId = FrontendId(9);
|
||||
|
||||
fn ctrl_as(s: &mut EditorState, fid: FrontendId, c: char) {
|
||||
s.dispatch_key(fid, key(KeyCode::Char(c), KeyModifiers::CONTROL));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn interleaved_kills_never_append_across_frontends() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // A kills "one"
|
||||
exec(&s, "pmacs.editor.goto_byte(1)");
|
||||
ctrl_as(&mut s, B, 'k'); // B kills "wo" — B's own first kill
|
||||
exec(&s, "pmacs.editor.goto_byte(2)");
|
||||
ctrl(&mut s, 'k'); // A again — A's last_kill is NOT the head (B's is)
|
||||
let r = ring(&s);
|
||||
assert_eq!(r.len(), 3, "A-kill/B-kill/A-kill = three entries");
|
||||
assert_eq!(r[1], "two", "B's entry intact — never appended onto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_pop_rotates_from_the_sessions_own_entry_despite_other_pushes() {
|
||||
let mut s = editor_with("one\ntwo\nthree\n");
|
||||
ctrl(&mut s, 'k'); // A: ring ["one"]
|
||||
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
|
||||
ctrl(&mut s, 'y'); // A yanks "one"; session → entry("one")
|
||||
|
||||
// B pushes a new head, shifting positions but not ids.
|
||||
exec(&s, "pmacs.editor.goto_byte(1)");
|
||||
ctrl_as(&mut s, B, 'k'); // B kills "wo" → ring ["wo", "one"]
|
||||
|
||||
// A's M-y must rotate from A's OWN entry ("one" — now position 2),
|
||||
// to the next-older-with-wrap = "wo". An index-based session would
|
||||
// have mis-resolved after B's push.
|
||||
// (B's kill edited the buffer, but before A's yanked range, so the
|
||||
// session's slice-verify passes — the range shifted is upstream.)
|
||||
// Note: B's kill removed bytes BEFORE the yank range, so the
|
||||
// remembered {start,stop} no longer hold the yanked text → the
|
||||
// invalidation guard fires instead. That IS the specified
|
||||
// behavior: refuse rather than splice a shifted range.
|
||||
alt(&mut s, 'y');
|
||||
assert!(
|
||||
status(&s).contains("changed since the yank"),
|
||||
"a concurrent upstream edit invalidates rather than mis-splices: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn yank_pop_uses_stable_ids_when_other_pushes_leave_the_range_intact() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl(&mut s, 'k'); // ring ["one"], A's chain live
|
||||
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
|
||||
ctrl(&mut s, 'y'); // A yanks "one" at end; session entry = "one"
|
||||
|
||||
// B COPIES bytes 1..3 ("tw" of the post-kill buffer "\ntwo\none"):
|
||||
// ring becomes ["tw", "one"], buffer untouched, so A's yanked
|
||||
// range is still intact.
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
|
||||
);
|
||||
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
|
||||
assert_eq!(ring(&s), vec!["tw", "one"]);
|
||||
|
||||
// A's M-y: the session's entry ("one") sits at position 2 now;
|
||||
// next-older-with-wrap is B's copy. An integer index recorded at
|
||||
// yank time (position 1) would have rotated from the wrong place.
|
||||
alt(&mut s, 'y');
|
||||
assert!(
|
||||
buffer_text(&s).ends_with("tw"),
|
||||
"rotation follows the stable id, not a shifted index: {:?}",
|
||||
buffer_text(&s)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn eviction_mid_session_invalidates_the_yank_pop() {
|
||||
let mut s = editor_with("one\ntwo\nthree\nfour\n");
|
||||
exec(&s, "pmacs.killring.max(2)");
|
||||
ctrl(&mut s, 'k'); // ring ["one"]
|
||||
let len: i64 = eval(&s, "local b = pmacs.window.buffer(); return b:len()");
|
||||
exec(&s, &format!("pmacs.editor.goto_byte({len})"));
|
||||
ctrl(&mut s, 'y'); // session → entry "one"
|
||||
|
||||
// Two copies (buffer untouched) evict "one" from a cap-2 ring.
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(1); pmacs.editor.goto_byte(3)",
|
||||
);
|
||||
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.begin_selection(5); pmacs.editor.goto_byte(8)",
|
||||
);
|
||||
s.dispatch_key(B, key(KeyCode::Char('w'), KeyModifiers::ALT));
|
||||
let r = ring(&s);
|
||||
assert_eq!(r.len(), 2);
|
||||
assert!(!r.contains(&"one".to_string()), "'one' evicted");
|
||||
|
||||
alt(&mut s, 'y');
|
||||
assert!(
|
||||
status(&s).contains("expired"),
|
||||
"an evicted session entry refuses cleanly: {:?}",
|
||||
status(&s)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// External content, menu, hook delivery, cap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[test]
|
||||
fn externally_pasted_content_joins_the_ring_at_yank() {
|
||||
let mut s = editor_with("");
|
||||
ctrl(&mut s, 'k'); // fails (empty buffer) — ring stays empty
|
||||
// An OS paste arrives (the daemon route sets the slot + inserts).
|
||||
s.core.borrow_mut().paste_inbound(b"external").unwrap();
|
||||
assert_eq!(buffer_text(&s), "external");
|
||||
// The next yank notices slot ≠ head, pushes it, and yanks it.
|
||||
ctrl(&mut s, 'y');
|
||||
assert_eq!(ring(&s), vec!["external"]);
|
||||
assert_eq!(buffer_text(&s), "externalexternal");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn menu_cut_feeds_the_ring_fires_after_edit_and_chains() {
|
||||
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||
let mut s = editor_with("alpha beta\n");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
_G.AE = 0
|
||||
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
|
||||
"#,
|
||||
);
|
||||
// Select "alpha", open the context menu via right-click, invoke Cut.
|
||||
exec(
|
||||
&s,
|
||||
"pmacs.editor.goto_byte(5); pmacs.editor.begin_selection(0)",
|
||||
);
|
||||
s.dispatch_mouse(
|
||||
FrontendId::LOCAL,
|
||||
MouseEvent {
|
||||
kind: MouseEventKind::Down(MouseButton::Right),
|
||||
column: 2,
|
||||
row: 0,
|
||||
modifiers: KeyModifiers::NONE,
|
||||
},
|
||||
pmacs::cell::CellSize::new(24, 80),
|
||||
);
|
||||
assert!(
|
||||
s.core.borrow().menu_is_open(),
|
||||
"right-click opened the menu"
|
||||
);
|
||||
// Find the "edit.cut" row in the open menu's state.
|
||||
let cut_index = {
|
||||
let menu = s.core.borrow().menu.clone();
|
||||
let guard = menu.lock().unwrap();
|
||||
guard
|
||||
.as_ref()
|
||||
.and_then(|m| {
|
||||
m.rows.iter().position(|r| {
|
||||
matches!(r, pmacs::menu::MenuRow::Item { command, .. }
|
||||
if command == "edit.cut")
|
||||
})
|
||||
})
|
||||
.expect("menu has a Cut row")
|
||||
};
|
||||
s.dispatch_menu_pointer(
|
||||
FrontendId::LOCAL,
|
||||
Some(u32::try_from(cut_index).unwrap()),
|
||||
true,
|
||||
);
|
||||
|
||||
assert_eq!(ring(&s), vec!["alpha"], "menu Cut fed the ring");
|
||||
assert_eq!(buffer_text(&s), " beta\n");
|
||||
let fired: i64 = eval(&s, "return _G.AE");
|
||||
assert_eq!(fired, 1, "menu Cut fires after-edit exactly once (Q#KR10b)");
|
||||
|
||||
// The menu rotation makes the cut chain like a keybound one:
|
||||
// a following C-k appends.
|
||||
ctrl(&mut s, 'k');
|
||||
let r = ring(&s);
|
||||
assert_eq!(r[0], "alpha beta", "menu Cut then C-k appends (rotate row)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn m_x_kill_fires_after_edit_and_keybound_does_not_double_fire() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
exec(
|
||||
&s,
|
||||
r#"
|
||||
_G.AE = 0
|
||||
pmacs.hook.add("buffer.after-edit", function() _G.AE = _G.AE + 1 end)
|
||||
"#,
|
||||
);
|
||||
ctrl(&mut s, 'k');
|
||||
let after_keybound: i64 = eval(&s, "return _G.AE");
|
||||
assert_eq!(after_keybound, 1, "keybound kill fires once, no double");
|
||||
m_x(&mut s, "edit.kill-line");
|
||||
let after_mx: i64 = eval(&s, "return _G.AE");
|
||||
assert_eq!(after_mx, 2, "M-x kill fires after-edit too (Q#KR10b)");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cap_is_validated_and_shrink_trims() {
|
||||
let s = editor_with("");
|
||||
let d: i64 = eval(&s, "return pmacs.killring.max()");
|
||||
assert_eq!(d, 60);
|
||||
for bad in ["0/0", "math.huge", "-math.huge", "0", "-3", "'ten'", "{}"] {
|
||||
let ok: bool = eval(&s, &format!("return (pcall(pmacs.killring.max, {bad}))"));
|
||||
assert!(!ok, "killring.max({bad}) must be rejected");
|
||||
}
|
||||
let set: i64 = eval(&s, "return pmacs.killring.max(2.9)");
|
||||
assert_eq!(set, 2, "floored");
|
||||
|
||||
// Five distinct entries via COPY over a static buffer (copies never
|
||||
// delete, so the byte offsets stay put; each text is distinct so
|
||||
// duplicate-of-head collapse never fires).
|
||||
let mut s2 = editor_with("aa bb cc dd ee\n");
|
||||
exec(&s2, "pmacs.killring.max(10)");
|
||||
for i in 0..5i64 {
|
||||
let lo = i * 3;
|
||||
exec(
|
||||
&s2,
|
||||
&format!(
|
||||
"pmacs.editor.begin_selection({lo}); pmacs.editor.goto_byte({})",
|
||||
lo + 2
|
||||
),
|
||||
);
|
||||
alt(&mut s2, 'w');
|
||||
}
|
||||
assert_eq!(ring(&s2).len(), 5);
|
||||
let trimmed: i64 = eval(&s2, "return pmacs.killring.max(3)");
|
||||
assert_eq!(trimmed, 3);
|
||||
assert_eq!(ring(&s2).len(), 3, "shrinking the cap trims immediately");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frontend_detached_drops_per_frontend_state() {
|
||||
let mut s = editor_with("one\ntwo\n");
|
||||
ctrl_as(&mut s, B, 'k'); // B kills → B has last_kill_id
|
||||
let has: bool = eval(
|
||||
&s,
|
||||
"return pmacs.killring._debug_state(9).last_kill_id ~= nil",
|
||||
);
|
||||
assert!(has, "B has kill state");
|
||||
// The daemon fires this on SessionDetached (Q#KR11); fire it the
|
||||
// same way to exercise the Lua-side cleanup.
|
||||
exec(&s, "pmacs.hook.run('frontend.detached', 9)");
|
||||
let gone: bool = eval(
|
||||
&s,
|
||||
"local st = pmacs.killring._debug_state(9); \
|
||||
return st.last_kill_id == nil and st.session == nil",
|
||||
);
|
||||
assert!(gone, "detach dropped B's killring state");
|
||||
}
|
||||
Loading…
Reference in New Issue