Vim search (#2657)

Conrad Irwin created

This PR makes searching in vim mode significantly more like vim.

I re-used search to implement "go to next instance of word under cursor"
as this is how it works in vim (for integration with other
search-related keyboard shortcuts) and to avoid having to rewrite all
the logic to be vim-specific; but that did mean I had to make some
changes to the way search works (in particular to allow different
searches to run with specific options).

Release Notes:
- vim: `<enter>` in search now puts you back in normal mode
([#1583](https://github.com/zed-industries/community/issues/1583))
- vim: `?` now works to search backwards.
- vim: jumping to definitions or search results keeps you in normal mode
([#1284](https://github.com/zed-industries/community/issues/1284))
([#1514](https://github.com/zed-industries/community/issues/1514))
- vim: `n`/`N` are now supported to jump to next/previous match after a
search
([#1583](https://github.com/zed-industries/community/issues/1583))
- vim: `*`/`#`/`g*`/`g#` are now supported to jump to the next/previous
occurrence of the word under the cursor.
- vim: `gD` now jumps to type definition

Change summary

Cargo.lock                              |   1 
assets/keymaps/vim.json                 |  37 ++
crates/ai/src/assistant.rs              |  22 +
crates/editor/src/editor.rs             |  15 +
crates/editor/src/items.rs              |  75 +++--
crates/search/Cargo.toml                |   1 
crates/search/src/buffer_search.rs      | 329 ++++++++++++++++++--------
crates/search/src/project_search.rs     |  73 ++---
crates/search/src/search.rs             |  43 ++-
crates/vim/src/normal.rs                |   2 
crates/vim/src/normal/search.rs         | 285 +++++++++++++++++++++++
crates/vim/src/state.rs                 |  18 +
crates/vim/src/test.rs                  |  46 +++
crates/vim/src/test/vim_test_context.rs |   1 
crates/vim/src/vim.rs                   |   4 
crates/workspace/src/searchable.rs      |  23 -
16 files changed, 746 insertions(+), 229 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -6469,6 +6469,7 @@ name = "search"
 version = "0.1.0"
 dependencies = [
  "anyhow",
+ "bitflags 1.3.2",
  "client",
  "collections",
  "editor",

assets/keymaps/vim.json 🔗

@@ -60,6 +60,8 @@
           "ignorePunctuation": true
         }
       ],
+      "n": "search::SelectNextMatch",
+      "shift-n": "search::SelectPrevMatch",
       "%": "vim::Matching",
       "f": [
         "vim::PushOperator",
@@ -103,6 +105,8 @@
         "vim::SwitchMode",
         "Normal"
       ],
+      "*": "vim::MoveToNext",
+      "#": "vim::MoveToPrev",
       "0": "vim::StartOfLine", // When no number operator present, use start of line motion
       "1": [
         "vim::Number",
@@ -197,10 +201,11 @@
       "p": "vim::Paste",
       "u": "editor::Undo",
       "ctrl-r": "editor::Redo",
-      "/": [
-        "buffer_search::Deploy",
+      "/": "vim::Search",
+      "?": [
+        "vim::Search",
         {
-          "focus": true
+          "backwards": true,
         }
       ],
       "ctrl-f": "vim::PageDown",
@@ -238,7 +243,20 @@
       "h": "editor::Hover",
       "t": "pane::ActivateNextItem",
       "shift-t": "pane::ActivatePrevItem",
-      "d": "editor::GoToDefinition"
+      "d": "editor::GoToDefinition",
+      "shift-d": "editor::GoToTypeDefinition",
+      "*": [
+        "vim::MoveToNext",
+        {
+          "partialWord": true
+        }
+      ],
+      "#": [
+        "vim::MoveToPrev",
+        {
+          "partialWord": true
+        }
+      ]
     }
   },
   {
@@ -310,8 +328,8 @@
         "vim::SwitchMode",
         "Normal"
       ],
-      "> >": "editor::Indent",
-      "< <": "editor::Outdent"
+      ">": "editor::Indent",
+      "<": "editor::Outdent"
     }
   },
   {
@@ -336,5 +354,12 @@
         "Normal"
       ]
     }
+  },
+  {
+    "context": "BufferSearchBar",
+    "bindings": {
+      "enter": "vim::SearchSubmit",
+      "escape": "buffer_search::Dismiss"
+    }
   }
 ]

crates/ai/src/assistant.rs 🔗

@@ -298,12 +298,22 @@ impl AssistantPanel {
     }
 
     fn deploy(&mut self, action: &search::buffer_search::Deploy, cx: &mut ViewContext<Self>) {
+        let mut propagate_action = true;
         if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
-            if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) {
-                return;
-            }
+            search_bar.update(cx, |search_bar, cx| {
+                if search_bar.show(cx) {
+                    search_bar.search_suggested(cx);
+                    if action.focus {
+                        search_bar.select_query(cx);
+                        cx.focus_self();
+                    }
+                    propagate_action = false
+                }
+            });
+        }
+        if propagate_action {
+            cx.propagate_action();
         }
-        cx.propagate_action();
     }
 
     fn handle_editor_cancel(&mut self, _: &editor::Cancel, cx: &mut ViewContext<Self>) {
@@ -320,13 +330,13 @@ impl AssistantPanel {
 
     fn select_next_match(&mut self, _: &search::SelectNextMatch, cx: &mut ViewContext<Self>) {
         if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
-            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, cx));
+            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Next, 1, cx));
         }
     }
 
     fn select_prev_match(&mut self, _: &search::SelectPrevMatch, cx: &mut ViewContext<Self>) {
         if let Some(search_bar) = self.toolbar.read(cx).item_of_type::<BufferSearchBar>() {
-            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, cx));
+            search_bar.update(cx, |bar, cx| bar.select_match(Direction::Prev, 1, cx));
         }
     }
 

crates/editor/src/editor.rs 🔗

