feat(editor): undo restores cursor position #41520

Problem:
Undo places the cursor wherever the cursor happened to sit at "save
time" (`uh_cursor` is sampled lazily on the first change).
Examples:
- `i` preserves, but `a` does not
- `diw`, `atest<Esc>`, `d^` abandon the original position
- `D`, `o` restore it (by accident).

Solution:
`composite` tracks the pending atom (and its `origin`) across frames.
A `stuffed` continuation frame inherits the `origin` + prepped redo.
Store `origin` info in the undo header, so undo can restore it.

- Not for a mid-command undo break (i_CTRL-G_u).
- Undoing a mapping restores where the mapping started (which
  technically may be different than where the "edit" started).
This commit is contained in:
Justin M. Keyes
2026-08-27 16:12:11 -04:00
committed by GitHub
parent 617f9e628c
commit 7e2e3f8c25
16 changed files with 185 additions and 137 deletions

View File

@@ -305,6 +305,11 @@ EDITOR
real-time, instead of only on |FocusGained| or |:checktime|.
• During |complete()|-triggered completion, CTRL-N and CTRL-P are now subject
to insert-mode mappings.
• |undo| restores the cursor to its original position ("diw", "atest<Esc>",
"d^", …), instead of jumping to the start of the changed text.
• |undo| no longer restores the old (wrong) position of a mark that you moved
(|m|, |:mark|) later. The mark shifts with the text it was moved to, the
same as a mark the change never touched.
• Multibyte characters, translated by 'langmap', now invoke correct
mappings.
Example: >vim
@@ -526,9 +531,6 @@ These existing features changed their behavior.
`opts.plain=true` and now does not expand leading tildes ("~") in addition
to environment variables ("expand_env" is still accepted, for backwards
compatibility).
• |undo| no longer restores the old (wrong) position of a mark that you moved
(|m|, |:mark|) later. The mark shifts with the text it was moved to, the
same as a mark the change never touched.
• |:helptags| finds help tags with the "vimdoc" |treesitter| parser (and thus
requires it to be installed).
• `:helptags ALL` reports |E152| for "doc" directories it cannot write,

View File

@@ -103,11 +103,10 @@ operations provided by plugins, without the need for "announcement" via
vim-repeat or similar. >lua
local last ---@type vim.event.cmdatom.data?
local maxseq = {} ---@type table<integer, integer>
vim.api.nvim_create_autocmd('CmdAtom', {
callback = function(ev)
local is_redo_or_undo = ev.data.changed and (ev.data.undoseq or 0) <= (maxseq[ev.buf] or 0)
maxseq[ev.buf] = vim.fn.undotree(ev.buf).seq_last
local is_redo_or_undo = ev.data.changed and (ev.data.undoseq or 0) <= (vim.b[ev.buf].maxseq or 0)
vim.b[ev.buf].maxseq = vim.fn.undotree(ev.buf).seq_last
if ev.data.changed and not is_redo_or_undo and ev.data.lhs ~= '.' then
last = ev.data
end
@@ -168,27 +167,17 @@ without a count replays the macro. >lua
end)
<
*restore-undo-cursor*
Example: Restore cursor position after undo. Works for |u|, "3u", |CTRL-R|,
":undo N", |g-| and any mapping: >lua
By default, |undo| returns the cursor to its original position. If you don't
like that, you can use this snippet to get Vim's behavior instead: >lua
local seen = {} ---@type table<integer, table>
vim.api.nvim_create_autocmd('CmdAtom', {
callback = function(ev)
local seq = ev.data.undoseq
if not seq then
return
-- Undo/redo: the buffer changed to an already-seen undo state.
local undid = ev.data.changed and (ev.data.undoseq or 0) <= (vim.b[ev.buf].maxseq or 0)
vim.b[ev.buf].maxseq = vim.fn.undotree(ev.buf).seq_last
if undid then
vim.cmd('normal! `[') -- Start of the changed text.
end
local s = seen[ev.buf] or {}
seen[ev.buf] = s
-- Note: g- :earlier may cross undo-tree branches, "best effort" in that case.
if s.prev and seq < s.prev and s[seq + 1] then
vim.api.nvim_win_set_cursor(0, s[seq + 1]) -- Undo: first abandoned state.
elseif s.prev and seq > s.prev and s[seq] then
vim.api.nvim_win_set_cursor(0, s[seq]) -- Redo: revisiting a known seq.
elseif ev.data.changed and not s[seq] then
s[seq] = ev.data.pos -- New edit.
end
s.prev = seq
end,
})
<

View File

@@ -14,7 +14,8 @@ The basics are explained in section |02.5| of the user manual.
1. Undo and redo commands *undo-commands*
<Undo> or *undo* *<Undo>* *u*
u Undo [count] changes.
u Undo [count] changes. The cursor returns to its
original position.
*:u* *:un* *:undo*
:u[ndo] Undo one change.

View File

@@ -724,14 +724,6 @@ void redo_append_char(int c)
}
}
// Append a number to the redo buffer.
void redo_append_num(int n)
{
if (!block_redo) {
kv_printf(redobuff.cur.body, "%d", n);
}
}
/// Appends string `s` (must be typeahead encoding) to the stuff buffer.
void stuffReadbuff(const char *s)
FUNC_ATTR_NONNULL_ALL

