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<CR>").

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.
This commit is contained in:
Justin M. Keyes
2026-09-02 10:38:27 -04:00
committed by GitHub
parent 8321941ff2
commit 80e9708afa
4 changed files with 40 additions and 6 deletions

View File

@@ -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: >

View File

@@ -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();

View File

@@ -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) {

View File

@@ -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<CR>')
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 :new<CR>ihello<Esc>:bwipe!<CR>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')