@@ -549,6 +549,7 @@ pub struct Editor {
     pending_rename: Option<RenameState>,
     searchable: bool,
     cursor_shape: CursorShape,
+    collapse_matches: bool,
     workspace: Option<(WeakViewHandle<Workspace>, i64)>,
     keymap_context_layers: BTreeMap<TypeId, KeymapContext>,
     input_enabled: bool,
@@ -1381,6 +1382,7 @@ impl Editor {
             searchable: true,
             override_text_style: None,
             cursor_shape: Default::default(),
+            collapse_matches: false,
             workspace: None,
             keymap_context_layers: Default::default(),
             input_enabled: true,
@@ -1520,6 +1522,17 @@ impl Editor {
         cx.notify();
     }
 
+    pub fn set_collapse_matches(&mut self, collapse_matches: bool) {
+        self.collapse_matches = collapse_matches;
+    }
+
+    fn range_for_match<T: std::marker::Copy>(&self, range: &Range<T>) -> Range<T> {
+        if self.collapse_matches {
+            return range.start..range.start;
+        }
+        range.clone()
+    }
+
     pub fn set_clip_at_line_ends(&mut self, clip: bool, cx: &mut ViewContext<Self>) {
         if self.display_map.read(cx).clip_at_line_ends != clip {
             self.display_map
@@ -6261,6 +6274,7 @@ impl Editor {
                 .to_offset(definition.target.buffer.read(cx));
 
             if Some(&definition.target.buffer) == self.buffer.read(cx).as_singleton().as_ref() {
+                let range = self.range_for_match(&range);
                 self.change_selections(Some(Autoscroll::fit()), cx, |s| {
                     s.select_ranges([range]);
                 });
@@ -6277,6 +6291,7 @@ impl Editor {
                         // When selecting a definition in a different buffer, disable the nav history
                         // to avoid creating a history entry at the previous cursor location.
                         pane.update(cx, |pane, _| pane.disable_history());
+                        let range = target_editor.range_for_match(&range);
                         target_editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                             s.select_ranges([range]);
                         });

crates/editor/src/items.rs 🔗

@@ -946,21 +946,27 @@ impl SearchableItem for Editor {
         cx: &mut ViewContext<Self>,
     ) {
         self.unfold_ranges([matches[index].clone()], false, true, cx);
+        let range = self.range_for_match(&matches[index]);
         self.change_selections(Some(Autoscroll::fit()), cx, |s| {
-            s.select_ranges([matches[index].clone()])
-        });
+            s.select_ranges([range]);
+        })
     }
 
     fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>) {
         self.unfold_ranges(matches.clone(), false, false, cx);
-        self.change_selections(None, cx, |s| s.select_ranges(matches));
+        let mut ranges = Vec::new();
+        for m in &matches {
+            ranges.push(self.range_for_match(&m))
+        }
+        self.change_selections(None, cx, |s| s.select_ranges(ranges));
     }
 
     fn match_index_for_direction(
         &mut self,
         matches: &Vec<Range<Anchor>>,
-        mut current_index: usize,
+        current_index: usize,
         direction: Direction,
+        count: usize,
         cx: &mut ViewContext<Self>,
     ) -> usize {
         let buffer = self.buffer().read(cx).snapshot(cx);
@@ -969,40 +975,39 @@ impl SearchableItem for Editor {
         } else {
             matches[current_index].start
         };
-        if matches[current_index]
-            .start
-            .cmp(&current_index_position, &buffer)
-            .is_gt()
-        {
-            if direction == Direction::Prev {
-                if current_index == 0 {
-                    current_index = matches.len() - 1;
-                } else {
-                    current_index -= 1;
+
+        let mut count = count % matches.len();
+        if count == 0 {
+            return current_index;
+        }
+        match direction {
+            Direction::Next => {
+                if matches[current_index]
+                    .start
+                    .cmp(&current_index_position, &buffer)
+                    .is_gt()
+                {
+                    count = count - 1
                 }
+
+                (current_index + count) % matches.len()
             }
-        } else if matches[current_index]
-            .end
-            .cmp(&current_index_position, &buffer)
-            .is_lt()
-        {
-            if direction == Direction::Next {
-                current_index = 0;
-            }
-        } else if direction == Direction::Prev {
-            if current_index == 0 {
-                current_index = matches.len() - 1;
-            } else {
-                current_index -= 1;
-            }
-        } else if direction == Direction::Next {
-            if current_index == matches.len() - 1 {
-                current_index = 0
-            } else {
-                current_index += 1;
+            Direction::Prev => {
+                if matches[current_index]
+                    .end
+                    .cmp(&current_index_position, &buffer)
+                    .is_lt()
+                {
+                    count = count - 1;
+                }
+
+                if current_index >= count {
+                    current_index - count
+                } else {
+                    matches.len() - (count - current_index)
+                }
             }
-        };
-        current_index
+        }
     }
 
     fn find_matches(

crates/search/Cargo.toml 🔗

@@ -9,6 +9,7 @@ path = "src/search.rs"
 doctest = false
 
 [dependencies]
+bitflags = "1"
 collections = { path = "../collections" }
 editor = { path = "../editor" }
 gpui = { path = "../gpui" }

crates/search/src/buffer_search.rs 🔗

@@ -1,15 +1,17 @@
 use crate::{
-    SearchOption, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
+    SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
     ToggleRegex, ToggleWholeWord,
 };
 use collections::HashMap;
 use editor::Editor;
+use futures::channel::oneshot;
 use gpui::{
     actions,
     elements::*,
     impl_actions,
     platform::{CursorStyle, MouseButton},
     Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
+    WindowContext,
 };
 use project::search::SearchQuery;
 use serde::Deserialize;
@@ -44,20 +46,19 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(BufferSearchBar::select_prev_match_on_pane);
     cx.add_action(BufferSearchBar::select_all_matches_on_pane);
     cx.add_action(BufferSearchBar::handle_editor_cancel);
-    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
-    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
-    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
+    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
+    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
+    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
 }
 
-fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
+fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
     cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
-            if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) {
-                search_bar.update(cx, |search_bar, cx| {
+            search_bar.update(cx, |search_bar, cx| {
+                if search_bar.show(cx) {
                     search_bar.toggle_search_option(option, cx);
-                });
-                return;
-            }
+                }
+            });
         }
         cx.propagate_action();
     });
@@ -71,9 +72,8 @@ pub struct BufferSearchBar {
     searchable_items_with_matches:
         HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
     pending_search: Option<Task<()>>,
-    case_sensitive: bool,
-    whole_word: bool,
-    regex: bool,
+    search_options: SearchOptions,
+    default_options: SearchOptions,
     query_contains_error: bool,
     dismissed: bool,
 }
@@ -156,19 +156,19 @@ impl View for BufferSearchBar {
                             .with_children(self.render_search_option(
                                 supported_options.case,
                                 "Case",
-                                SearchOption::CaseSensitive,
+                                SearchOptions::CASE_SENSITIVE,
                                 cx,
                             ))
                             .with_children(self.render_search_option(
                                 supported_options.word,
                                 "Word",
-                                SearchOption::WholeWord,
+                                SearchOptions::WHOLE_WORD,
                                 cx,
                             ))
                             .with_children(self.render_search_option(
                                 supported_options.regex,
                                 "Regex",
-                                SearchOption::Regex,
+                                SearchOptions::REGEX,
                                 cx,
                             ))
                             .contained()
@@ -212,7 +212,7 @@ impl ToolbarItemView for BufferSearchBar {
                 ));
 
             self.active_searchable_item = Some(searchable_item_handle);
-            self.update_matches(false, cx);
+            let _ = self.update_matches(cx);
             if !self.dismissed {
                 return ToolbarItemLocation::Secondary;
             }
@@ -253,9 +253,8 @@ impl BufferSearchBar {
             active_searchable_item_subscription: None,
             active_match_index: None,
             searchable_items_with_matches: Default::default(),
-            case_sensitive: false,
-            whole_word: false,
-            regex: false,
+            default_options: SearchOptions::NONE,
+            search_options: SearchOptions::NONE,
             pending_search: None,
             query_contains_error: false,
             dismissed: true,
@@ -282,48 +281,86 @@ impl BufferSearchBar {
         cx.notify();
     }
 
-    pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
-        let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
-            SearchableItemHandle::boxed_clone(searchable_item.as_ref())
-        } else {
+    pub fn show(&mut self, cx: &mut ViewContext<Self>) -> bool {
+        if self.active_searchable_item.is_none() {
             return false;
-        };
-
-        if suggest_query {
-            let text = searchable_item.query_suggestion(cx);
-            if !text.is_empty() {
-                self.set_query(&text, cx);
-            }
         }
-
-        if focus {
-            let query_editor = self.query_editor.clone();
-            query_editor.update(cx, |query_editor, cx| {
-                query_editor.select_all(&editor::SelectAll, cx);
-            });
-            cx.focus_self();
-        }
-
         self.dismissed = false;
         cx.notify();
         cx.emit(Event::UpdateLocation);
         true
     }
 
-    fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
+    pub fn search_suggested(&mut self, cx: &mut ViewContext<Self>) {
+        let search = self
+            .query_suggestion(cx)
+            .map(|suggestion| self.search(&suggestion, Some(self.default_options), cx));
+
+        if let Some(search) = search {
+            cx.spawn(|this, mut cx| async move {
+                search.await?;
+                this.update(&mut cx, |this, cx| this.activate_current_match(cx))
+            })
+            .detach_and_log_err(cx);
+        }
+    }
+
+    pub fn activate_current_match(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(match_ix) = self.active_match_index {
+            if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
+                if let Some(matches) = self
+                    .searchable_items_with_matches
+                    .get(&active_searchable_item.downgrade())
+                {
+                    active_searchable_item.activate_match(match_ix, matches, cx)
+                }
+            }
+        }
+    }
+
+    pub fn select_query(&mut self, cx: &mut ViewContext<Self>) {
         self.query_editor.update(cx, |query_editor, cx| {
-            query_editor.buffer().update(cx, |query_buffer, cx| {
-                let len = query_buffer.len(cx);
-                query_buffer.edit([(0..len, query)], None, cx);
-            });
+            query_editor.select_all(&Default::default(), cx);
         });
     }
 
+    pub fn query(&self, cx: &WindowContext) -> String {
+        self.query_editor.read(cx).text(cx)
+    }
+
+    pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
+        self.active_searchable_item
+            .as_ref()
+            .map(|searchable_item| searchable_item.query_suggestion(cx))
+    }
+
+    pub fn search(
+        &mut self,
+        query: &str,
+        options: Option<SearchOptions>,
+        cx: &mut ViewContext<Self>,
+    ) -> oneshot::Receiver<()> {
+        let options = options.unwrap_or(self.default_options);
+        if query != self.query_editor.read(cx).text(cx) || self.search_options != options {
+            self.query_editor.update(cx, |query_editor, cx| {
+                query_editor.buffer().update(cx, |query_buffer, cx| {
+                    let len = query_buffer.len(cx);
+                    query_buffer.edit([(0..len, query)], None, cx);
+                });
+            });
+            self.search_options = options;
+            self.query_contains_error = false;
+            self.clear_matches(cx);
+            cx.notify();
+        }
+        self.update_matches(cx)
+    }
+
     fn render_search_option(
         &self,
         option_supported: bool,
         icon: &'static str,
-        option: SearchOption,
+        option: SearchOptions,
         cx: &mut ViewContext<Self>,
     ) -> Option<AnyElement<Self>> {
         if !option_supported {
@@ -331,9 +368,9 @@ impl BufferSearchBar {
         }
 
         let tooltip_style = theme::current(cx).tooltip.clone();
-        let is_active = self.is_search_option_enabled(option);
+        let is_active = self.search_options.contains(option);
         Some(
-            MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
+            MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
                 let theme = theme::current(cx);
                 let style = theme
                     .search
@@ -349,7 +386,7 @@ impl BufferSearchBar {
             })
             .with_cursor_style(CursorStyle::PointingHand)
             .with_tooltip::<Self>(
-                option as usize,
+                option.bits as usize,
                 format!("Toggle {}", option.label()),
                 Some(option.to_toggle_action()),
                 tooltip_style,
@@ -471,12 +508,23 @@ impl BufferSearchBar {
     }
 
     fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext<Pane>) {
+        let mut propagate_action = true;
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
-            if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) {
-                return;
-            }
+            search_bar.update(cx, |search_bar, cx| {
+                if search_bar.show(cx) {
+                    search_bar.search_suggested(cx);
+                    if action.focus {
+                        search_bar.select_query(cx);
+                        cx.focus_self();
+                    }
+                    propagate_action = false;
+                }
+            });
+        }
+
+        if propagate_action {
+            cx.propagate_action();
         }
-        cx.propagate_action();
     }
 
     fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext<Pane>) {
@@ -489,37 +537,34 @@ impl BufferSearchBar {
         cx.propagate_action();
     }
 
-    fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
+    pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
         if let Some(active_editor) = self.active_searchable_item.as_ref() {
             cx.focus(active_editor.as_any());
         }
     }
 
