vim: add */#/g*/g# for jumping to next word

Conrad Irwin created

As in vim, this toggles the normal search experience.

Change summary

assets/keymaps/vim.json            |  16 ++++
crates/search/src/buffer_search.rs |  28 +++++++
crates/vim/src/normal.rs           |   4 +
crates/vim/src/normal/search.rs    | 108 ++++++++++++++++++++++++++++++++
4 files changed, 152 insertions(+), 4 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -101,6 +101,8 @@
         "vim::SwitchMode",
         "Normal"
       ],
+      "*": "vim::MoveToNext",
+      "#": "vim::MoveToPrev",
       "0": "vim::StartOfLine", // When no number operator present, use start of line motion
       "1": [
         "vim::Number",
@@ -240,7 +242,19 @@
         "vim::SwitchMode",
         "Normal"
       ],
-      "d": "editor::GoToDefinition"
+      "d": "editor::GoToDefinition",
+      "*": [
+        "vim::MoveToNext",
+        {
+          "partialWord": true
+        }
+      ],
+      "#": [
+        "vim::MoveToPrev",
+        {
+          "partialWord": true
+        }
+      ]
     }
   },
   {

crates/search/src/buffer_search.rs 🔗

@@ -65,6 +65,7 @@ pub struct BufferSearchBar {
     pub query_editor: ViewHandle<Editor>,
     active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
     active_match_index: Option<usize>,
+    pending_match_direction: Option<Direction>,
     active_searchable_item_subscription: Option<Subscription>,
     seachable_items_with_matches:
         HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
@@ -252,6 +253,7 @@ impl BufferSearchBar {
             default_options: SearchOptions::NONE,
             search_options: SearchOptions::NONE,
             pending_search: None,
+            pending_match_direction: None,
             query_contains_error: false,
             dismissed: true,
         }
@@ -285,10 +287,10 @@ impl BufferSearchBar {
         &mut self,
         focus: bool,
         suggest_query: bool,
-        search_option: SearchOptions,
+        search_options: SearchOptions,
         cx: &mut ViewContext<Self>,
     ) -> bool {
-        self.search_options = search_option;
+        self.search_options = search_options;
         let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
             SearchableItemHandle::boxed_clone(searchable_item.as_ref())
         } else {
@@ -486,6 +488,17 @@ impl BufferSearchBar {
         self.select_match(Direction::Prev, cx);
     }
 
+    pub fn select_word_under_cursor(
+        &mut self,
+        direction: Direction,
+        options: SearchOptions,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.active_match_index = None;
+        self.pending_match_direction = Some(direction);
+        self.show_with_options(false, true, options, cx);
+    }
+
     pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
         if let Some(index) = self.active_match_index {
             if let Some(searchable_item) = self.active_searchable_item.as_ref() {
@@ -567,6 +580,7 @@ impl BufferSearchBar {
         if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
             if query.is_empty() {
                 self.active_match_index.take();
+                self.pending_match_direction.take();
                 active_searchable_item.clear_matches(cx);
             } else {
                 let query = if self.search_options.contains(SearchOptions::REGEX) {
@@ -614,7 +628,15 @@ impl BufferSearchBar {
                                     .unwrap();
                                 active_searchable_item.update_matches(matches, cx);
                                 if select_closest_match {
-                                    if let Some(match_ix) = this.active_match_index {
+                                    if let Some(mut match_ix) = this.active_match_index {
+                                        if let Some(direction) = this.pending_match_direction.take()
+                                        {
+                                            match_ix += match direction {
+                                                Direction::Next => 1,
+                                                Direction::Prev => matches.len() - 1,
+                                            };
+                                            match_ix = match_ix % matches.len();
+                                        }
                                         active_searchable_item
                                             .activate_match(match_ix, matches, cx);
                                     }

crates/vim/src/normal.rs 🔗

@@ -2,6 +2,7 @@ mod case;
 mod change;
 mod delete;
 mod scroll;
+mod search;
 mod substitute;
 mod yank;
 
@@ -27,6 +28,7 @@ use self::{
     case::change_case,
     change::{change_motion, change_object},
     delete::{delete_motion, delete_object},
+    search::{move_to_next, move_to_prev},
     substitute::substitute,
     yank::{yank_motion, yank_object},
 };
@@ -57,6 +59,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(insert_line_above);
     cx.add_action(insert_line_below);
     cx.add_action(change_case);
+    cx.add_action(move_to_next);
+    cx.add_action(move_to_prev);
     cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
         Vim::update(cx, |vim, cx| {
             let times = vim.pop_number_operator(cx);

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

@@ -0,0 +1,108 @@
+use gpui::{impl_actions, ViewContext};
+use search::{BufferSearchBar, SearchOptions};
+use serde_derive::Deserialize;
+use workspace::{searchable::Direction, Workspace};
+
+use crate::Vim;
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct MoveToNext {
+    #[serde(default)]
+    partial_word: bool,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+pub(crate) struct MoveToPrev {
+    #[serde(default)]
+    partial_word: bool,
+}
+
+impl_actions!(vim, [MoveToNext, MoveToPrev]);
+
+pub(crate) fn move_to_next(
+    workspace: &mut Workspace,
+    action: &MoveToNext,
+    cx: &mut ViewContext<Workspace>,
+) {
+    move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
+}
+
+pub(crate) fn move_to_prev(
+    workspace: &mut Workspace,
+    action: &MoveToPrev,
+    cx: &mut ViewContext<Workspace>,
+) {
+    move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
+}
+
+fn move_to_internal(
+    workspace: &mut Workspace,
+    direction: Direction,
+    whole_word: bool,
+    cx: &mut ViewContext<Workspace>,
+) {
+    Vim::update(cx, |vim, cx| {
+        let pane = workspace.active_pane().clone();
+        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| {
+                    let mut options = SearchOptions::CASE_SENSITIVE;
+                    options.set(SearchOptions::WHOLE_WORD, whole_word);
+                    search_bar.select_word_under_cursor(direction, options, cx);
+                });
+            }
+        });
+        vim.clear_operator(cx);
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use search::BufferSearchBar;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_move_to_next(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        let search_bar = cx.workspace(|workspace, cx| {
+            workspace
+                .active_pane()
+                .read(cx)
+                .toolbar()
+                .read(cx)
+                .item_of_type::<BufferSearchBar>()
+                .expect("Buffer search bar should be deployed")
+        });
+        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["*"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["*"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["#"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["#"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["g", "*"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["n"]);
+        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["g", "#"]);
+        search_bar.next_notification(&cx).await;
+        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
+    }
+}