fix(multicursor): cursors may be "deduped" incorrectly #41721

Problem:
Freeing an unrelated buffer deletes the cursor under the primary.
Scratch-buffer cleanup triggers this unexpectedly.

Solution:
Separate dead-cursor cleanup from dedupe/merge. Merge overlapping
cursors only at cascade boundaries, not during buffer/namespace cleanup
or cursor removal.
This commit is contained in:
Justin M. Keyes
2026-09-05 13:18:22 -04:00
committed by GitHub
parent c543e9e1de
commit 455b6a810c
2 changed files with 43 additions and 16 deletions

View File

@@ -353,7 +353,7 @@ static void mc_execute(size_t cursoridx, size_t atomidx)
// Get the tracked position: edits by other cursors (etc) may have shifted it since last update.
if (ctx.mark != 0 && !mc_mark_get(curbuf, mc_ns(), ctx.mark, &ctx.pos)) {
// The extmark was deleted, thus the cursor is deleted (swept by mc_dedupe()).
// The extmark was deleted, thus the cursor is deleted (swept by mc_cleanup()).
return;
}
@@ -457,7 +457,7 @@ static void mc_cascade(void)
edits |= kv_A(g_atoms, i).type != kAMotion;
}
if (edits) {
mc_dedupe();
mc_cleanup(true);
if (kv_size(mc_cursors) == 0) {
atoms_free(&g_atoms);
return;
@@ -491,7 +491,7 @@ done:
atoms_free(&g_atoms);
mc_sandbox_leave(&sb);
end_batch_changes();
mc_dedupe();
mc_cleanup(true);
if (handle_get_buffer(sb.bufnr) == curbuf && !curbuf->b_u_synced
&& curbuf->b_u_newhead != NULL) {
// Store the primary's post-cascade position in the still-open undo block; redo restores it.
@@ -528,8 +528,10 @@ void mc_clock_edge(bool map_edit, bool map_moved)
}
}
/// Prunes cursors that overlap others, so an edit does not apply N times at one position.
static void mc_dedupe(void)
/// Prunes dead cursors and ends empty sessions. Optionally dedupes.
///
/// @param dedupe Also removes overlapping cursors at cascade boundaries.
static void mc_cleanup(bool dedupe)
{
const bool had_cursors = kv_size(mc_cursors) > 0;
size_t n = 0;
@@ -542,9 +544,9 @@ static void mc_dedupe(void)
ctx_free(ctx);
continue;
}
// This cursor is a duplicate (to sweep) if it coincides with the primary (which always
// wins), or another cursor's mark is first at its position (first-wins tiebreak).
const bool dup = ctx->mark != 0
// Deduplicate/merge: cursor is a duplicate if it coincides with the primary (which always
// wins), or another cursor's mark is first at its position (first wins).
const bool dup = dedupe && ctx->mark != 0
&& ((curwin != NULL && buf == curbuf && equalpos(ctx->pos, curwin->w_cursor))
|| mc_mark_at(buf, ctx->pos) != ctx->mark);
if (dup) {
@@ -1263,7 +1265,7 @@ void mc_toggle(buf_T *buf, pos_T pos, bool end_follow)
uint32_t mark = mc_mark_at(buf, pos);
if (mark != 0) {
extmark_del_id(buf, mc_ns(), mark);
mc_dedupe(); // sweeps the mark-less cursor (and ends the session if it was the last)
mc_cleanup(false);
return;
}
mc_add(buf, pos);
@@ -1307,10 +1309,10 @@ void mc_buf_clear(buf_T *buf)
void mc_buf_free(buf_T *buf)
{
if (mc_replaying()) {
// Can't mutate mc_cursors during cascade. Entries are swept by mc_dedupe() after the cascade.
// Can't mutate (mc_cleanup) mc_cursors during cascade.
return;
}
mc_dedupe();
mc_cleanup(false);
if (mc_vsel_buf == buf->handle) {
// The selection extmarks died with the buffer too.
mc_vsel_buf = 0;
@@ -1362,7 +1364,7 @@ void mc_ns_cleared(buf_T *buf, uint32_t ns_id)
uint32_t mark = 0;
mc_mark_set(buf, mc_last_ns(), &mark, ctx->pos, false, true, false);
}
mc_dedupe(); // Cleanup.
mc_cleanup(false);
if (kv_size(mc_cursors) == 0) {
// Session ended; drop the pending cascade.
atoms_free(&g_atoms);

View File

@@ -302,6 +302,16 @@ describe('multicursor', function()
api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0)
api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0)
eq(0, ncursors())
-- Toggling another cursor keeps the cursor under the primary.
feed('Q')
for _ = 1, 2 do
api.nvim_input_mouse('left', 'press', 'C', 0, 2, 0)
api.nvim_input_mouse('left', 'release', 'C', 0, 2, 0)
end
eq({ { 0, 0 } }, anchors())
feed('Q')
-- CTRL-click keeps "q=" follow-mode (unlike Q).
feed('ggQj')
feed('q=')
@@ -796,8 +806,7 @@ describe('multicursor', function()
cursors({ 'a', 'b' }, 'Qj0')
feed('5g<C-A>')
eq({ '5a', '6b' }, get_lines())
-- The primary sitting ON a cursor (no cascade ran in between, so mc_dedupe did not):
-- the coincident pair shares one number slot, and the cursor survives.
-- Number overlapping primary/multicursor positions once, without deleting the multicursor.
clear_cursors()
cursors({ 'x', 'y', 'z' }, 'QjQj')
feed('gg0g<C-A>')
@@ -855,7 +864,7 @@ describe('multicursor', function()
t.matches('Invalid buffer', t.pcall_err(api.nvim_mcursor, 9999, { 1, 0 }))
end)
it('deleting a cursor extmark deletes the cursor', function()
it('deleting an extmark deletes its cursor', function()
cursors({ 'aaa', 'bbb', 'ccc' })
local ns = api.nvim_create_namespace('nvim.multicursor')
local marks = api.nvim_buf_get_extmarks(0, ns, 0, -1, {})
@@ -875,11 +884,22 @@ describe('multicursor', function()
n.expect_exit(command, 'qall!')
end)
it('cursors are freed with their buffer', function()
it('cursors are disposed with their buffer', function()
fn.setline(1, { 'aaa', 'bbb' })
local buf = api.nvim_get_current_buf()
eq(1, api.nvim_mcursor(0, { 1, 0 }))
eq(2, api.nvim_mcursor(0, { 2, 0 }))
-- Deleting an unrelated buffer does not dedupe the cursor under the primary. #41651
for _, has_cursor in ipairs({ false, true }) do
local scratch = api.nvim_create_buf(false, true)
if has_cursor then
eq(3, api.nvim_mcursor(scratch, { 1, 0 }))
end
api.nvim_buf_delete(scratch, { force = true })
eq({ { 0, 0 }, { 1, 0 } }, anchors())
end
command('new')
fn.setreg('"', 'KEEP')
command('bwipeout! ' .. buf)
@@ -990,6 +1010,11 @@ describe('multicursor', function()
eq(1, ncursors())
feed('x') -- both cursors edit again (primary still on line 2)
eq({ 'aa', 'bb' }, get_lines())
-- Partially clearing the namespace does not "dedupe" the cursor under the primary.
feed('Q')
api.nvim_buf_clear_namespace(0, api.nvim_create_namespace('nvim.multicursor'), 0, 1)
eq({ { 1, 0 } }, anchors())
end)
it(':edit! reload clears the cursors', function()