-    fn is_search_option_enabled(&self, search_option: SearchOption) -> bool {
-        match search_option {
-            SearchOption::WholeWord => self.whole_word,
-            SearchOption::CaseSensitive => self.case_sensitive,
-            SearchOption::Regex => self.regex,
-        }
+    fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext<Self>) {
+        self.search_options.toggle(search_option);
+        self.default_options = self.search_options;
+        let _ = self.update_matches(cx);
+        cx.notify();
     }
 
-    fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext<Self>) {
-        let value = match search_option {
-            SearchOption::WholeWord => &mut self.whole_word,
-            SearchOption::CaseSensitive => &mut self.case_sensitive,
-            SearchOption::Regex => &mut self.regex,
-        };
-        *value = !*value;
-        self.update_matches(false, cx);
+    pub fn set_search_options(
+        &mut self,
+        search_options: SearchOptions,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.search_options = search_options;
         cx.notify();
     }
 
     fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
-        self.select_match(Direction::Next, cx);
+        self.select_match(Direction::Next, 1, cx);
     }
 
     fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
-        self.select_match(Direction::Prev, cx);
+        self.select_match(Direction::Prev, 1, cx);
     }
 
     fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext<Self>) {
@@ -536,15 +581,15 @@ impl BufferSearchBar {
         }
     }
 