View File

@@ -73,11 +73,20 @@ static uint64_t atom_captures = 0;
static bool atom_suppressed = false;
/// Mapping edited the buffer, or its insert-session cascaded: cascades as one unit, incl. motions.
static bool map_edit = false;
/// Ticks per command frame (CmdFrame.id).
/// Incremented per command frame (CmdFrame.id).
static uint64_t frame_id = 0;
/// The executing command's frame; its `parent` chain spans nested `normal_execute()`.
static CmdFrame *cur_frame = NULL;
/// Accumulating composite atom: an executing mapping/macro. See `vatom` for Visual composite.
/// The pending atom, while it spans CmdFrames (a mapping's commands, a stuffed continuation, an
/// operator awaiting its motion). See `vatom` for Visual composite.
///
/// Also used by undo, to restore cursor position.
static struct {
bool open; ///< True from the atom's first toplevel frame until it resolves.
bool stuffed; ///< Last frame had stuffed keys pending (next frame continues the atom).
bool redo; ///< The continuation completes the prepped redo ("!ip" stuffs ":.,.+1!").
CmdOrigin origin; ///< State at the atom's start.
CmdAtomVec atoms; ///< Subatoms of the mapping/macro.
char *lhs; ///< Label: mapping LHS or macro "@x" (NULL: not collecting).
bool queued; ///< A cascadable atom was queued (g_atoms) while collecting.
@@ -86,12 +95,8 @@ static struct {
bool macro; ///< Macro execution: captured as an "@x"-labeled atom.
uint64_t frame; ///< CmdFrame already executing when a lookahead resolved this mapping
///< ("f(" + mapped key in one batch). 0: none.
CmdOrigin origin; ///< State at start.
} composite;
/// The executing command's frame; its `parent` chain spans nested `normal_execute()`.
static CmdFrame *cur_frame = NULL;
/// State of a Visual composite atom.
typedef enum {
kVatomNone = 0, ///< No pending Visual atom.
@@ -219,6 +224,25 @@ static int atom_origin_undoseq(CmdOrigin origin)
return bufref_valid(&origin.buf) ? origin.buf.br_buf->b_u_seq_cur : 0;
}
/// Cursor position from the pending atom's start, or else `w_cursor`.
pos_T atom_origin_pos(buf_T *buf)
{
CmdOrigin o = composite.origin;
if (composite.open && o.buf.br_buf == buf && bufref_valid(&o.buf)
&& buf_get_changedtick(buf) == o.tick) {
return o.pos;
}
return curwin->w_cursor;
}
/// Decides the "origin" state. Usually `frame->origin`, except for "stuffed" cases.
/// A "stuffed" continuation belongs to its stuffing frame (`x` stuffs `dl`: the `dl` atom's origin
/// is at `x`).
static CmdOrigin frame_origin(const CmdFrame *frame)
{
return frame->cont ? composite.origin : frame->origin;
}
/// Composes a CmdSpec into `redo_keys` format.
/// @return Allocated key sequence.
static char *atom_redo_keys(CmdSpec spec)
@@ -521,7 +545,7 @@ bool atom_composite_active(void)
return composite.lhs != NULL;
}
/// Starts a composite: accumulate a mapping/macro's subatoms.
/// Starts collecting subatoms.
static void atom_composite_start(const char *lhs, size_t len)
{
xfree(composite.lhs);
@@ -529,7 +553,6 @@ static void atom_composite_start(const char *lhs, size_t len)
composite.queued = false;
composite.lossy = false;
composite.frame = 0;
composite.origin = atom_origin();
}
/// Emits the composite atom with its collected subatoms (`CmdAtom.atoms`).
@@ -574,20 +597,23 @@ static void atom_composite_end(void)
atom_free(&atom);
}
/// Entering :terminal mode ends the composite.
void atom_term_enter(void)
{
if (!mc_replaying()) {
atom_composite_end();
}
}
/// Discards the collecting composite (its subatoms): error/interrupt voided it.
/// Discards the pending atom and its subatoms: error/interrupt voided it (but the continuation
/// keys, typeahead/stuff, were flushed).
void atom_composite_abort(void)
{
composite.macro = false;
XFREE_CLEAR(composite.lhs);
atoms_free(&composite.atoms);
composite.open = false;
}
/// Entering :terminal mode resolves (ends) the pending atom. Terminal keys are never captured.
void atom_term_enter(void)
{
if (!mc_replaying()) {
atom_composite_end();
composite.open = false;
}
}
/// True if the just-executed command is user input. Excludes re-execution of captured
@@ -796,8 +822,7 @@ void atom_typed_del(size_t len)
kv_size(typed.keys) -= MIN(len, kv_size(typed.keys));
}
/// Forgets the redo-atom: new command, or a policy exclusion.
/// Only toplevel commands track it: a nested ":normal!" must not disturb it.
/// Discards the redo-atom: new or invalid command. Only at toplevel (not nested ":normal!").
static void atom_redo_reset(void)
{
if (!atom_is_user_cmd()) {
@@ -992,7 +1017,7 @@ static bool atom_visual_end_suffix(char *suffix, const CmdSpec *spec, bool redoa
};
if (spec != NULL) {
kv_push(vatom.atoms, ((CmdAtom){ .type = kAOperator, .spec = *spec, .keys = suffix,
.origin = cur_frame->origin }));
.origin = frame_origin(cur_frame) }));
} else {
xfree(suffix);
}
@@ -1043,7 +1068,7 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank)
spec.cmd2 = operand ? NUL : cap->nchar;
spec.cmdarg = operand ? cap->nchar : NUL;
CmdAtom op_atom = atom_from_spec(kAOperator, spec);
op_atom.origin = cur_frame->origin;
op_atom.origin = frame_origin(cur_frame);
atom_stage_set(&op_atom);
}
} else if (!Visual.active || oap->motion_force) {
@@ -1098,7 +1123,8 @@ void atom_capture_op(oparg_T *oap, cmdarg_T *cap, bool redo_yank)
XFREE_CLEAR(repeat_cmdline);
}
} else if (cap->cmdchar == K_LUA) {
redo_append_num(repeat_luaref);
char buf[NUMBUFLEN];
redo_append_str(buf, snprintf(buf, sizeof(buf), "%d", repeat_luaref));
redo_append_str(S_LEN(NL_STR));
}
} else if (Visual.active && redoable && oap->motion_force == NUL) {
@@ -1143,7 +1169,7 @@ InsSession atom_ins_start(int cmd, long count, VisualIns vis, bool vblock)
// A consumed selection opens the redo body, so the atom starts where the selection did.
// Else the CmdFrame origin, from before the entry moved the cursor (a/A/…).
.origin = vis == kVInsKeys ? vatom.origin
: cur_frame != NULL ? cur_frame->origin : atom_origin(),
: cur_frame != NULL ? frame_origin(cur_frame) : atom_origin(),
};
if (vis != kVInsNone && !mc_replaying()) {
if (vis == kVInsKeys && !(atom_visual_replayable() && (vatom.state & kVatomTyped))) {
@@ -1197,11 +1223,19 @@ static void atom_ins_push(const InsSession *session, bool cascade)
atom_push_raw(cascade, &atom);
}
/// Samples the pre-command state at normal_execute() entry; atom_cmd_end() diffs against it to
/// classify the command (motion, Visual-mode transition, edit). Pushes the frame (`cur_frame`).
/// Toplevel entry: starts a new atom. Samples the pre-cmd state (`origin`); pushes the frame.
void atom_cmd_start(CmdFrame *old)
{
// A stuffed continuation frame is one cmd with its stuffing frame: `v2e".p` stuffs `c…`, `!ip`
// stuffs `:.,.+1!`. KeyStuffed=false if error/CTRL-C aborted, `composite.stuffed` is then stale.
old->cont = KeyStuffed && cur_frame == NULL && composite.open && composite.stuffed;
old->origin = atom_origin();
if (cur_frame == NULL && !composite.open) {
composite.open = true;
composite.stuffed = false;
composite.redo = false;
composite.origin = old->origin;
}
old->visual = Visual;
old->keytyped = KeyTyped;
old->captures = atom_captures;
@@ -1217,8 +1251,9 @@ void atom_cmd_start(CmdFrame *old)
old->parent = cur_frame;
cur_frame = old;
curcmd.op_global = false;
// A stuffed continuation frame keeps the redo-prep. "!ipsort<CR>" spans both frames.
if (!(KeyStuffed && curcmd.redo_frame == old->id)) {
if (old->cont && composite.redo) {
curcmd.redo_frame = old->id; // The continuation completes the prepped redo.
} else {
atom_redo_reset();
}
}
@@ -1278,7 +1313,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
// Decided once, at session start.
vatom.state = (old->keytyped || (atom_composite_active() && atom_is_user_cmd()))
? kVatomTyped : kVatomFed;
vatom.origin = old->origin;
vatom.origin = frame_origin(old);
}
// Decided by the session (not atom_capturable()), so fed selections (":normal! vjd") still
// accumulate for redo-prep. Recording/replay commands are meta (not part of the edit).
@@ -1305,11 +1340,6 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
//
// Capture: does this command own an atom?
//
if (!stuff_empty() && curcmd.redo_frame == old->id && !Visual.active && !old->visual.active) {
// The stuffed continuation completes the redo (op_filter/do_bang()), and is the next frame
// (stuff precedes typeahead). Flushed instead? atom_cmd_start() checks KeyStuffed.
curcmd.redo_frame = old->id + 1;
}
if ((vis && atom_captures == old->captures && ca->oap->op_type == OP_NOP)
|| (!Visual.active
&& !old->visual.active
@@ -1352,7 +1382,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
// did neither is a no-op (vim-surround "ysa[" whose surround char was <Esc>).
bool effect = changed || reg_max_ts(true) > old->reg_ts;
if (atom.keys != NULL && *atom.keys != NUL) {
atom.origin = old->origin;
atom.origin = frame_origin(old);
atom_push(effect, &atom);
} else {
atom_free(&atom);
@@ -1361,14 +1391,14 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
&& !(vis && unchanged)) {
// Payload typed in the cmdline ("/pat<CR>"). Emit-only. Not if pattern was not found.
CmdAtom atom = atom_from_cmdline(kAMotion, ca, ca->searchbuf);
atom.origin = old->origin;
atom.origin = frame_origin(old);
atom_push(false, &atom);
} else if (!vis && curcmd.cmdline != NULL && (ca->cmdchar == ':' || ca->cmdchar == K_COMMAND)) {
// Same for ":cnext<CR>" or "<Cmd>cnext<CR>". Never a Visual subatom.
CmdAtom atom = atom_from_cmdline(kAExcmd, ca, curcmd.cmdline);
// Payload read during the cmdline execution (`ds)` getchar() => ")").
atom_payload_append(&atom, old);
atom.origin = old->origin;
atom.origin = frame_origin(old);
atom_push(false, &atom);
} else if (replayable && (!vis || (keycls & kKeyPayload) == 0)) {
// Non-redoable command (u, zz, q=): never cascaded as an edit.
@@ -1378,7 +1408,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
spec.regname = 0;
}
CmdAtom atom = atom_from_spec(motion ? kAMotion : jump_cmd ? kAJump : kANormal, spec);
atom.origin = old->origin;
atom.origin = frame_origin(old);
atom_push(follow, &atom);
} else if ((scroll_cmd || mouse_cmd) && !atom_composite_active()) {
// Emit-only (viewport-dependent).
@@ -1388,7 +1418,7 @@ static void atom_capture_cmd(cmdarg_T *ca, CmdFrame *old)
spec.count = 0;
}
CmdAtom atom = atom_from_spec(scroll_cmd ? kAScroll : kAMouse, spec);
atom.origin = old->origin;
atom.origin = frame_origin(old);
atom_push(false, &atom);
}
if (vis && kv_size(vatom.atoms) == collected && !unchanged) {
@@ -1423,11 +1453,20 @@ void atom_cmd_end(cmdarg_T *ca, CmdFrame *old)
if (old->parent == NULL && !mc_replaying() && typebuf_typed() && stuff_empty()) {
mc_clock_edge(map_edit);
map_edit = false;
// Mapping contains its continuation. While op-pending, selection-active, or insert-will-resume
// (i_CTRL-O), composite keeps collecting: ",Dw" (":nnoremap ,D d") is one atom, `keys="dw"`.
// One atom spans its continuation: while op-pending, selection-active, or insert-will-resume
// (i_CTRL-O), it stays open. ",Dw" (":nnoremap ,D d") is one atom, `keys="dw"`.
if (ca->oap->op_type == OP_NOP && !Visual.active && restart_edit == 0) {
atom_composite_end();
composite.open = false; // Resolved.
}
}
if (old->parent == NULL) {
// Stuffed keys pending: the next frame continues the atom.
composite.stuffed = !stuff_empty();
// The continuation also completes the redo-prep.
// Except Visual: stuffing is captured in its payload.
composite.redo = composite.stuffed && curcmd.redo_frame == old->id
&& !Visual.active && !old->visual.active;
}
cur_frame = old->parent;
}

View File

@@ -17,6 +17,7 @@ extern CmdAtomVec g_atoms;
typedef struct CmdFrame CmdFrame;
struct CmdFrame {
CmdOrigin origin; ///< State at entry.
bool cont; ///< Stuffed continuation frame: the atom began earlier.
VisualState visual; ///< Visual-mode state (active/start/mode are diffed).
bool keytyped; ///< KeyTyped
uint64_t captures; ///< Capture counter.

View File

@@ -474,8 +474,9 @@ int u_savecommon(buf_T *buf, linenr_T top, linenr_T bot, linenr_T newbot, bool r
uhp->uh_walk = 0;
uhp->uh_entry = NULL;
uhp->uh_getbot_entry = NULL;
uhp->uh_cursor = curwin->w_cursor; // save cursor pos. for undo
if (virtual_active(curwin) && curwin->w_cursor.coladd > 0) {
uhp->uh_cursor = atom_origin_pos(buf); // The pre-change position: undo restores it.
if (virtual_active(curwin) && curwin->w_cursor.coladd > 0
&& equalpos(uhp->uh_cursor, curwin->w_cursor)) {
uhp->uh_cursor_vcol = getviscol();
} else {
uhp->uh_cursor_vcol = -1;
@@ -2530,31 +2531,40 @@ static void u_undoredo(bool undo, bool do_buf_event)
curhead->uh_visual = visualinfo;
}
// If the cursor is only off by one line, put it at the same position as
// before starting the change (for the "o" command).
// Otherwise the cursor should go to the first undone line.
if (curhead->uh_cursor.lnum + 1 == curwin->w_cursor.lnum
&& curwin->w_cursor.lnum > 1) {
curwin->w_cursor.lnum--;
}
if (curwin->w_cursor.lnum <= curbuf->b_ml.ml_line_count) {
if (curhead->uh_cursor.lnum == curwin->w_cursor.lnum) {
curwin->w_cursor.col = curhead->uh_cursor.col;
if (virtual_active(curwin) && curhead->uh_cursor_vcol >= 0) {
coladvance(curwin, curhead->uh_cursor_vcol);
} else {
curwin->w_cursor.coladd = 0;
}
} else {
beginline(BL_SOL | BL_FIX);
if (undo && curhead->uh_cursor.lnum >= 1
&& curhead->uh_cursor.lnum <= curbuf->b_ml.ml_line_count) {
// Undo restores the pre-change text; restore the pre-change cursor too. #5989
curwin->w_cursor = curhead->uh_cursor;
if (virtual_active(curwin) && curhead->uh_cursor_vcol >= 0) {
coladvance(curwin, curhead->uh_cursor_vcol);
}
} else {
// We get here with the current cursor line being past the end (eg
// after adding lines at the end of the file, and then undoing it).
// check_cursor() will move the cursor to the last line. Move it to
// the first column here.
curwin->w_cursor.col = 0;
curwin->w_cursor.coladd = 0;
// If the cursor is only off by one line, put it at the same position as
// before starting the change (for the "o" command).
// Otherwise the cursor should go to the first changed line.
if (curhead->uh_cursor.lnum + 1 == curwin->w_cursor.lnum
&& curwin->w_cursor.lnum > 1) {
curwin->w_cursor.lnum--;
}
if (curwin->w_cursor.lnum <= curbuf->b_ml.ml_line_count) {
if (curhead->uh_cursor.lnum == curwin->w_cursor.lnum) {
curwin->w_cursor.col = curhead->uh_cursor.col;
if (virtual_active(curwin) && curhead->uh_cursor_vcol >= 0) {
coladvance(curwin, curhead->uh_cursor_vcol);
} else {
curwin->w_cursor.coladd = 0;
}
} else {
beginline(BL_SOL | BL_FIX);
}
} else {
// We get here with the current cursor line being past the end (eg
// after adding lines at the end of the file, and then undoing it).
// check_cursor() will move the cursor to the last line. Move it to
// the first column here.
curwin->w_cursor.col = 0;
curwin->w_cursor.coladd = 0;
}
}
// Make sure the cursor is on an existing line and column.

View File

@@ -888,11 +888,12 @@ describe('API/extmarks', function()
set_extmark(ns, marks[1], 1, 2)
feed('0<c-v>k>')
check_undo_redo(ns, marks[1], 1, 2, 1, 6)
feed('<c-v>j>')
-- "gg0": the cursor after undo/redo depends on |restore-undo-cursor|.
feed('gg0<c-v>j>')
expect('\t12345\n\t12345')
check_undo_redo(ns, marks[1], 1, 6, 1, 3)
feed('<c-v>j<LT>')
feed('gg0<c-v>j<LT>')
check_undo_redo(ns, marks[1], 1, 3, 1, 6)
end)

View File

@@ -56,17 +56,9 @@ describe('put command', function()
end
local init_contents = curbuf_contents()
local init_cursorpos = fn.getcurpos()
local assert_no_change = function(exception_table, after_undo)
local assert_no_change = function()
expect(init_contents)
-- When putting the ". register forwards, undo doesn't move
-- the cursor back to where it was before.
-- This is because it uses the command character 'a' to
-- start the insert, and undo after that leaves the cursor
-- one place to the right (unless we were at the end of the
-- line when we pasted).
if not (exception_table.undo_position and after_undo) then
eq(init_cursorpos, fn.getcurpos())
end
eq(init_cursorpos, fn.getcurpos())
end
for _, test in pairs(test_variations) do
@@ -77,13 +69,12 @@ describe('put command', function()
local orig_dotstr = fn.getreg('.')
t.ok(visual_marks_zero())
-- Make sure every test starts from the same conditions
assert_no_change(test.exception_table, false)
assert_no_change()
local was_cli = test.test_action()
test.test_assertions(test.exception_table, false)
-- Check that undo twice puts us back to the original conditions
-- (i.e. puts the cursor and text back to before)
-- Undo puts the cursor and text back to before the change.
feed('u')
assert_no_change(test.exception_table, true)
assert_no_change()
-- Should not have changed the ". register
-- If we paste the ". register with a count we can't avoid
@@ -104,9 +95,6 @@ describe('put command', function()
return
end
if test.exception_table.undo_position then
fn.setpos('.', init_cursorpos)
end
if was_cli then
feed('@:')
else
@@ -781,15 +769,13 @@ describe('put command', function()
)
run_test_variations(select_down_test_defs)
-- Undo and redo of a visual block put leave the cursor in the top
-- left of the visual block area no matter where the cursor was
-- when it started.
-- "." repeat of an upward visual block put applies at the restored cursor, so the put text
-- lands elsewhere: skip the position check after redo.
local undo_redo_no = map(function(table)
local rettab = copy_def(table)
if not rettab[4] then
rettab[4] = {}
end
rettab[4].undo_position = true
rettab[4].redo_position = true
return rettab
end, normal_command_defs)
@@ -809,20 +795,16 @@ describe('put command', function()
)
describe('blockwise cursor after undo', function()
-- A bit of a hack of the reset above.
-- In the tests that selection direction doesn't matter, we
-- don't check the undo/redo position because it doesn't fit
-- the same pattern as everything else.
-- Here we fix this by directly checking the undo/redo position
-- in the test_assertions of our test definitions.
-- Undo and CTRL-R restore the pre-change cursor position, even for an upward selection
-- (the harness only covers "." redo, not CTRL-R).
local function assertion_creator(_, _)
return function(_, _)
feed('u')
-- Have to use feed('u') here to set curswant, because
-- ex_undo() doesn't do that.
eq({ 0, 1, 1, 0, 1 }, fn.getcurpos())
eq({ 0, 2, 1, 0, 1 }, fn.getcurpos())
feed('<C-r>')
eq({ 0, 1, 1, 0, 1 }, fn.getcurpos())
eq({ 0, 2, 1, 0, 1 }, fn.getcurpos())
end
end

View File

@@ -89,6 +89,31 @@ describe('u CTRL-R g- g+', function()
undo_and_redo(4, 'g-', 'g+', '1')
end)
it('u restores the pre-change cursor position #5989', function()
local api = n.api
local function undo_restores(row, col, keys, pre)
if pre then
command(pre)
end
api.nvim_buf_set_lines(0, 0, -1, true, { 'this is a test' })
api.nvim_win_set_cursor(0, { row, col })
feed(keys)
feed('u')
eq({ row, col }, api.nvim_win_get_cursor(0))
end
undo_restores(1, 5, 'diw') -- Operator moves to the word start before deleting.
undo_restores(1, 5, 'd^') -- Backwards motion.
undo_restores(1, 5, 'atest<Esc>') -- Insert entered after the cursor.
undo_restores(1, 5, 'viwd')
undo_restores(1, 2, ',x', 'nnoremap ,x wdiw') -- Mapping: where it was triggered.
-- i_CTRL-G_u breaks: each block restores to its own start.
api.nvim_win_set_cursor(0, { 1, 5 })
feed('ifoo<C-G>ubar<Esc>u')
eq({ 1, 8 }, api.nvim_win_get_cursor(0))
feed('u')
eq({ 1, 5 }, api.nvim_win_get_cursor(0))
end)
describe('undo works correctly when writing in Insert mode', function()
before_each(function()
exec([[

View File

@@ -37,7 +37,8 @@ describe('regexp with magic settings', function()
feed('x:$<cr>')
feed_command('set undolevels=100')
feed('dv?bar?<cr>')
feed('Yup:<cr>')
-- Nvim: "k" after "u": undo restores the cursor to where "dv?bar?" started (the last line).
feed('Yukp:<cr>')
feed_command('?^1?,$yank A')
-- Put @a and clean empty line

View File

@@ -543,10 +543,11 @@ describe('prompt buffer', function()
{5:-- INSERT --} |
]])
feed('<Esc>u')
-- Nvim: the cursor returns to its pre-"S" position (|restore-undo-cursor|).
screen:expect([[
cmd: tests-initial |
Command: "tests-initial" |
^cmd: hello |
cmd: hell^o |
{1:~ }|
{3:[Prompt] }|
other buffer |
@@ -561,7 +562,7 @@ describe('prompt buffer', function()
screen:expect([[
cmd: tests-initial |
Command: "tests-initial" |
c^md > hello |
cmd > hell^o |
{1:~ }|
{3:[Prompt] }|
other buffer |

View File

@@ -167,6 +167,8 @@ describe('lua: nvim_buf_attach on_lines', function()
tick = tick + 1
check_events { { 'test2', 'lines', 1, tick, 1, 1, 2, 0 } }
-- Undo restored the cursor (|restore-undo-cursor|); pin it for the relative edits below.
api.nvim_win_set_cursor(0, { 5, 0 })
feed('wix')
tick = tick + 1
check_events { { 'test2', 'lines', 1, tick, 4, 5, 5, 16 } }

View File

@@ -639,7 +639,7 @@ describe(":substitute, 'inccommand' preserves undo", function()
if case == 'split' then
screen:expect([[
Inc substitution on |
two line^s |
^two lines |
|
{1:~ }|*6
Already ...t change |
@@ -647,7 +647,7 @@ describe(":substitute, 'inccommand' preserves undo", function()
else
screen:expect([[
Inc substitution on |
two line^s |
^two lines |
|
{1:~ }|*6
Already ...t change |

View File

@@ -554,9 +554,10 @@ describe('Signs', function()
]])
exec('norm 2Gdd')
exec('silent undo')
-- Nvim: undo restores the cursor to where "2Gdd" started (|restore-undo-cursor|).
screen:expect([[
{7: }1 |
{7:S1}^2 |
{7: }^1 |
{7:S1}2 |
{7: }3 |
{7: }4 |
{1:~ }|*9

View File

@@ -767,8 +767,9 @@ func Test_search_regexp()
call assert_equal([0, 2, 5, 0], getpos('.'))
call assert_equal(2, line('$'))
normal u
call assert_equal('9 foobar', getline('.'))
call assert_equal([0, 2, 6, 0], getpos('.'))
" Nvim: undo restores the cursor to where "dv?bar?" started.
call assert_equal('9 foobar', getline(2))
call assert_equal([0, 3, 1, 0], getpos('.'))
call assert_equal(3, line('$'))
set undolevels&