From 80e9708afa2a4e46967e5961dfc6a49fea84a118 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Wed, 2 Sep 2026 10:38:27 -0400 Subject: [PATCH] fix(multicursor): atom may cascade in another buffer #41624 Problem: An atom queued in buffer A cascades on B's cursors if the mapping ends in B ("nnoremap X x:bnext"). Solution: Check the atom's origin buffer (`CmdAtom.origin.buf`). Note: This does not preclude mappings etc from doing work in temporary throwaway buffers, as long as they return to the origin buffer. --- runtime/doc/repeat.txt | 1 + src/nvim/input_cmdatom.c | 5 ++--- src/nvim/mcursor.c | 12 ++++++++--- test/functional/editor/mcursor_spec.lua | 28 +++++++++++++++++++++++++ 4 files changed, 40 insertions(+), 6 deletions(-) diff --git a/runtime/doc/repeat.txt b/runtime/doc/repeat.txt index ded134a4d6..884527eacf 100644 --- a/runtime/doc/repeat.txt +++ b/runtime/doc/repeat.txt @@ -343,6 +343,7 @@ context), multicursor replays actions "semantically" (|CmdAtom|). line at cursor, not the entire folded contents). Motions step into folds instead of skipping over them. The primary cursor keeps the usual |fold-behavior|. +- Cursors are per-buffer, and cascade only while their buffer is current. - CTRL-C interrupts the cascade. Example: place a cursor on every matching pattern: > diff --git a/src/nvim/input_cmdatom.c b/src/nvim/input_cmdatom.c index 01129076d8..627d4699e4 100644 --- a/src/nvim/input_cmdatom.c +++ b/src/nvim/input_cmdatom.c @@ -498,8 +498,7 @@ static char *atom_composite_lhs(void) return keys.items; } -/// Queues an LHS-replay atom: a mapping that edited invisibly (:normal/:call, "ds'") re-runs -/// per cursor from its LHS + payload keys. Cascade only. +/// Queues an internal-only atom for mcursor cascade (no emit). void atom_lhs_replay_queue(void) { kv_push(g_atoms, ((CmdAtom){ .type = kAMapping, .keys = atom_composite_lhs(), .remap = true })); @@ -972,7 +971,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa redo_append_str(suffix, -1); } if (!atom_is_user_cmd() || !(vatom.state & kVatomTyped)) { - // Not user input (":normal! vjd", fed keys): the redo prep above is the only effect; no emit. + // Internal input (":norm! vjd", fed keys): the redo prep above is the only effect; no emit. xfree(vkeys); xfree(suffix); atom_visual_reset(); diff --git a/src/nvim/mcursor.c b/src/nvim/mcursor.c index b3c1037c89..f288be7fd0 100644 --- a/src/nvim/mcursor.c +++ b/src/nvim/mcursor.c @@ -468,6 +468,13 @@ static void mc_cascade(void) // Replay each atom at each cursor (nested ":norm! xx" queues multiple atoms per clock edge). for (size_t ai = 0; ai < kv_size(g_atoms); ai++) { + CmdAtom *atom = &kv_A(g_atoms, ai); + if (atom->origin.buf.br_buf != NULL + && (!bufref_valid(&atom->origin.buf) || atom->origin.buf.br_buf != curbuf)) { + // Cascade only in the atom's origin buffer (a mapping may switch buffers). + // Assume untagged atoms (atom_lhs_replay_queue()) are current-buffer. + continue; + } for (size_t ci = 0; ci < kv_size(mc_cursors); ci++) { // Replays consume `typebuf` only, so check for CTRL-C in OS/RPC input here. line_breakcheck(); @@ -499,9 +506,8 @@ void mc_clock_edge(bool map_edit) { if (map_edit && !atom_composite_queued() && kv_size(g_atoms) == 0 && atom_composite_active() && mc_buf_has_cursors(curbuf)) { - // A payload mapping (vim-surround "ds'"/"S") edited the buffer via :normal/:call; invisible - // to atom capture, so nothing was queued. Fallback to LHS-replay: re-run the mapping at each - // cursor by replaying its trigger plus the keys its getchar() read (atom_lhs_replay_queue()). + // A payload mapping (vim-surround "ds'"/"S") edited the buffer via :norm/:call, invisible to + // atom capture, so nothing was queued. Fallback to LHS-replay. atom_lhs_replay_queue(); } if (mc_buf_has_cursors(curbuf) && kv_size(g_atoms) > 0) { diff --git a/test/functional/editor/mcursor_spec.lua b/test/functional/editor/mcursor_spec.lua index 209d410b4d..dbaa66d55b 100644 --- a/test/functional/editor/mcursor_spec.lua +++ b/test/functional/editor/mcursor_spec.lua @@ -930,6 +930,34 @@ describe('multicursor', function() eq({ 'xx', 'yy' }, api.nvim_buf_get_lines(buf_b, 0, -1, true)) end) + it('atom queued in one buffer does not cascade in another buffer', function() + command('set hidden') + cursors({ 'aaa', 'bbb' }, 'Qj') + local buf_a = api.nvim_get_current_buf() + command('enew') + fn.setline(1, { 'ccc', 'ddd' }) + feed('gg0Qj') + local buf_b = api.nvim_get_current_buf() + command('buffer #') + command('nnoremap X x:bnext') + feed('2G0X') + -- The "x" atom was captured in A but resolves in B. It should not cascade to B's cursors. + -- A's cursors are skipped, except the edit done by the primary cursor. + eq({ a = { 'aaa', 'bb' }, b = { 'ccc', 'ddd' } }, { + a = api.nvim_buf_get_lines(buf_a, 0, -1, true), + b = api.nvim_buf_get_lines(buf_b, 0, -1, true), + }) + -- Same-buffer edit still cascades. + feed('2G0x') + eq({ 'cc', 'dd' }, api.nvim_buf_get_lines(buf_b, 0, -1, true)) + + -- TEMPORARY buffer-switch is allowed. A mapping may do work in a throwaway buffer, then apply + -- edits to curbuf. + command('nnoremap Y :newihello:bwipe!x') + feed('2G0Y') + eq({ 'c', 'd' }, api.nvim_buf_get_lines(buf_b, 0, -1, true)) + end) + it('edits cascade from any window showing the buffer (:split)', function() -- Cascade is conditional on the buffer, not the window. cursors({ 'aaa', 'bbb' }, 'Q')