fix(multicursor): insert-mode Lua/API edit dropped on ESC #42312

Problem:
With multicursors, an Insert-mode edit by a Lua/`<Cmd>` mapping or API
(nvim_put(), …) is live-previewed at the other cursors, but dropped at
ESC: the cascade replays _redo_, which does not contain such edits.

Solution:
When `<Cmd>`/Lua/RPC changes the buffer during Insert-mode, sync the
previews and restart the capture (like a non-captured cursor-move). The
edit stays as text.

- The mapping runs only at the primary. Its text is mirrored as-is
  (similar to insert-completion). Re-running it per cursor would require
  a "partial-remap" span replay, and breaks single-session plugins
  (vim.snippet).
- Not during completion; redo already captures the pre-inserted
  candidate on accept.
- Text typed before the edit is kept as mirrored, not replayed, so
  per-cursor effects ('textwidth' wrapping, …) don't apply to it.

Known issues:
- "o"/"O"/"cc" with 'autoindent' followed by the mapping: the per-cursor
  insert-entry replay is still pending, so the edit is lost.
This commit is contained in:
Justin M. Keyes authored and GitHub committed 2026-10-12 07:40:50 -04:00
1 parent 0f17fab619
commit 4bfbad6e91
4 files changed
+63 -7

No files matched your search

+2
View File
@@ -511,6 +511,8 @@ LIMITATIONS *mcursor-limitations*
- During Insert-mode completion, live mirroring pauses if a preview could
shift or overlap the primary's completion (same-line edits before the
primary, or line insertions/deletions before it).
- During Insert-mode, edits by a <Cmd>/Lua/API are not replayed per-cursor;
the primary text is mirrored as-is (similar to insert-completion).
- Cursor is removed if its Insert-entering operation fails. E.g. "ct;" on
a line without ";", "cgn" with no match.
- Explicit clipboard registers (`"*yy`, `"+yy`) are global, not per-cursor. On
+7 -4
View File
@@ -986,14 +986,17 @@ static int insert_handle_key(InsertState *s)
pum_want.active = false;
}
if (curbuf->b_u_synced
|| (bufref_valid(&save_curbuf)
&& curbuf == save_curbuf.br_buf
&& tick != buf_get_changedtick(curbuf))) {
const bool changed = bufref_valid(&save_curbuf) && curbuf == save_curbuf.br_buf
&& tick != buf_get_changedtick(curbuf);
if (curbuf->b_u_synced || changed) {
// The K_EVENT, K_COMMAND, or K_LUA synced undo or changed this buffer.
// Save the cursor line before the next typed edit.
Ins.need_undo = true;
}
if (changed && !ins_compl_active()) { // Multicursor: keep the edit as preview text.
mc_ins_cascade();
mc_ins_cascade_restart();
}
break;
}
+4 -2
View File
@@ -103,6 +103,7 @@ typedef struct {
/// The boundary:
/// - literal text is previewed
/// - non-literal keys (mc_ins_keys_nonliteral) have per-cursor effects, so flush (early commit).
/// - non-captured cursor-moves and edits keep the previews (see mc_ins_cascade_restart).
static struct {
CmdOrigin origin; ///< State at session start: the session's atoms diff against it.
kvec_t(uint32_t) regions; ///< Per-cursor preview regions (`mc_session_ns`).
@@ -792,8 +793,9 @@ static bool mc_ins_keys_nonliteral(const char *keys, size_t len)
return false;
}
/// Capture restarted mid insert-session (stop_arrow(), after a non-captured cursor-move: mouse,
/// <PageUp>, …). The previews stay; rebase and continue the insert-cascade.
/// Capture restarted mid insert-session, after a non-captured cursor-move (stop_arrow(): mouse,
/// <PageUp>, …) or edit (<Cmd>, Lua mapping, RPC, …). The previews stay; rebase and continue the
/// insert-cascade.
void mc_ins_cascade_restart(void)
{
if (!mc_ins_span.active || mc_replaying() || !(State & MODE_INSERT)
+50 -1
View File
@@ -385,12 +385,18 @@ describe('multicursor', function()
local screen = Screen.new(30, 5)
cursors({ 'aa bb cc', 'dd ee' }, '')
feed('zqw')
eq({ 1, 0 }, api.nvim_win_get_cursor(0))
screen:expect({
grid = [[
{17:^a}a {17:b}b {17:c}c |
{17:d}d {17:ee} |
{1:~ }|*2
|
]],
condition = function()
eq('normal', screen.mode) -- Not "operator".
end,
})
eq({ 1, 0 }, api.nvim_win_get_cursor(0))
-- ("w" on the last word stops at its end.)
eq({ { 0, 0 }, { 0, 3 }, { 0, 6 }, { 1, 0 }, { 1, 3 }, { 1, 4 } }, anchors())
-- A count applies to the motion ("2zqw" is "zq2w": every 2nd word).
@@ -1887,6 +1893,49 @@ describe('multicursor', function()
end
end)
it('edit not captured by redo (Lua/RPC) stays as previewed #42205', function()
n.exec_lua(function()
vim.keymap.set('i', '<C-j>', function()
vim.api.nvim_put({ 'X' }, 'c', false, true)
end)
end)
cursors({ 'foo', 'bar' }, 'QjQ')
feed('A<C-j>')
eq({ 'fooX', 'barX' }, get_lines())
feed('<Esc>')
eq({ 'fooX', 'barX' }, get_lines())
-- Cursors land on the last-inserted char, like the primary.
eq({ { 0, 3 }, { 1, 3 } }, anchors())
eq({ 2, 3 }, api.nvim_win_get_cursor(0))
-- Typed text around the edits; RPC edit (K_EVENT).
feed('a1<C-j>2')
api.nvim_put({ 'Y' }, 'c', false, true)
feed('3<Esc>')
eq({ 'fooX1X2Y3', 'barX1X2Y3' }, get_lines())
feed('u')
eq({ 'fooX', 'barX' }, get_lines())
-- Typed text after the edit still replays ('textwidth' wraps per cursor), non-literal keys
-- after it flush, and a session ended by the mapping (:stopinsert) or CTRL-C keeps it.
command('set textwidth=12')
command('imap <C-k> <C-j><Cmd>stopinsert<CR>')
for _, case in ipairs({
{ 'Aab<C-j>cd ee ff<Esc>', { 'fooabXcd ee', 'ff', 'barabXcd ee', 'ff' } },
{ 'A<C-j><Left>Y<BS><Esc>', { 'fooX', 'barX' } },
{ 'A<C-k>', { 'fooX', 'barX' } },
{ 'A<C-j>', { 'fooX', 'barX' }, '<C-c>' },
}) do
clear_cursors()
cursors({ 'foo', 'bar' }, 'QjQ')
feed(case[1])
if case[3] then
n.poke_eventloop() -- Mapping runs before CTRL-C interrupts.
feed(case[3])
end
eq(case[2], get_lines(), case[1])
end
end)
it('mapping ending in a Normal-mode "tail", cascades #41864', function()
command('inoremap <Esc> <Esc>l') -- the "<Esc>" leaves a Normal-mode tail ("l")
api.nvim_buf_set_lines(0, 0, -1, true, { 'echo hi', 'echo bye' })