vim: Fix cgn backwards movement when there is no matches (#10237)

joaquin30 and Conrad Irwin created

Release Notes:

- Fixed `cgn` backwards movement problem in #9982

There are two issues:

- When there are no more matches, the next repetition still moves the
cursor to the left. After that, the recording is cleared. For this I
simply move the cursor to the right, but it doesn't work when the cursor
is at the end of the line.
- If `cgn` is used when there are no matches, it cleans the previous
recorded actions. Maybe there should be a way to revert the recording.
This also happens when using `c` and `esc`

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

crates/search/src/buffer_search.rs         |  5 +++
crates/vim/src/normal/repeat.rs            |  7 ++++
crates/vim/src/vim.rs                      |  5 +++
crates/vim/src/visual.rs                   | 37 ++++++++++++++++++++++-
crates/vim/test_data/test_cgn_nomatch.json | 28 ++++++++++++++++++
5 files changed, 79 insertions(+), 3 deletions(-)

Detailed changes

crates/search/src/buffer_search.rs 🔗

@@ -1100,6 +1100,11 @@ impl BufferSearchBar {
             }
         }
     }
+
+    pub fn match_exists(&mut self, cx: &mut ViewContext<Self>) -> bool {
+        self.update_match_index(cx);
+        self.active_match_index.is_some()
+    }
 }
 
 #[cfg(test)]

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

@@ -172,6 +172,13 @@ pub(crate) fn repeat(cx: &mut WindowContext, from_insert_mode: bool) {
             editor.show_local_selections = false;
         })?;
         for action in actions {
+            if !matches!(
+                cx.update(|cx| Vim::read(cx).workspace_state.replaying),
+                Ok(true)
+            ) {
+                break;
+            }
+
             match action {
                 ReplayableAction::Action(action) => {
                     if should_replay(&action) {

crates/vim/src/vim.rs 🔗

@@ -341,6 +341,10 @@ impl Vim {
         }
     }
 
+    pub fn stop_replaying(&mut self) {
+        self.workspace_state.replaying = false;
+    }
+
     /// When finishing an action that modifies the buffer, stop recording.
     /// as you usually call this within a keystroke handler we also ensure that
     /// the current action is recorded.
@@ -499,6 +503,7 @@ impl Vim {
         self.sync_vim_settings(cx);
         popped_operator
     }
+
     fn clear_operator(&mut self, cx: &mut WindowContext) {
         self.take_count(cx);
         self.update_state(|state| state.operator_stack.clear());

crates/vim/src/visual.rs 🔗

@@ -509,7 +509,6 @@ pub fn select_match(
     vim.update_active_editor(cx, |_, editor, _| {
         editor.set_collapse_matches(false);
     });
-
     if vim_is_normal {
         pane.update(cx, |pane, cx| {
             if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
@@ -521,21 +520,27 @@ pub fn select_match(
             }
         });
     }
-
     vim.update_active_editor(cx, |_, editor, cx| {
         let latest = editor.selections.newest::<usize>(cx);
         start_selection = latest.start;
         end_selection = latest.end;
     });
 
+    let mut match_exists = false;
     pane.update(cx, |pane, cx| {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
             search_bar.update(cx, |search_bar, cx| {
                 search_bar.update_match_index(cx);
                 search_bar.select_match(direction, count, cx);
+                match_exists = search_bar.match_exists(cx);
             });
         }
     });
+    if !match_exists {
+        vim.clear_operator(cx);
+        vim.stop_replaying();
+        return;
+    }
     vim.update_active_editor(cx, |_, editor, cx| {
         let latest = editor.selections.newest::<usize>(cx);
         if vim_is_normal {
@@ -553,6 +558,7 @@ pub fn select_match(
         });
         editor.set_collapse_matches(true);
     });
+
     match vim.maybe_pop_operator() {
         Some(Operator::Change) => substitute(vim, None, false, cx),
         Some(Operator::Delete) => {
@@ -561,7 +567,7 @@ pub fn select_match(
         }
         Some(Operator::Yank) => yank(vim, cx),
         _ => {} // Ignoring other operators
-    };
+    }
 }
 
 #[cfg(test)]
@@ -1195,4 +1201,29 @@ mod test {
         cx.simulate_shared_keystrokes(["."]).await;
         cx.assert_shared_state("aa x ˇx aa aa").await;
     }
+
+    #[gpui::test]
+    async fn test_cgn_nomatch(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "b", "b", "enter"])
+            .await;
+        cx.assert_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["c", "g", "n", "x", "escape"])
+            .await;
+        cx.assert_shared_state("aaˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        cx.assert_shared_state("aaˇa aa aa aa").await;
+
+        cx.set_shared_state("aaˇ bb aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "b", "b", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇbb aa aa aa").await;
+        cx.simulate_shared_keystrokes(["c", "g", "n", "x", "escape"])
+            .await;
+        cx.assert_shared_state("aa ˇx aa aa aa").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        cx.assert_shared_state("aa ˇx aa aa aa").await;
+    }
 }

crates/vim/test_data/test_cgn_nomatch.json 🔗

@@ -0,0 +1,28 @@
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"enter"}
+{"Get":{"state":"aaˇ aa aa aa aa","mode":"Normal"}}
+{"Key":"c"}
+{"Key":"g"}
+{"Key":"n"}
+{"Key":"x"}
+{"Key":"escape"}
+{"Get":{"state":"aaˇaa aa aa aa","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"aaˇa aa aa aa","mode":"Normal"}}
+{"Put":{"state":"aaˇ bb aa aa aa"}}
+{"Key":"/"}
+{"Key":"b"}
+{"Key":"b"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇbb aa aa aa","mode":"Normal"}}
+{"Key":"c"}
+{"Key":"g"}
+{"Key":"n"}
+{"Key":"x"}
+{"Key":"escape"}
+{"Get":{"state":"aa ˇx aa aa aa","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"aa ˇx aa aa aa","mode":"Normal"}}