fix(multicursor): undo corrupted by insert that changes line-count #41835

Problem:
Undo "u" is wrong after cascading an insert that adds a line (`A{<CR>`, `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.
This commit is contained in:
Justin M. Keyes
2026-09-10 11:06:08 -04:00
committed by GitHub
parent 515c61f075
commit e973eb4a8f
2 changed files with 36 additions and 0 deletions

View File

@@ -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 <Esc> does not end the primary insert-session,

View File

@@ -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{<CR><Esc>') -- 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('<C-r>')
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<Esc>')
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/<C-R> 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