-    pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+    pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext<Self>) {
         if let Some(index) = self.active_match_index {
             if let Some(searchable_item) = self.active_searchable_item.as_ref() {
                 if let Some(matches) = self
                     .searchable_items_with_matches
                     .get(&searchable_item.downgrade())
                 {
-                    let new_match_index =
-                        searchable_item.match_index_for_direction(matches, index, direction, cx);
+                    let new_match_index = searchable_item
+                        .match_index_for_direction(matches, index, direction, count, cx);
                     searchable_item.update_matches(matches, cx);
                     searchable_item.activate_match(new_match_index, matches, cx);
                 }
@@ -588,17 +633,23 @@ impl BufferSearchBar {
         event: &editor::Event,
         cx: &mut ViewContext<Self>,
     ) {
-        if let editor::Event::BufferEdited { .. } = event {
+        if let editor::Event::Edited { .. } = event {
             self.query_contains_error = false;
             self.clear_matches(cx);
-            self.update_matches(true, cx);
-            cx.notify();
+            let search = self.update_matches(cx);
+            cx.spawn(|this, mut cx| async move {
+                search.await?;
+                this.update(&mut cx, |this, cx| this.activate_current_match(cx))
+            })
+            .detach_and_log_err(cx);
         }
     }
 
     fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext<Self>) {
         match event {
-            SearchEvent::MatchesInvalidated => self.update_matches(false, cx),
+            SearchEvent::MatchesInvalidated => {
+                let _ = self.update_matches(cx);
+            }
             SearchEvent::ActiveMatchChanged => self.update_match_index(cx),
         }
     }
@@ -621,19 +672,21 @@ impl BufferSearchBar {
             .extend(active_item_matches);
     }
 
-    fn update_matches(&mut self, select_closest_match: bool, cx: &mut ViewContext<Self>) {
+    fn update_matches(&mut self, cx: &mut ViewContext<Self>) -> oneshot::Receiver<()> {
+        let (done_tx, done_rx) = oneshot::channel();
         let query = self.query_editor.read(cx).text(cx);
         self.pending_search.take();
         if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
             if query.is_empty() {
                 self.active_match_index.take();
                 active_searchable_item.clear_matches(cx);
+                let _ = done_tx.send(());
             } else {
-                let query = if self.regex {
+                let query = if self.search_options.contains(SearchOptions::REGEX) {
                     match SearchQuery::regex(
                         query,
-                        self.whole_word,
-                        self.case_sensitive,
+                        self.search_options.contains(SearchOptions::WHOLE_WORD),
+                        self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                         Vec::new(),
                         Vec::new(),
                     ) {
@@ -641,14 +694,14 @@ impl BufferSearchBar {
                         Err(_) => {
                             self.query_contains_error = true;
                             cx.notify();
-                            return;
+                            return done_rx;
                         }
                     }
                 } else {
                     SearchQuery::text(
                         query,
-                        self.whole_word,
-                        self.case_sensitive,
+                        self.search_options.contains(SearchOptions::WHOLE_WORD),
+                        self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                         Vec::new(),
                         Vec::new(),
                     )
@@ -673,12 +726,7 @@ impl BufferSearchBar {
                                     .get(&active_searchable_item.downgrade())
                                     .unwrap();
                                 active_searchable_item.update_matches(matches, cx);
-                                if select_closest_match {
-                                    if let Some(match_ix) = this.active_match_index {
-                                        active_searchable_item
-                                            .activate_match(match_ix, matches, cx);
-                                    }
-                                }
+                                let _ = done_tx.send(());
                             }
                             cx.notify();
                         }
@@ -687,6 +735,7 @@ impl BufferSearchBar {
                 }));
             }
         }
+        done_rx
     }
 
     fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
@@ -714,8 +763,7 @@ mod tests {
     use language::Buffer;
     use unindent::Unindent as _;
 
-    #[gpui::test]
-    async fn test_search_simple(cx: &mut TestAppContext) {
+    fn init_test(cx: &mut TestAppContext) -> (ViewHandle<Editor>, ViewHandle<BufferSearchBar>) {
         crate::project_search::tests::init_test(cx);
 
         let buffer = cx.add_model(|cx| {
@@ -738,16 +786,23 @@ mod tests {
         let search_bar = cx.add_view(window_id, |cx| {
             let mut search_bar = BufferSearchBar::new(cx);
             search_bar.set_active_pane_item(Some(&editor), cx);
-            search_bar.show(false, true, cx);
+            search_bar.show(cx);
             search_bar
         });
 
+        (editor, search_bar)
+    }
+
+    #[gpui::test]
+    async fn test_search_simple(cx: &mut TestAppContext) {
+        let (editor, search_bar) = init_test(cx);
+
         // Search for a string that appears with different casing.
         // By default, search is case-insensitive.
-        search_bar.update(cx, |search_bar, cx| {
-            search_bar.set_query("us", cx);
-        });
-        editor.next_notification(cx).await;
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("us", None, cx))
+            .await
+            .unwrap();
         editor.update(cx, |editor, cx| {
             assert_eq!(
                 editor.all_background_highlights(cx),
@@ -766,7 +821,7 @@ mod tests {
 
         // Switch to a case sensitive search.
         search_bar.update(cx, |search_bar, cx| {
-            search_bar.toggle_search_option(SearchOption::CaseSensitive, cx);
+            search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
         });
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
@@ -781,10 +836,10 @@ mod tests {
 
         // Search for a string that appears both as a whole word and
         // within other words. By default, all results are found.
-        search_bar.update(cx, |search_bar, cx| {
-            search_bar.set_query("or", cx);
-        });
-        editor.next_notification(cx).await;
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("or", None, cx))
+            .await
+            .unwrap();
         editor.update(cx, |editor, cx| {
             assert_eq!(
                 editor.all_background_highlights(cx),
@@ -823,7 +878,7 @@ mod tests {
 
         // Switch to a whole word search.
         search_bar.update(cx, |search_bar, cx| {
-            search_bar.toggle_search_option(SearchOption::WholeWord, cx);
+            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
         });
         editor.next_notification(cx).await;
         editor.update(cx, |editor, cx| {
@@ -1025,6 +1080,65 @@ mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_search_option_handling(cx: &mut TestAppContext) {
+        let (editor, search_bar) = init_test(cx);
+
+        // show with options should make current search case sensitive
+        search_bar
+            .update(cx, |search_bar, cx| {
+                search_bar.show(cx);
+                search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx)
+            })
+            .await
+            .unwrap();
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[(
+                    DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
+                    Color::red(),
+                )]
+            );
+        });
+
+        // search_suggested should restore default options
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.search_suggested(cx);
+            assert_eq!(search_bar.search_options, SearchOptions::NONE)
+        });
+
+        // toggling a search option should update the defaults
+        search_bar
+            .update(cx, |search_bar, cx| {
+                search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx)
+            })
+            .await
+            .unwrap();
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx)
+        });
+        editor.next_notification(cx).await;
+        editor.update(cx, |editor, cx| {
+            assert_eq!(
+                editor.all_background_highlights(cx),
+                &[(
+                    DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
+                    Color::red(),
+                ),]
+            );
+        });
+
+        // defaults should still include whole word
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.search_suggested(cx);
+            assert_eq!(
+                search_bar.search_options,
+                SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD
+            )
+        });
+    }
+
     #[gpui::test]
     async fn test_search_select_all_matches(cx: &mut TestAppContext) {
         crate::project_search::tests::init_test(cx);
@@ -1052,15 +1166,18 @@ mod tests {
         let search_bar = cx.add_view(window_id, |cx| {
             let mut search_bar = BufferSearchBar::new(cx);
             search_bar.set_active_pane_item(Some(&editor), cx);
-            search_bar.show(false, true, cx);
+            search_bar.show(cx);
             search_bar
         });
 
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("a", None, cx))
+            .await
+            .unwrap();
         search_bar.update(cx, |search_bar, cx| {
-            search_bar.set_query("a", cx);
+            search_bar.activate_current_match(cx);
         });
 
-        editor.next_notification(cx).await;
         let initial_selections = editor.update(cx, |editor, cx| {
             let initial_selections = editor.selections.display_ranges(cx);
             assert_eq!(

crates/search/src/project_search.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
+    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
     ToggleWholeWord,
 };
 use anyhow::Result;
@@ -51,12 +51,12 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(ProjectSearchBar::select_prev_match);
     cx.capture_action(ProjectSearchBar::tab);
     cx.capture_action(ProjectSearchBar::tab_previous);
-    add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
-    add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
-    add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
+    add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
+    add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
+    add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
 }
 
-fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
+fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
     cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
         if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
             if search_bar.update(cx, |search_bar, cx| {
@@ -89,9 +89,7 @@ pub struct ProjectSearchView {
     model: ModelHandle<ProjectSearch>,
     query_editor: ViewHandle<Editor>,
     results_editor: ViewHandle<Editor>,
-    case_sensitive: bool,
-    whole_word: bool,
-    regex: bool,
+    search_options: SearchOptions,
     panels_with_errors: HashSet<InputPanel>,
     active_match_index: Option<usize>,
     search_id: usize,
@@ -408,9 +406,7 @@ impl ProjectSearchView {
         let project;
         let excerpts;
         let mut query_text = String::new();
-        let mut regex = false;
-        let mut case_sensitive = false;
-        let mut whole_word = false;
+        let mut options = SearchOptions::NONE;
 
         {
             let model = model.read(cx);
@@ -418,9 +414,7 @@ impl ProjectSearchView {
             excerpts = model.excerpts.clone();
             if let Some(active_query) = model.active_query.as_ref() {
                 query_text = active_query.as_str().to_string();
-                regex = active_query.is_regex();
-                case_sensitive = active_query.case_sensitive();
-                whole_word = active_query.whole_word();
+                options = SearchOptions::from_query(active_query);
             }
         }
         cx.observe(&model, |this, _, cx| this.model_changed(cx))
@@ -496,9 +490,7 @@ impl ProjectSearchView {
             model,
             query_editor,
             results_editor,
-            case_sensitive,
-            whole_word,
-            regex,
+            search_options: options,
             panels_with_errors: HashSet::new(),
             active_match_index: None,
             query_editor_was_focused: false,
@@ -594,11 +586,11 @@ impl ProjectSearchView {
                     return None;
                 }
             };
-        if self.regex {
+        if self.search_options.contains(SearchOptions::REGEX) {
             match SearchQuery::regex(
                 text,
-                self.whole_word,
-                self.case_sensitive,
+                self.search_options.contains(SearchOptions::WHOLE_WORD),
+                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                 included_files,
                 excluded_files,
             ) {
@@ -615,8 +607,8 @@ impl ProjectSearchView {
         } else {
             Some(SearchQuery::text(
                 text,
-                self.whole_word,
-                self.case_sensitive,
+                self.search_options.contains(SearchOptions::WHOLE_WORD),
+                self.search_options.contains(SearchOptions::CASE_SENSITIVE),
                 included_files,
                 excluded_files,
             ))
@@ -635,7 +627,7 @@ impl ProjectSearchView {
         if let Some(index) = self.active_match_index {
             let match_ranges = self.model.read(cx).match_ranges.clone();
             let new_index = self.results_editor.update(cx, |editor, cx| {
-                editor.match_index_for_direction(&match_ranges, index, direction, cx)
+                editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
             });
 
             let range_to_select = match_ranges[new_index].clone();
@@ -676,7 +668,6 @@ impl ProjectSearchView {
             self.active_match_index = None;
         } else {
             self.active_match_index = Some(0);
-            self.select_match(Direction::Next, cx);
             self.update_match_index(cx);
             let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
             let is_new_search = self.search_id != prev_search_id;
@@ -768,9 +759,7 @@ impl ProjectSearchBar {
                         search_view.query_editor.update(cx, |editor, cx| {
                             editor.set_text(old_query.as_str(), cx);
                         });
-                        search_view.regex = old_query.is_regex();
-                        search_view.whole_word = old_query.whole_word();
-                        search_view.case_sensitive = old_query.case_sensitive();
+                        search_view.search_options = SearchOptions::from_query(&old_query);
                     }
                 }
                 new_query
@@ -858,15 +847,10 @@ impl ProjectSearchBar {
         });
     }
 
-    fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
+    fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
         if let Some(search_view) = self.active_project_search.as_ref() {
             search_view.update(cx, |search_view, cx| {
-                let value = match option {
-                    SearchOption::WholeWord => &mut search_view.whole_word,
-                    SearchOption::CaseSensitive => &mut search_view.case_sensitive,
-                    SearchOption::Regex => &mut search_view.regex,
-                };
-                *value = !*value;
+                search_view.search_options.toggle(option);
                 search_view.search(cx);
             });
             cx.notify();
@@ -923,12 +907,12 @@ impl ProjectSearchBar {
     fn render_option_button(
         &self,
         icon: &'static str,
-        option: SearchOption,
+        option: SearchOptions,
         cx: &mut ViewContext<Self>,
     ) -> AnyElement<Self> {
         let tooltip_style = theme::current(cx).tooltip.clone();
         let is_active = self.is_option_enabled(option, cx);
-        MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
+        MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
             let theme = theme::current(cx);
             let style = theme
                 .search
@@ -944,7 +928,7 @@ impl ProjectSearchBar {
         })
         .with_cursor_style(CursorStyle::PointingHand)
         .with_tooltip::<Self>(
-            option as usize,
+            option.bits as usize,
             format!("Toggle {}", option.label()),
             Some(option.to_toggle_action()),
             tooltip_style,
@@ -953,14 +937,9 @@ impl ProjectSearchBar {
         .into_any()
     }
 
-    fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
+    fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
         if let Some(search) = self.active_project_search.as_ref() {
-            let search = search.read(cx);
-            match option {
-                SearchOption::WholeWord => search.whole_word,
-                SearchOption::CaseSensitive => search.case_sensitive,
-                SearchOption::Regex => search.regex,
-            }
+            search.read(cx).search_options.contains(option)
         } else {
             false
         }
@@ -1051,17 +1030,17 @@ impl View for ProjectSearchBar {
                             Flex::row()
                                 .with_child(self.render_option_button(
                                     "Case",
-                                    SearchOption::CaseSensitive,
+                                    SearchOptions::CASE_SENSITIVE,
                                     cx,
                                 ))
                                 .with_child(self.render_option_button(
                                     "Word",
-                                    SearchOption::WholeWord,
+                                    SearchOptions::WHOLE_WORD,
                                     cx,
                                 ))
                                 .with_child(self.render_option_button(
                                     "Regex",
-                                    SearchOption::Regex,
+                                    SearchOptions::REGEX,
                                     cx,
                                 ))
                                 .contained()

crates/search/src/search.rs 🔗

@@ -1,5 +1,7 @@
+use bitflags::bitflags;
 pub use buffer_search::BufferSearchBar;
 use gpui::{actions, Action, AppContext};
+use project::search::SearchQuery;
 pub use project_search::{ProjectSearchBar, ProjectSearchView};
 
 pub mod buffer_search;
@@ -22,27 +24,40 @@ actions!(
     ]
 );
 
-#[derive(Clone, Copy, PartialEq)]
-pub enum SearchOption {
-    WholeWord,
-    CaseSensitive,
-    Regex,
+bitflags! {
+    #[derive(Default)]
+    pub struct SearchOptions: u8 {
+        const NONE = 0b000;
+        const WHOLE_WORD = 0b001;
+        const CASE_SENSITIVE = 0b010;
+        const REGEX = 0b100;
+    }
 }
 
-impl SearchOption {
+impl SearchOptions {
     pub fn label(&self) -> &'static str {
-        match self {
-            SearchOption::WholeWord => "Match Whole Word",
-            SearchOption::CaseSensitive => "Match Case",
-            SearchOption::Regex => "Use Regular Expression",
+        match *self {
+            SearchOptions::WHOLE_WORD => "Match Whole Word",
+            SearchOptions::CASE_SENSITIVE => "Match Case",
+            SearchOptions::REGEX => "Use Regular Expression",
+            _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
 
     pub fn to_toggle_action(&self) -> Box<dyn Action> {
-        match self {
-            SearchOption::WholeWord => Box::new(ToggleWholeWord),
-            SearchOption::CaseSensitive => Box::new(ToggleCaseSensitive),
-            SearchOption::Regex => Box::new(ToggleRegex),
+        match *self {
+            SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord),
+            SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive),
+            SearchOptions::REGEX => Box::new(ToggleRegex),
+            _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
+
+    pub fn from_query(query: &SearchQuery) -> SearchOptions {
+        let mut options = SearchOptions::NONE;
+        options.set(SearchOptions::WHOLE_WORD, query.whole_word());
+        options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive());
+        options.set(SearchOptions::REGEX, query.is_regex());
+        options
+    }
 }

crates/vim/src/normal.rs 🔗

@@ -2,6 +2,7 @@ mod case;
 mod change;
 mod delete;
 mod scroll;
+mod search;
 mod substitute;
 mod yank;
 
@@ -57,6 +58,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(insert_line_above);
     cx.add_action(insert_line_below);
     cx.add_action(change_case);
+    search::init(cx);
     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,285 @@
+use gpui::{actions, impl_actions, AppContext, ViewContext};
+use search::{buffer_search, BufferSearchBar, SearchOptions};
+use serde_derive::Deserialize;
+use workspace::{searchable::Direction, Pane, Workspace};
+
+use crate::{state::SearchState, 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,
+}
+
+#[derive(Clone, Deserialize, PartialEq)]
+pub(crate) struct Search {
+    #[serde(default)]
+    backwards: bool,
+}
+
+impl_actions!(vim, [MoveToNext, MoveToPrev, Search]);
+actions!(vim, [SearchSubmit]);
+
+pub(crate) fn init(cx: &mut AppContext) {
+    cx.add_action(move_to_next);
+    cx.add_action(move_to_prev);
+    cx.add_action(search);
+    cx.add_action(search_submit);
+    cx.add_action(search_deploy);
+}
+
+fn move_to_next(workspace: &mut Workspace, action: &MoveToNext, cx: &mut ViewContext<Workspace>) {
+    move_to_internal(workspace, Direction::Next, !action.partial_word, cx)
+}
+
+fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewContext<Workspace>) {
+    move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
+}
+
+fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
+    let pane = workspace.active_pane().clone();
+    let direction = if action.backwards {
+        Direction::Prev
+    } else {
+        Direction::Next
+    };
+    Vim::update(cx, |vim, cx| {
+        let count = vim.pop_number_operator(cx).unwrap_or(1);
+        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| {
+                    if !search_bar.show(cx) {
+                        return;
+                    }
+                    let query = search_bar.query(cx);
+
+                    search_bar.select_query(cx);
+                    cx.focus_self();
+
+                    if query.is_empty() {
+                        search_bar.set_search_options(
+                            SearchOptions::CASE_SENSITIVE | SearchOptions::REGEX,
+                            cx,
+                        );
+                    }
+                    vim.state.search = SearchState {
+                        direction,
+                        count,
+                        initial_query: query,
+                    };
+                });
+            }
+        })
+    })
+}
+
+// hook into the existing to clear out any vim search state on cmd+f or edit -> find.
+fn search_deploy(_: &mut Pane, _: &buffer_search::Deploy, cx: &mut ViewContext<Pane>) {
+    Vim::update(cx, |vim, _| vim.state.search = Default::default());
+    cx.propagate_action();
+}
+
+fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, 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 state = &mut vim.state.search;
+                    let mut count = state.count;
+
+                    // in the case that the query has changed, the search bar
+                    // will have selected the next match already.
+                    if (search_bar.query(cx) != state.initial_query)
+                        && state.direction == Direction::Next
+                    {
+                        count = count.saturating_sub(1)
+                    }
+                    search_bar.select_match(state.direction, count, cx);
+                    state.count = 1;
+                    search_bar.focus_editor(&Default::default(), cx);
+                });
+            }
+        });
+    })
+}
+
+pub 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();
+        let count = vim.pop_number_operator(cx).unwrap_or(1);
+        pane.update(cx, |pane, cx| {
+            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+                let search = search_bar.update(cx, |search_bar, cx| {
+                    let mut options = SearchOptions::CASE_SENSITIVE;
+                    options.set(SearchOptions::WHOLE_WORD, whole_word);
+                    if search_bar.show(cx) {
+                        search_bar
+                            .query_suggestion(cx)
+                            .map(|query| search_bar.search(&query, Some(options), cx))
+                    } else {
+                        None
+                    }
+                });
+
+                if let Some(search) = search {
+                    let search_bar = search_bar.downgrade();
+                    cx.spawn(|_, mut cx| async move {
+                        search.await?;
+                        search_bar.update(&mut cx, |search_bar, cx| {
+                            search_bar.select_match(direction, count, cx)
+                        })?;
+                        anyhow::Ok(())
+                    })
+                    .detach_and_log_err(cx);
+                }
+            }
+        });
+        vim.clear_operator(cx);
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use std::sync::Arc;
+
+    use editor::DisplayPoint;
+    use search::BufferSearchBar;
+
+    use crate::{state::Mode, test::VimTestContext};
+
+    #[gpui::test]
+    async fn test_move_to_next(
+        cx: &mut gpui::TestAppContext,
+        deterministic: Arc<gpui::executor::Deterministic>,
+    ) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["*"]);
+        deterministic.run_until_parked();
+        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["*"]);
+        deterministic.run_until_parked();
+        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["#"]);
+        deterministic.run_until_parked();
+        cx.assert_state("hi\nhigh\nˇhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["#"]);
+        deterministic.run_until_parked();
+        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["2", "*"]);
+        deterministic.run_until_parked();
+        cx.assert_state("ˇhi\nhigh\nhi\n", Mode::Normal);
+
+        cx.simulate_keystrokes(["g", "*"]);
+        deterministic.run_until_parked();
+        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", "#"]);
+        deterministic.run_until_parked();
+        cx.assert_state("hi\nˇhigh\nhi\n", Mode::Normal);
+    }
+
+    #[gpui::test]
+    async fn test_search(
+        cx: &mut gpui::TestAppContext,
+        deterministic: Arc<gpui::executor::Deterministic>,
+    ) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
+        cx.simulate_keystrokes(["/", "c", "c"]);
+
+        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")
+        });
+
+        search_bar.read_with(cx.cx, |bar, cx| {
+            assert_eq!(bar.query_editor.read(cx).text(cx), "cc");
+        });
+
+        deterministic.run_until_parked();
+
+        cx.update_editor(|editor, cx| {
+            let highlights = editor.all_background_highlights(cx);
+            assert_eq!(3, highlights.len());
+            assert_eq!(
+                DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
+                highlights[0].0
+            )
+        });
+
+        cx.simulate_keystrokes(["enter"]);
+        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
+
+        // n to go to next/N to go to previous
+        cx.simulate_keystrokes(["n"]);
+        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
+        cx.simulate_keystrokes(["shift-n"]);
+        cx.assert_state("aa\nbb\nˇcc\ncc\ncc\n", Mode::Normal);
+
+        // ?<enter> to go to previous
+        cx.simulate_keystrokes(["?", "enter"]);
+        deterministic.run_until_parked();
+        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
+        cx.simulate_keystrokes(["?", "enter"]);
+        deterministic.run_until_parked();
+        cx.assert_state("aa\nbb\ncc\nˇcc\ncc\n", Mode::Normal);
+
+        // /<enter> to go to next
+        cx.simulate_keystrokes(["/", "enter"]);
+        deterministic.run_until_parked();
+        cx.assert_state("aa\nbb\ncc\ncc\nˇcc\n", Mode::Normal);
+
+        // ?{search}<enter> to search backwards
+        cx.simulate_keystrokes(["?", "b", "enter"]);
+        deterministic.run_until_parked();
+        cx.assert_state("aa\nbˇb\ncc\ncc\ncc\n", Mode::Normal);
+
+        // works with counts
+        cx.simulate_keystrokes(["4", "/", "c"]);
+        deterministic.run_until_parked();
+        cx.simulate_keystrokes(["enter"]);
+        cx.assert_state("aa\nbb\ncc\ncˇc\ncc\n", Mode::Normal);
+
+        // check that searching resumes from cursor, not previous match
+        cx.set_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
+        cx.simulate_keystrokes(["/", "d"]);
+        deterministic.run_until_parked();
+        cx.simulate_keystrokes(["enter"]);
+        cx.assert_state("aa\nbb\nˇdd\ncc\nbb\n", Mode::Normal);
+        cx.update_editor(|editor, cx| editor.move_to_beginning(&Default::default(), cx));
+        cx.assert_state("ˇaa\nbb\ndd\ncc\nbb\n", Mode::Normal);
+        cx.simulate_keystrokes(["/", "b"]);
+        deterministic.run_until_parked();
+        cx.simulate_keystrokes(["enter"]);
+        cx.assert_state("aa\nˇbb\ndd\ncc\nbb\n", Mode::Normal);
+    }
+}

crates/vim/src/state.rs 🔗

@@ -1,6 +1,7 @@
 use gpui::keymap_matcher::KeymapContext;
 use language::CursorShape;
 use serde::{Deserialize, Serialize};
+use workspace::searchable::Direction;
 
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
 pub enum Mode {
@@ -38,6 +39,23 @@ pub enum Operator {
 pub struct VimState {
     pub mode: Mode,
     pub operator_stack: Vec<Operator>,
+    pub search: SearchState,
+}
+
+pub struct SearchState {
+    pub direction: Direction,
+    pub count: usize,
+    pub initial_query: String,
+}
+
+impl Default for SearchState {
+    fn default() -> Self {
+        Self {
+            direction: Direction::Next,
+            count: 1,
+            initial_query: "".to_string(),
+        }
+    }
 }
 
 impl VimState {

crates/vim/src/test.rs 🔗

@@ -5,6 +5,7 @@ mod vim_binding_test_context;
 mod vim_test_context;
 
 use command_palette::CommandPalette;
+use editor::DisplayPoint;
 pub use neovim_backed_binding_test_context::*;
 pub use neovim_backed_test_context::*;
 pub use vim_binding_test_context::*;
@@ -96,7 +97,7 @@ async fn test_buffer_search(cx: &mut gpui::TestAppContext) {
     });
 
     search_bar.read_with(cx.cx, |bar, cx| {
-        assert_eq!(bar.query_editor.read(cx).text(cx), "jumps");
+        assert_eq!(bar.query_editor.read(cx).text(cx), "");
     })
 }
 
@@ -137,7 +138,7 @@ async fn test_indent_outdent(cx: &mut gpui::TestAppContext) {
     cx.assert_editor_state("aa\nbˇb\ncc");
 
     // works in visuial mode
-    cx.simulate_keystrokes(["shift-v", "down", ">", ">"]);
+    cx.simulate_keystrokes(["shift-v", "down", ">"]);
     cx.assert_editor_state("aa\n    b«b\n    cˇ»c");
 }
 
@@ -153,3 +154,44 @@ async fn test_escape_command_palette(cx: &mut gpui::TestAppContext) {
     assert!(!cx.workspace(|workspace, _| workspace.modal::<CommandPalette>().is_some()));
     cx.assert_state("aˇbc\n", Mode::Insert);
 }
+
+#[gpui::test]
+async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
+    let mut cx = VimTestContext::new(cx, true).await;
+
+    cx.set_state(indoc! {"aa\nbˇb\ncc\ncc\ncc\n"}, Mode::Normal);
+    cx.simulate_keystrokes(["/", "c", "c"]);
+
+    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")
+    });
+
+    search_bar.read_with(cx.cx, |bar, cx| {
+        assert_eq!(bar.query_editor.read(cx).text(cx), "cc");
+    });
+
+    // wait for the query editor change event to fire.
+    search_bar.next_notification(&cx).await;
+
+    cx.update_editor(|editor, cx| {
+        let highlights = editor.all_background_highlights(cx);
+        assert_eq!(3, highlights.len());
+        assert_eq!(
+            DisplayPoint::new(2, 0)..DisplayPoint::new(2, 2),
+            highlights[0].0
+        )
+    });
+    cx.simulate_keystrokes(["enter"]);
+
+    cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal);
+    cx.simulate_keystrokes(["n"]);
+    cx.assert_state(indoc! {"aa\nbb\ncc\nˇcc\ncc\n"}, Mode::Normal);
+    cx.simulate_keystrokes(["shift-n"]);
+    cx.assert_state(indoc! {"aa\nbb\nˇcc\ncc\ncc\n"}, Mode::Normal);
+}

