vim: Fix dot repeat ignoring recorded register (#50753)

Finn Eitreim and dino created

When a command used an explicit register (e.g. `"_dd` or `"add`), the
subsequent dot repeat (`.`) was ignoring that register and using the
default instead.

Store the register at recording start in `recording_register_for_dot`,
persist it to `recorded_register_for_dot` when recording stops, and
restore it in `Vim::repeat` when no explicit register is supplied for
`.`. An explicit register on `.` (e.g. `"b.`) still takes precedence.

This commit also updates the dot-repeat logic to closely follow Neovim's
when using numbered registers, where each dot repeat increments the
register. For example, after using `"1p`, using `.` will repeat the
command using `"2p`, `"3p`, etc.

Closes #49867

Release Notes:

- Fixed vim's repeat . to preserve the register the recorded command
  used
- Updated vim's repeat . to increment the recorded register when using
  numbered registers

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/vim/src/normal.rs                                  |   7 
crates/vim/src/normal/paste.rs                            |  16 
crates/vim/src/normal/repeat.rs                           | 219 +++++++++
crates/vim/src/state.rs                                   |  10 
crates/vim/src/vim.rs                                     |  16 
crates/vim/test_data/test_dot_repeat_registers.json       | 125 +++++
crates/vim/test_data/test_dot_repeat_registers_paste.json | 105 ++++
7 files changed, 490 insertions(+), 8 deletions(-)

Detailed changes

crates/vim/src/normal.rs 🔗

@@ -949,17 +949,16 @@ impl Vim {
             let current_line = point.row;
             let percentage = current_line as f32 / lines as f32;
             let modified = if buffer.is_dirty() { " [modified]" } else { "" };
-            vim.status_label = Some(
+            vim.set_status_label(
                 format!(
                     "{}{} {} lines --{:.0}%--",
                     filename,
                     modified,
                     lines,
                     percentage * 100.0,
-                )
-                .into(),
+                ),
+                cx,
             );
-            cx.notify();
         });
     }
 

crates/vim/src/normal/paste.rs 🔗

@@ -50,6 +50,10 @@ impl Vim {
                 })
                 .filter(|reg| !reg.text.is_empty())
                 else {
+                    vim.set_status_label(
+                        format!("Nothing in register {}", selected_register.unwrap_or('"')),
+                        cx,
+                    );
                     return;
                 };
                 let clipboard_selections = clipboard_selections
