fix(extmarks): undo-redo of a mark explicitly moved during an edit #41252

Problem:
A mark moved by nvim_buf_set_extmark() during an edit is misplaced by
undo and redo. Only splices ("edits") are recorded, and replaying them
reproduces the shifts they caused, never the explicit set: the mark ends
up wherever the text pushed it.

Solution:
When an open undo block moves an existing mark, record both positions.
Undo restores the pre-set position, redo re-applies the set.

Partially reverts 18334a4a0c ; ExtmarkSavePos.row/col were unused
because nothing recorded an explicit move, but now `extmark_set()` does.
This commit is contained in:
Justin M. Keyes
2026-08-10 04:21:31 -04:00
committed by GitHub
parent 8d406ed2ac
commit 2546741d1b
3 changed files with 51 additions and 7 deletions

View File

@@ -64,6 +64,25 @@ void extmark_set(buf_T *buf, uint32_t ns_id, uint32_t *idp, int row, colnr_T col
MarkTreeIter itr[1] = { 0 };
MTKey old_mark = marktree_lookup_ns(buf->b_marktree, ns_id, id, false, itr);
if (old_mark.id) {
// Moved inside an open undo block: record BOTH positions, because replaying the surrounding
// splices reproduces only the shifts they caused, not an explicit extmark_set().
if (!no_undo && !buf->b_u_synced
&& (old_mark.pos.row != row || old_mark.pos.col != col)) {
u_header_T *uhp = u_force_get_undo_header(buf);
if (uhp) {
kv_push(uhp->uh_extmark, ((ExtmarkUndoObject){
.type = kExtmarkSavePos,
.data.savepos = {
.mark = mt_lookup_key(old_mark),
.undo_row = old_mark.pos.row,
.undo_col = old_mark.pos.col,
.redo_row = row,
.redo_col = col,
.invalidated = false,
}
}));
}
}
if (mt_paired(old_mark) || end_row > -1) {
extmark_del_id(buf, ns_id, id);
} else {
@@ -419,8 +438,10 @@ void extmark_splice_delete(buf_T *buf, int l_row, colnr_T l_col, int u_row, coln
ExtmarkSavePos pos = {
.mark = mt_lookup_key(mark),
.invalidated = invalidated,
.old_row = mark.pos.row,
.old_col = mark.pos.col
.undo_row = mark.pos.row,
.undo_col = mark.pos.col,
.redo_row = -1,
.redo_col = -1,
};
undo.data.savepos = pos;
undo.type = kExtmarkSavePos;
@@ -454,10 +475,12 @@ void extmark_apply_undo(ExtmarkUndoObject undo_info, bool undo)
// kExtmarkSavePos
} else if (undo_info.type == kExtmarkSavePos) {
ExtmarkSavePos pos = undo_info.data.savepos;
if (undo && pos.old_row >= 0) {
extmark_setraw(curbuf, pos.mark, pos.old_row, pos.old_col, pos.invalidated);
if (undo && pos.undo_row >= 0) {
extmark_setraw(curbuf, pos.mark, pos.undo_row, pos.undo_col, pos.invalidated);
} else if (!undo && pos.redo_row >= 0) {
// Redo an explicit extmark_set (kExtmarkSplice only redo's the shifts).
extmark_setraw(curbuf, pos.mark, pos.redo_row, pos.redo_col, false);
}
// No Redo since kExtmarkSplice will move marks back
} else if (undo_info.type == kExtmarkMove) {
ExtmarkMove move = undo_info.data.move;
if (undo) {

View File

@@ -43,8 +43,10 @@ typedef struct {
// extmark was updated
typedef struct {
uint64_t mark; // raw mark id of the marktree
int old_row;
colnr_T old_col;
int undo_row;
colnr_T undo_col;
int redo_row; // -1: nothing to redo (the mark was not explicitly moved).
colnr_T redo_col;
bool invalidated;
} ExtmarkSavePos;

View File

@@ -983,6 +983,25 @@ describe('API/extmarks', function()
eq(2, #rv)
end)
it('undo and redo of a mark explicitly moved during an edit', function()
feed('ggdGiabcdef<esc>')
set_extmark(ns, marks[1], 0, 4)
-- Insert at col 0; while the undo block is still open, explicitly
-- move the mark.
feed('0i!!')
set_extmark(ns, marks[1], 0, 1)
feed('<esc>')
eq({ 0, 1 }, get_extmark_by_id(ns, marks[1]))
feed('u')
-- Back where it was before the edit (the recorded set-time position,
-- shifted back by the splice reversal).
eq({ 0, 4 }, get_extmark_by_id(ns, marks[1]))
feed('<c-r>')
-- Redo restores the explicit position: splice adjustment alone would
-- leave the mark at col 6.
eq({ 0, 1 }, get_extmark_by_id(ns, marks[1]))
end)
it('undo and redo of marks deleted during edits', function()
-- test extmark_adjust
feed('A<cr>12345<esc>')