crates/vim/src/test/vim_test_context.rs 🔗

@@ -90,6 +90,7 @@ impl<'a> VimTestContext<'a> {
         self.cx.set_state(text)
     }
 
+    #[track_caller]
     pub fn assert_state(&mut self, text: &str, mode: Mode) {
         self.assert_editor_state(text);
         assert_eq!(self.mode(), mode, "{}", self.assertion_context());

crates/vim/src/vim.rs 🔗

@@ -295,11 +295,15 @@ impl Vim {
             if self.enabled && editor.mode() == EditorMode::Full {
                 editor.set_cursor_shape(cursor_shape, cx);
                 editor.set_clip_at_line_ends(state.clip_at_line_end(), cx);
+                editor.set_collapse_matches(true);
                 editor.set_input_enabled(!state.vim_controlled());
                 editor.selections.line_mode = matches!(state.mode, Mode::Visual { line: true });
                 let context_layer = state.keymap_context_layer();
                 editor.set_keymap_context_layer::<Self>(context_layer, cx);
             } else {
+                // Note: set_collapse_matches is not in unhook_vim_settings, as that method is called on blur,
+                // but we need collapse_matches to persist when the search bar is focused.
+                editor.set_collapse_matches(false);
                 Self::unhook_vim_settings(editor, cx);
             }
         });