@@ -249,7 +253,7 @@ impl Vim {
     ) {
         self.stop_recording(cx);
         let selected_register = self.selected_register.take();
-        self.update_editor(cx, |_, editor, cx| {
+        self.update_editor(cx, |vim, editor, cx| {
             editor.transact(window, cx, |editor, window, cx| {
                 editor.set_clip_at_line_ends(false, cx);
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
@@ -262,6 +266,10 @@ impl Vim {
                     globals.read_register(selected_register, Some(editor), cx)
                 })
                 .filter(|reg| !reg.text.is_empty()) else {
+                    vim.set_status_label(
+                        format!("Nothing in register {}", selected_register.unwrap_or('"')),
+                        cx,
+                    );
                     return;
                 };
                 editor.insert(&text, window, cx);
@@ -286,7 +294,7 @@ impl Vim {
     ) {
         self.stop_recording(cx);
         let selected_register = self.selected_register.take();
-        self.update_editor(cx, |_, editor, cx| {
+        self.update_editor(cx, |vim, editor, cx| {
             let text_layout_details = editor.text_layout_details(window, cx);
             editor.transact(window, cx, |editor, window, cx| {
                 editor.set_clip_at_line_ends(false, cx);
@@ -306,6 +314,10 @@ impl Vim {
                     globals.read_register(selected_register, Some(editor), cx)
                 })
                 .filter(|reg| !reg.text.is_empty()) else {
+                    vim.set_status_label(
+                        format!("Nothing in register {}", selected_register.unwrap_or('"')),
+                        cx,
+                    );
                     return;
                 };
                 editor.insert(&text, window, cx);

crates/vim/src/normal/repeat.rs 🔗

@@ -291,6 +291,24 @@ impl Vim {
         }) else {
             return;
         };
+
+        // Dot repeat always uses the recorded register, ignoring any "X
+        // override, as the register is an inherent part of the recorded action.
+        // For numbered registers, Neovim increments on each dot repeat so after
+        // using `"1p`, using `.` will equate to `"2p", the next `.` to `"3p`,
+        // etc..
+        let recorded_register = cx.global::<VimGlobals>().recorded_register_for_dot;
+        let next_register = recorded_register
+            .filter(|c| matches!(c, '1'..='9'))
+            .map(|c| ((c as u8 + 1).min(b'9')) as char);
+
+        self.selected_register = next_register.or(recorded_register);
+        if let Some(next_register) = next_register {
+            Vim::update_globals(cx, |globals, _| {
+                globals.recorded_register_for_dot = Some(next_register)
+            })
+        };
+
         if mode != Some(self.mode) {
             if let Some(mode) = mode {
                 self.switch_mode(mode, false, window, cx)
@@ -441,6 +459,207 @@ mod test {
         cx.shared_state().await.assert_eq("THE QUICK ˇbrown fox");
     }
 
+    #[gpui::test]
+    async fn test_dot_repeat_registers_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // basic paste repeat uses the unnamed register
+        cx.set_shared_state("ˇhello\n").await;
+        cx.simulate_shared_keystrokes("y y p").await;
+        cx.shared_state().await.assert_eq("hello\nˇhello\n");
+        cx.simulate_shared_keystrokes(".").await;
+        cx.shared_state().await.assert_eq("hello\nhello\nˇhello\n");
+
+        // "_ (blackhole) is recorded and replayed, so the pasted text is still
+        // the original yanked line.
+        cx.set_shared_state(indoc! {"
+            ˇone
+            two
+            three
+            four
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("y y j \" _ d d . p").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            one
+            four
+            ˇone
+        "});
+
+        // the recorded register is replayed, not whatever is in the unnamed register
+        cx.set_shared_state(indoc! {"
+            ˇone
+            two
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("y y j \" a y y \" a p .")
+            .await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            one
+            two
+            two
+            ˇtwo
+        "});
+
+        // `"X.` ignores the override and always uses the recorded register.
+        // Both `dd` calls go into register `a`, so register `b` is empty and
+        // `"bp` pastes nothing.
+        cx.set_shared_state(indoc! {"
+            ˇone
+            two
+            three
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("\" a d d \" b .").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            ˇthree
+        "});
+        cx.simulate_shared_keystrokes("\" a p \" b p").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            three
+            ˇtwo
+        "});
+
+        // numbered registers cycle on each dot repeat: "1p . . uses registers 2, 3, …
+        // Since the cycling behavior caps at register 9, the first line to be
+        // deleted `1`, is no longer in any of the registers.
+        cx.set_shared_state(indoc! {"
+            ˇone
+            two
+            three
+            four
+            five
+            six
+            seven
+            eight
+            nine
+            ten
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("d d . . . . . . . . .").await;
+        cx.shared_state().await.assert_eq(indoc! {"ˇ"});
+        cx.simulate_shared_keystrokes("\" 1 p . . . . . . . . .")
+            .await;
+        cx.shared_state().await.assert_eq(indoc! {"
+
+            ten
+            nine
+            eight
+            seven
+            six
+            five
+            four
+            three
+            two
+            ˇtwo"});
+
+        // unnamed register repeat: dd records None, so . pastes the same
+        // deleted text
+        cx.set_shared_state(indoc! {"
+            ˇone
+            two
+            three
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("d d p .").await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            two
+            one
+            ˇone
+            three
+        "});
+
+        // After `"1p` cycles to `2`, using `"ap` resets recorded_register to `a`,
+        // so the next `.` uses `a` and not 3.
+        cx.set_shared_state(indoc! {"
+            one
+            two
+            ˇthree
+        "})
+            .await;
+        cx.simulate_shared_keystrokes("\" 2 y y k k \" a y y j \" 1 y y k \" 1 p . \" a p .")
+            .await;
+        cx.shared_state().await.assert_eq(indoc! {"
+            one
+            two
+            three
+            one
+            ˇone
+            two
+            three
+        "});
+    }
+
+    // This needs to be a separate test from `test_dot_repeat_registers_paste`
+    // as Neovim doesn't have support for using registers in replace operations
+    // by default.
+    #[gpui::test]
+    async fn test_dot_repeat_registers_replace(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state(
+            indoc! {"
+            line ˇone
+            line two
+            line three
+        "},
+            Mode::Normal,
+        );
+
+        // 1. Yank `one` into register `a`
+        // 2. Move down and yank `two` into the default register
+        // 3. Replace `two` with the contents of register `a`
+        cx.simulate_keystrokes("\" a y w j y w \" a g R w");
+        cx.assert_state(
+            indoc! {"
+            line one
+            line onˇe
+            line three
+        "},
+            Mode::Normal,
+        );
+
+        // 1. Move down to `three`
+        // 2. Repeat the replace operation
+        cx.simulate_keystrokes("j .");
+        cx.assert_state(
+            indoc! {"
+            line one
+            line one
+            line onˇe
+        "},
+            Mode::Normal,
+        );
+
+        // Similar test, but this time using numbered registers, as those should
+        // automatically increase on successive uses of `.` .
+        cx.set_state(
+            indoc! {"
+            line ˇone
+            line two
+            line three
+            line four
+        "},
+            Mode::Normal,
+        );
+
+        // 1. Yank `one` into register `1`
+        // 2. Yank `two` into register `2`
+        // 3. Move down and yank `three` into the default register
+        // 4. Replace `three` with the contents of register `1`
+        // 5. Move down and repeat
+        cx.simulate_keystrokes("\" 1 y w j \" 2 y w j y w \" 1 g R w j .");
+        cx.assert_state(
+            indoc! {"
+            line one
+            line two
+            line one
+            line twˇo
+        "},
+            Mode::Normal,
+        );
+    }
+
     #[gpui::test]
     async fn test_repeat_ime(cx: &mut gpui::TestAppContext) {
         let mut cx = VimTestContext::new(cx, true).await;

crates/vim/src/state.rs 🔗

@@ -232,7 +232,15 @@ pub struct VimGlobals {
     pub recorded_actions: Vec<ReplayableAction>,
     pub recorded_selection: RecordedSelection,
 
+    /// The register being written to by the active `q{register}` macro
+    /// recording.
     pub recording_register: Option<char>,
+    /// The register that was selected at the start of the current
+    /// dot-recording, for example, `"ap`.
+    pub recording_register_for_dot: Option<char>,
+    /// The register from the last completed dot-recording. Used when replaying
+    /// with `.`.
+    pub recorded_register_for_dot: Option<char>,
     pub last_recorded_register: Option<char>,
     pub last_replayed_register: Option<char>,
     pub replayer: Option<Replayer>,
@@ -919,6 +927,7 @@ impl VimGlobals {
                 self.dot_recording = false;
                 self.recorded_actions = std::mem::take(&mut self.recording_actions);
                 self.recorded_count = self.recording_count.take();
+                self.recorded_register_for_dot = self.recording_register_for_dot.take();
                 self.stop_recording_after_next_action = false;
             }
         }
@@ -946,6 +955,7 @@ impl VimGlobals {
                 self.dot_recording = false;
                 self.recorded_actions = std::mem::take(&mut self.recording_actions);
                 self.recorded_count = self.recording_count.take();
+                self.recorded_register_for_dot = self.recording_register_for_dot.take();
                 self.stop_recording_after_next_action = false;
             }
         }

crates/vim/src/vim.rs 🔗

@@ -996,7 +996,14 @@ impl Vim {
         cx: &mut Context<Vim>,
         f: impl Fn(&mut Vim, &A, &mut Window, &mut Context<Vim>) + 'static,
     ) {
-        let subscription = editor.register_action(cx.listener(f));
+        let subscription = editor.register_action(cx.listener(move |vim, action, window, cx| {
+            if !Vim::globals(cx).dot_replaying {
+                if vim.status_label.take().is_some() {
+                    cx.notify();
+                }
+            }
+            f(vim, action, window, cx);
+        }));
         cx.on_release(|_, _| drop(subscription)).detach();
     }
 
@@ -1155,7 +1162,6 @@ impl Vim {
         let last_mode = self.mode;
         let prior_mode = self.last_mode;
         let prior_tx = self.current_tx;
-        self.status_label.take();
         self.last_mode = last_mode;
         self.mode = mode;
         self.operator_stack.clear();
@@ -1586,6 +1592,7 @@ impl Vim {
                 globals.dot_recording = true;
                 globals.recording_actions = Default::default();
                 globals.recording_count = None;
+                globals.recording_register_for_dot = self.selected_register;
 
                 let selections = self.editor().map(|editor| {
                     editor.update(cx, |editor, cx| {
@@ -2092,6 +2099,11 @@ impl Vim {
         editor.selections.set_line_mode(state.line_mode);
         editor.set_edit_predictions_hidden_for_vim_mode(state.hide_edit_predictions, window, cx);
     }
+
+    fn set_status_label(&mut self, label: impl Into<SharedString>, cx: &mut Context<Editor>) {
+        self.status_label = Some(label.into());
+        cx.notify();
+    }
 }
 
 struct VimEditorSettingsState {

crates/vim/test_data/test_dot_repeat_registers.json 🔗

@@ -0,0 +1,125 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"p"}
+{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}}
+{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"_"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"."}
+{"Key":"p"}
+{"Get":{"state":"tocopytext\n3\nˇtocopytext\n","mode":"Normal"}}
+{"Put":{"state":"ˇtocopytext\n1\n2\n3\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"tocopytext\n1\n2\n3\nˇ1\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\n"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"\""}
+{"Key":"b"}
+{"Key":"."}
+{"Get":{"state":"ˇthree\n","mode":"Normal"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"\""}
+{"Key":"b"}
+{"Key":"p"}
+{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}}
+{"Put":{"state":"ˇline one\nline two\n"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"\""}
+{"Key":"b"}
+{"Key":"."}
+{"Get":{"state":"line one\nline two\nline one\nline one\nˇline one\n","mode":"Normal"}}
+{"Put":{"state":"ˇ1\n2\n3\n4\n5\n6\n7\n8\n9\n"}}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Get":{"state":"ˇ","mode":"Normal"}}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Get":{"state":"\n9\n8\n7\n6\n5\n4\n3\n2\n1\nˇ1","mode":"Normal"}}
+{"Put":{"state":"ˇa\nb\nc\n"}}
+{"Key":"\""}
+{"Key":"9"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"\""}
+{"Key":"9"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"."}
+{"Get":{"state":"a\na\na\nˇa\nb\nc\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\n"}}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\n"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"k"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"one\ntwo\n9\none\nˇone\ntwo\nthree\n","mode":"Normal"}}

crates/vim/test_data/test_dot_repeat_registers_paste.json 🔗

@@ -0,0 +1,105 @@
+{"Put":{"state":"ˇhello\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"p"}
+{"Get":{"state":"hello\nˇhello\n","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"hello\nhello\nˇhello\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\nfour\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"_"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"."}
+{"Key":"p"}
+{"Get":{"state":"one\nfour\nˇone\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\n"}}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"one\ntwo\ntwo\nˇtwo\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\n"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"\""}
+{"Key":"b"}
+{"Key":"."}
+{"Get":{"state":"ˇthree\n","mode":"Normal"}}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"\""}
+{"Key":"b"}
+{"Key":"p"}
+{"Get":{"state":"three\nˇtwo\n","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\nfour\nfive\nsix\nseven\neight\nnine\nten\n"}}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Get":{"state":"ˇ","mode":"Normal"}}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Key":"."}
+{"Get":{"state":"\nten\nnine\neight\nseven\nsix\nfive\nfour\nthree\ntwo\nˇtwo","mode":"Normal"}}
+{"Put":{"state":"ˇone\ntwo\nthree\n"}}
+{"Key":"d"}
+{"Key":"d"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"two\none\nˇone\nthree\n","mode":"Normal"}}
+{"Put":{"state":"one\ntwo\nˇthree\n"}}
+{"Key":"\""}
+{"Key":"2"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"k"}
+{"Key":"k"}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"j"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"y"}
+{"Key":"y"}
+{"Key":"k"}
+{"Key":"\""}
+{"Key":"1"}
+{"Key":"p"}
+{"Key":"."}
+{"Key":"\""}
+{"Key":"a"}
+{"Key":"p"}
+{"Key":"."}
+{"Get":{"state":"one\ntwo\nthree\none\nˇone\ntwo\nthree\n","mode":"Normal"}}