From e973eb4a8ff5e3082de401b4667ee92ba4777847 Mon Sep 17 00:00:00 2001 From: "Justin M. Keyes" Date: Thu, 10 Sep 2026 11:06:08 -0400 Subject: [PATCH] fix(multicursor): undo corrupted by insert that changes line-count #41835 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Undo "u" is wrong after cascading an insert that adds a line (`A{`, `o`, …). The cascade replays the insert as a per-cursor `edit()`, where `u_save` stores a line multiple times into the undo block at different line counts; then `getbot` computes conflicting entries that undo each other. Solution: `u_save` the whole affected line-range as one entry at session start, the same approach used by Vim for linewise ops (`op_shift`, `ex_sort`). The "enclosing" `u_save` wins and the invalid entries are overridden. --- src/nvim/mcursor.c | 18 ++++++++++++++++++ test/functional/editor/mcursor_spec.lua | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/src/nvim/mcursor.c b/src/nvim/mcursor.c index b06eaa94da..78dbca8143 100644 --- a/src/nvim/mcursor.c +++ b/src/nvim/mcursor.c @@ -601,6 +601,24 @@ void mc_ins_cascade_start(bool cascade, varnumber_T tick) mc_ins_span.tick = tick; mc_ins_span.region = 0; mc_ins_regions_clear(); + + if (mc_ins_span.active) { + // Cover the line-range of all cursors with one undo-entry. #41822 + // + // Undo entries apply in reverse save-order, so this entry (saved first) "wins", even though + // per-cursor newline-shifting edits may record conflicting undo entries. Same approach is used + // by Vim for linewise-op/range-command (see u_save in op_shift, ex_sort, …). + linenr_T lo = curwin->w_cursor.lnum; + linenr_T hi = curwin->w_cursor.lnum; + for (size_t i = 0; i < kv_size(mc_cursors); i++) { + pos_T pos; + if (mc_ctx_resolve(&kv_A(mc_cursors, i), &pos)) { + lo = MIN(lo, pos.lnum); + hi = MAX(hi, pos.lnum); + } + } + (void)u_save(lo - 1, hi + 1); // Ignore FAIL result: only relevant only if undo is unavailable. + } } /// True during a span replay. The replay's synthetic does not end the primary insert-session, diff --git a/test/functional/editor/mcursor_spec.lua b/test/functional/editor/mcursor_spec.lua index 42a3ccbb5b..9eb05c3277 100644 --- a/test/functional/editor/mcursor_spec.lua +++ b/test/functional/editor/mcursor_spec.lua @@ -2359,6 +2359,24 @@ describe('multicursor', function() eq(3, fn.line('.')) -- primary placement after undoing a live insert end) + it('one undo reverts a per-cursor newline-insert #41822', function() + -- A newline shifts the line count between the per-cursor replays; single "u" restores it. + cursors({ 'aa', 'bb', 'cc' }, 'Qjj') + feed('A{') -- Append + newline at cursor (line 1) and primary (line 3). + eq({ 'aa{', '', 'bb', 'cc{', '' }, get_lines()) + feed('u') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + feed('') + eq({ 'aa{', '', 'bb', 'cc{', '' }, get_lines()) + -- "o" opens the line before Insert starts: the range still covers it. + clear_cursors() + cursors({ 'aa', 'bb', 'cc' }, 'Qjj') + feed('ox') + eq({ 'aa', 'x', 'bb', 'cc', 'x' }, get_lines()) + feed('u') + eq({ 'aa', 'bb', 'cc' }, get_lines()) + end) + it('a mapped undo/redo (vim-repeat "nmap u") does not cascade', function() -- vim-repeat maps u/U/ to undo/redo wrappers. Such a mapping changes the buffer, but an -- undo/redo is buffer-global, not a per-cursor edit: it must NOT cascade, or every cursor