crates/workspace/src/searchable.rs 🔗

@@ -55,26 +55,21 @@ pub trait SearchableItem: Item {
     fn match_index_for_direction(
         &mut self,
         matches: &Vec<Self::Match>,
-        mut current_index: usize,
+        current_index: usize,
         direction: Direction,
+        count: usize,
         _: &mut ViewContext<Self>,
     ) -> usize {
         match direction {
             Direction::Prev => {
-                if current_index == 0 {
-                    matches.len() - 1
-                } else {
-                    current_index - 1
-                }
-            }
-            Direction::Next => {
-                current_index += 1;
-                if current_index == matches.len() {
-                    0
+                let count = count % matches.len();
+                if current_index >= count {
+                    current_index - count
                 } else {
-                    current_index
+                    matches.len() - (count - current_index)
                 }
             }
+            Direction::Next => (current_index + count) % matches.len(),
         }
     }
     fn find_matches(
@@ -113,6 +108,7 @@ pub trait SearchableItemHandle: ItemHandle {
         matches: &Vec<Box<dyn Any + Send>>,
         current_index: usize,
         direction: Direction,
+        count: usize,
         cx: &mut WindowContext,
     ) -> usize;
     fn find_matches(
@@ -183,11 +179,12 @@ impl<T: SearchableItem> SearchableItemHandle for ViewHandle<T> {
         matches: &Vec<Box<dyn Any + Send>>,
         current_index: usize,
         direction: Direction,
+        count: usize,
         cx: &mut WindowContext,
     ) -> usize {
         let matches = downcast_matches(matches);
         self.update(cx, |this, cx| {
-            this.match_index_for_direction(&matches, current_index, direction, cx)
+            this.match_index_for_direction(&matches, current_index, direction, count, cx)
         })
     }
     fn find_matches(