Track history items in the buffer and project searches (#2817)

Kirill Bulatov created

![image](https://github.com/zed-industries/zed/assets/2690773/53ce324f-2243-4cd9-a8aa-11f361c36860)

Tracks last N (20 currently) items in buffer and project search, allows
cycling through between them with up/down keys by default.

Release Notes:

- Track history in buffer and project searches

Change summary

assets/keymaps/default.json         |  14 +
crates/gpui/src/app.rs              |   6 
crates/search/src/buffer_search.rs  | 230 ++++++++++++++++++++++++
crates/search/src/project_search.rs | 283 ++++++++++++++++++++++++++++++
crates/search/src/search.rs         | 187 ++++++++++++++++++++
crates/vim/src/normal/search.rs     |   2 
crates/vim/src/test.rs              |   4 
7 files changed, 716 insertions(+), 10 deletions(-)

Detailed changes

assets/keymaps/default.json 🔗

@@ -227,12 +227,26 @@
       "alt-enter": "search::SelectAllMatches"
     }
   },
+  {
+    "context": "BufferSearchBar > Editor",
+    "bindings": {
+      "up": "search::PreviousHistoryQuery",
+      "down": "search::NextHistoryQuery"
+    }
+  },
   {
     "context": "ProjectSearchBar",
     "bindings": {
       "escape": "project_search::ToggleFocus"
     }
   },
+  {
+    "context": "ProjectSearchBar > Editor",
+    "bindings": {
+      "up": "search::PreviousHistoryQuery",
+      "down": "search::NextHistoryQuery"
+    }
+  },
   {
     "context": "ProjectSearchView",
     "bindings": {

crates/gpui/src/app.rs 🔗

@@ -1128,6 +1128,12 @@ impl AppContext {
         self.keystroke_matcher.clear_bindings();
     }
 
+    pub fn binding_for_action(&self, action: &dyn Action) -> Option<&Binding> {
+        self.keystroke_matcher
+            .bindings_for_action(action.id())
+            .find(|binding| binding.action().eq(action))
+    }
+
     pub fn default_global<T: 'static + Default>(&mut self) -> &T {
         let type_id = TypeId::of::<T>();
         self.update(|this| {

crates/search/src/buffer_search.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
-    ToggleRegex, ToggleWholeWord,
+    NextHistoryQuery, PreviousHistoryQuery, SearchHistory, SearchOptions, SelectAllMatches,
+    SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord,
 };
 use collections::HashMap;
 use editor::Editor;
@@ -46,6 +46,8 @@ 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);
+    cx.add_action(BufferSearchBar::next_history_query);
+    cx.add_action(BufferSearchBar::previous_history_query);
     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);
@@ -65,7 +67,7 @@ fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContex
 }
 
 pub struct BufferSearchBar {
-    pub query_editor: ViewHandle<Editor>,
+    query_editor: ViewHandle<Editor>,
     active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
     active_match_index: Option<usize>,
     active_searchable_item_subscription: Option<Subscription>,
@@ -76,6 +78,7 @@ pub struct BufferSearchBar {
     default_options: SearchOptions,
     query_contains_error: bool,
     dismissed: bool,
+    search_history: SearchHistory,
 }
 
 impl Entity for BufferSearchBar {
@@ -106,6 +109,48 @@ impl View for BufferSearchBar {
             .map(|active_searchable_item| active_searchable_item.supported_options())
             .unwrap_or_default();
 
+        let previous_query_keystrokes =
+            cx.binding_for_action(&PreviousHistoryQuery {})
+                .map(|binding| {
+                    binding
+                        .keystrokes()
+                        .iter()
+                        .map(|k| k.to_string())
+                        .collect::<Vec<_>>()
+                });
+        let next_query_keystrokes = cx.binding_for_action(&NextHistoryQuery {}).map(|binding| {
+            binding
+                .keystrokes()
+                .iter()
+                .map(|k| k.to_string())
+                .collect::<Vec<_>>()
+        });
+        let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
+            (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => {
+                format!(
+                    "Search ({}/{} for previous/next query)",
+                    previous_query_keystrokes.join(" "),
+                    next_query_keystrokes.join(" ")
+                )
+            }
+            (None, Some(next_query_keystrokes)) => {
+                format!(
+                    "Search ({} for next query)",
+                    next_query_keystrokes.join(" ")
+                )
+            }
+            (Some(previous_query_keystrokes), None) => {
+                format!(
+                    "Search ({} for previous query)",
+                    previous_query_keystrokes.join(" ")
+                )
+            }
+            (None, None) => String::new(),
+        };
+        self.query_editor.update(cx, |editor, cx| {
+            editor.set_placeholder_text(new_placeholder_text, cx);
+        });
+
         Flex::row()
             .with_child(
                 Flex::row()
@@ -258,6 +303,7 @@ impl BufferSearchBar {
             pending_search: None,
             query_contains_error: false,
             dismissed: true,
+            search_history: SearchHistory::default(),
         }
     }
 
@@ -341,7 +387,7 @@ impl BufferSearchBar {
         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 {
+        if query != self.query(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);
@@ -674,7 +720,7 @@ impl BufferSearchBar {
 
     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);
+        let query = self.query(cx);
         self.pending_search.take();
         if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
             if query.is_empty() {
@@ -707,6 +753,7 @@ impl BufferSearchBar {
                     )
                 };
 
+                let query_text = query.as_str().to_string();
                 let matches = active_searchable_item.find_matches(query, cx);
 
                 let active_searchable_item = active_searchable_item.downgrade();
@@ -720,6 +767,7 @@ impl BufferSearchBar {
                                 .insert(active_searchable_item.downgrade(), matches);
 
                             this.update_match_index(cx);
+                            this.search_history.add(query_text);
                             if !this.dismissed {
                                 let matches = this
                                     .searchable_items_with_matches
@@ -753,6 +801,28 @@ impl BufferSearchBar {
             cx.notify();
         }
     }
+
+    fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
+        if let Some(new_query) = self.search_history.next().map(str::to_string) {
+            let _ = self.search(&new_query, Some(self.search_options), cx);
+        } else {
+            self.search_history.reset_selection();
+            let _ = self.search("", Some(self.search_options), cx);
+        }
+    }
+
+    fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
+        if self.query(cx).is_empty() {
+            if let Some(new_query) = self.search_history.current().map(str::to_string) {
+                let _ = self.search(&new_query, Some(self.search_options), cx);
+                return;
+            }
+        }
+
+        if let Some(new_query) = self.search_history.previous().map(str::to_string) {
+            let _ = self.search(&new_query, Some(self.search_options), cx);
+        }
+    }
 }
 
 #[cfg(test)]
@@ -1333,4 +1403,154 @@ mod tests {
             );
         });
     }
+
+    #[gpui::test]
+    async fn test_search_query_history(cx: &mut TestAppContext) {
+        crate::project_search::tests::init_test(cx);
+
+        let buffer_text = r#"
+        A regular expression (shortened as regex or regexp;[1] also referred to as
+        rational expression[2][3]) is a sequence of characters that specifies a search
+        pattern in text. Usually such patterns are used by string-searching algorithms
+        for "find" or "find and replace" operations on strings, or for input validation.
+        "#
+        .unindent();
+        let buffer = cx.add_model(|cx| Buffer::new(0, buffer_text, cx));
+        let (window_id, _root_view) = cx.add_window(|_| EmptyView);
+
+        let editor = cx.add_view(window_id, |cx| Editor::for_buffer(buffer.clone(), None, cx));
+
+        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(cx);
+            search_bar
+        });
+
+        // Add 3 search items into the history.
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("a", None, cx))
+            .await
+            .unwrap();
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("b", None, cx))
+            .await
+            .unwrap();
+        search_bar
+            .update(cx, |search_bar, cx| {
+                search_bar.search("c", Some(SearchOptions::CASE_SENSITIVE), cx)
+            })
+            .await
+            .unwrap();
+        // Ensure that the latest search is active.
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "c");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Next history query after the latest should set the query to the empty string.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // First previous query for empty current query should set the query to the latest.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "c");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Further previous items should go over the history in reverse order.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "b");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Previous items should never go behind the first history item.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "a");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "a");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Next items should go over the history in the original order.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "b");
+            assert_eq!(search_bar.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        search_bar
+            .update(cx, |search_bar, cx| search_bar.search("ba", None, cx))
+            .await
+            .unwrap();
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "ba");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+
+        // New search input should add another entry to history and move the selection to the end of the history.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "c");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "b");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "c");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "ba");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_bar.read_with(cx, |search_bar, cx| {
+            assert_eq!(search_bar.query(cx), "");
+            assert_eq!(search_bar.search_options, SearchOptions::NONE);
+        });
+    }
 }

crates/search/src/project_search.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
-    ToggleWholeWord,
+    NextHistoryQuery, PreviousHistoryQuery, SearchHistory, SearchOptions, SelectNextMatch,
+    SelectPrevMatch, ToggleCaseSensitive, ToggleRegex, ToggleWholeWord,
 };
 use anyhow::Context;
 use collections::HashMap;
@@ -56,6 +56,8 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(ProjectSearchBar::search_in_new);
     cx.add_action(ProjectSearchBar::select_next_match);
     cx.add_action(ProjectSearchBar::select_prev_match);
+    cx.add_action(ProjectSearchBar::next_history_query);
+    cx.add_action(ProjectSearchBar::previous_history_query);
     cx.capture_action(ProjectSearchBar::tab);
     cx.capture_action(ProjectSearchBar::tab_previous);
     add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
@@ -83,6 +85,7 @@ struct ProjectSearch {
     match_ranges: Vec<Range<Anchor>>,
     active_query: Option<SearchQuery>,
     search_id: usize,
+    search_history: SearchHistory,
 }
 
 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
@@ -131,6 +134,7 @@ impl ProjectSearch {
             match_ranges: Default::default(),
             active_query: None,
             search_id: 0,
+            search_history: SearchHistory::default(),
         }
     }
 
@@ -144,6 +148,7 @@ impl ProjectSearch {
             match_ranges: self.match_ranges.clone(),
             active_query: self.active_query.clone(),
             search_id: self.search_id,
+            search_history: self.search_history.clone(),
         })
     }
 
@@ -152,6 +157,7 @@ impl ProjectSearch {
             .project
             .update(cx, |project, cx| project.search(query.clone(), cx));
         self.search_id += 1;
+        self.search_history.add(query.as_str().to_string());
         self.active_query = Some(query);
         self.match_ranges.clear();
         self.pending_search = Some(cx.spawn_weak(|this, mut cx| async move {
@@ -202,6 +208,7 @@ impl ProjectSearch {
         });
         self.search_id += 1;
         self.match_ranges.clear();
+        self.search_history.add(query.as_str().to_string());
         self.pending_search = Some(cx.spawn(|this, mut cx| async move {
             let results = search?.await.log_err()?;
 
@@ -278,6 +285,49 @@ impl View for ProjectSearchView {
                 Cow::Borrowed("No results")
             };
 
+            let previous_query_keystrokes =
+                cx.binding_for_action(&PreviousHistoryQuery {})
+                    .map(|binding| {
+                        binding
+                            .keystrokes()
+                            .iter()
+                            .map(|k| k.to_string())
+                            .collect::<Vec<_>>()
+                    });
+            let next_query_keystrokes =
+                cx.binding_for_action(&NextHistoryQuery {}).map(|binding| {
+                    binding
+                        .keystrokes()
+                        .iter()
+                        .map(|k| k.to_string())
+                        .collect::<Vec<_>>()
+                });
+            let new_placeholder_text = match (previous_query_keystrokes, next_query_keystrokes) {
+                (Some(previous_query_keystrokes), Some(next_query_keystrokes)) => {
+                    format!(
+                        "Search ({}/{} for previous/next query)",
+                        previous_query_keystrokes.join(" "),
+                        next_query_keystrokes.join(" ")
+                    )
+                }
+                (None, Some(next_query_keystrokes)) => {
+                    format!(
+                        "Search ({} for next query)",
+                        next_query_keystrokes.join(" ")
+                    )
+                }
+                (Some(previous_query_keystrokes), None) => {
+                    format!(
+                        "Search ({} for previous query)",
+                        previous_query_keystrokes.join(" ")
+                    )
+                }
+                (None, None) => String::new(),
+            };
+            self.query_editor.update(cx, |editor, cx| {
+                editor.set_placeholder_text(new_placeholder_text, cx);
+            });
+
             MouseEventHandler::<Status, _>::new(0, cx, |_, _| {
                 Label::new(text, theme.search.results_status.clone())
                     .aligned()
@@ -1152,6 +1202,47 @@ impl ProjectSearchBar {
             false
         }
     }
+
+    fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext<Self>) {
+        if let Some(search_view) = self.active_project_search.as_ref() {
+            search_view.update(cx, |search_view, cx| {
+                let new_query = search_view.model.update(cx, |model, _| {
+                    if let Some(new_query) = model.search_history.next().map(str::to_string) {
+                        new_query
+                    } else {
+                        model.search_history.reset_selection();
+                        String::new()
+                    }
+                });
+                search_view.set_query(&new_query, cx);
+            });
+        }
+    }
+
+    fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext<Self>) {
+        if let Some(search_view) = self.active_project_search.as_ref() {
+            search_view.update(cx, |search_view, cx| {
+                if search_view.query_editor.read(cx).text(cx).is_empty() {
+                    if let Some(new_query) = search_view
+                        .model
+                        .read(cx)
+                        .search_history
+                        .current()
+                        .map(str::to_string)
+                    {
+                        search_view.set_query(&new_query, cx);
+                        return;
+                    }
+                }
+
+                if let Some(new_query) = search_view.model.update(cx, |model, _| {
+                    model.search_history.previous().map(str::to_string)
+                }) {
+                    search_view.set_query(&new_query, cx);
+                }
+            });
+        }
+    }
 }
 
 impl Entity for ProjectSearchBar {
@@ -1333,6 +1424,7 @@ pub mod tests {
     use editor::DisplayPoint;
     use gpui::{color::Color, executor::Deterministic, TestAppContext};
     use project::FakeFs;
+    use semantic_index::semantic_index_settings::SemanticIndexSettings;
     use serde_json::json;
     use settings::SettingsStore;
     use std::sync::Arc;
@@ -1758,6 +1850,192 @@ pub mod tests {
         });
     }
 
+    #[gpui::test]
+    async fn test_search_query_history(cx: &mut TestAppContext) {
+        init_test(cx);
+
+        let fs = FakeFs::new(cx.background());
+        fs.insert_tree(
+            "/dir",
+            json!({
+                "one.rs": "const ONE: usize = 1;",
+                "two.rs": "const TWO: usize = one::ONE + one::ONE;",
+                "three.rs": "const THREE: usize = one::ONE + two::TWO;",
+                "four.rs": "const FOUR: usize = one::ONE + three::THREE;",
+            }),
+        )
+        .await;
+        let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+        let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
+        workspace.update(cx, |workspace, cx| {
+            ProjectSearchView::deploy(workspace, &workspace::NewSearch, cx)
+        });
+
+        let search_view = cx.read(|cx| {
+            workspace
+                .read(cx)
+                .active_pane()
+                .read(cx)
+                .active_item()
+                .and_then(|item| item.downcast::<ProjectSearchView>())
+                .expect("Search view expected to appear after new search event trigger")
+        });
+
+        let search_bar = cx.add_view(window_id, |cx| {
+            let mut search_bar = ProjectSearchBar::new();
+            search_bar.set_active_pane_item(Some(&search_view), cx);
+            // search_bar.show(cx);
+            search_bar
+        });
+
+        // Add 3 search items into the history + another unsubmitted one.
+        search_view.update(cx, |search_view, cx| {
+            search_view.search_options = SearchOptions::CASE_SENSITIVE;
+            search_view
+                .query_editor
+                .update(cx, |query_editor, cx| query_editor.set_text("ONE", cx));
+            search_view.search(cx);
+        });
+        cx.foreground().run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            search_view
+                .query_editor
+                .update(cx, |query_editor, cx| query_editor.set_text("TWO", cx));
+            search_view.search(cx);
+        });
+        cx.foreground().run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            search_view
+                .query_editor
+                .update(cx, |query_editor, cx| query_editor.set_text("THREE", cx));
+            search_view.search(cx);
+        });
+        cx.foreground().run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            search_view.query_editor.update(cx, |query_editor, cx| {
+                query_editor.set_text("JUST_TEXT_INPUT", cx)
+            });
+        });
+        cx.foreground().run_until_parked();
+
+        // Ensure that the latest input with search settings is active.
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(
+                search_view.query_editor.read(cx).text(cx),
+                "JUST_TEXT_INPUT"
+            );
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Next history query after the latest should set the query to the empty string.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // First previous query for empty current query should set the query to the latest submitted one.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Further previous items should go over the history in reverse order.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Previous items should never go behind the first history item.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "ONE");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // Next items should go over the history in the original order.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        search_view.update(cx, |search_view, cx| {
+            search_view
+                .query_editor
+                .update(cx, |query_editor, cx| query_editor.set_text("TWO_NEW", cx));
+            search_view.search(cx);
+        });
+        cx.foreground().run_until_parked();
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+
+        // New search input should add another entry to history and move the selection to the end of the history.
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.previous_history_query(&PreviousHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "THREE");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "TWO_NEW");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+        search_bar.update(cx, |search_bar, cx| {
+            search_bar.next_history_query(&NextHistoryQuery, cx);
+        });
+        search_view.update(cx, |search_view, cx| {
+            assert_eq!(search_view.query_editor.read(cx).text(cx), "");
+            assert_eq!(search_view.search_options, SearchOptions::CASE_SENSITIVE);
+        });
+    }
+
     pub fn init_test(cx: &mut TestAppContext) {
         cx.foreground().forbid_parking();
         let fonts = cx.font_cache();
@@ -1767,6 +2045,7 @@ pub mod tests {
         cx.update(|cx| {
             cx.set_global(SettingsStore::test(cx));
             cx.set_global(ActiveSearches::default());
+            settings::register::<SemanticIndexSettings>(cx);
 
             theme::init((), cx);
             cx.update_global::<SettingsStore, _, _>(|store, _| {

crates/search/src/search.rs 🔗

@@ -3,6 +3,7 @@ pub use buffer_search::BufferSearchBar;
 use gpui::{actions, Action, AppContext};
 use project::search::SearchQuery;
 pub use project_search::{ProjectSearchBar, ProjectSearchView};
+use smallvec::SmallVec;
 
 pub mod buffer_search;
 pub mod project_search;
@@ -21,6 +22,8 @@ actions!(
         SelectNextMatch,
         SelectPrevMatch,
         SelectAllMatches,
+        NextHistoryQuery,
+        PreviousHistoryQuery,
     ]
 );
 
@@ -65,3 +68,187 @@ impl SearchOptions {
         options
     }
 }
+
+const SEARCH_HISTORY_LIMIT: usize = 20;
+
+#[derive(Default, Debug, Clone)]
+pub struct SearchHistory {
+    history: SmallVec<[String; SEARCH_HISTORY_LIMIT]>,
+    selected: Option<usize>,
+}
+
+impl SearchHistory {
+    pub fn add(&mut self, search_string: String) {
+        if let Some(i) = self.selected {
+            if search_string == self.history[i] {
+                return;
+            }
+        }
+
+        if let Some(previously_searched) = self.history.last_mut() {
+            if search_string.find(previously_searched.as_str()).is_some() {
+                *previously_searched = search_string;
+                self.selected = Some(self.history.len() - 1);
+                return;
+            }
+        }
+
+        self.history.push(search_string);
+        if self.history.len() > SEARCH_HISTORY_LIMIT {
+            self.history.remove(0);
+        }
+        self.selected = Some(self.history.len() - 1);
+    }
+
+    pub fn next(&mut self) -> Option<&str> {
+        let history_size = self.history.len();
+        if history_size == 0 {
+            return None;
+        }
+
+        let selected = self.selected?;
+        if selected == history_size - 1 {
+            return None;
+        }
+        let next_index = selected + 1;
+        self.selected = Some(next_index);
+        Some(&self.history[next_index])
+    }
+
+    pub fn current(&self) -> Option<&str> {
+        Some(&self.history[self.selected?])
+    }
+
+    pub fn previous(&mut self) -> Option<&str> {
+        let history_size = self.history.len();
+        if history_size == 0 {
+            return None;
+        }
+
+        let prev_index = match self.selected {
+            Some(selected_index) => {
+                if selected_index == 0 {
+                    return None;
+                } else {
+                    selected_index - 1
+                }
+            }
+            None => history_size - 1,
+        };
+
+        self.selected = Some(prev_index);
+        Some(&self.history[prev_index])
+    }
+
+    pub fn reset_selection(&mut self) {
+        self.selected = None;
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_add() {
+        let mut search_history = SearchHistory::default();
+        assert_eq!(
+            search_history.current(),
+            None,
+            "No current selection should be set fo the default search history"
+        );
+
+        search_history.add("rust".to_string());
+        assert_eq!(
+            search_history.current(),
+            Some("rust"),
+            "Newly added item should be selected"
+        );
+
+        // check if duplicates are not added
+        search_history.add("rust".to_string());
+        assert_eq!(
+            search_history.history.len(),
+            1,
+            "Should not add a duplicate"
+        );
+        assert_eq!(search_history.current(), Some("rust"));
+
+        // check if new string containing the previous string replaces it
+        search_history.add("rustlang".to_string());
+        assert_eq!(
+            search_history.history.len(),
+            1,
+            "Should replace previous item if it's a substring"
+        );
+        assert_eq!(search_history.current(), Some("rustlang"));
+
+        // push enough items to test SEARCH_HISTORY_LIMIT
+        for i in 0..SEARCH_HISTORY_LIMIT * 2 {
+            search_history.add(format!("item{i}"));
+        }
+        assert!(search_history.history.len() <= SEARCH_HISTORY_LIMIT);
+    }
+
+    #[test]
+    fn test_next_and_previous() {
+        let mut search_history = SearchHistory::default();
+        assert_eq!(
+            search_history.next(),
+            None,
+            "Default search history should not have a next item"
+        );
+
+        search_history.add("Rust".to_string());
+        assert_eq!(search_history.next(), None);
+        search_history.add("JavaScript".to_string());
+        assert_eq!(search_history.next(), None);
+        search_history.add("TypeScript".to_string());
+        assert_eq!(search_history.next(), None);
+
+        assert_eq!(search_history.current(), Some("TypeScript"));
+
+        assert_eq!(search_history.previous(), Some("JavaScript"));
+        assert_eq!(search_history.current(), Some("JavaScript"));
+
+        assert_eq!(search_history.previous(), Some("Rust"));
+        assert_eq!(search_history.current(), Some("Rust"));
+
+        assert_eq!(search_history.previous(), None);
+        assert_eq!(search_history.current(), Some("Rust"));
+
+        assert_eq!(search_history.next(), Some("JavaScript"));
+        assert_eq!(search_history.current(), Some("JavaScript"));
+
+        assert_eq!(search_history.next(), Some("TypeScript"));
+        assert_eq!(search_history.current(), Some("TypeScript"));
+
+        assert_eq!(search_history.next(), None);
+        assert_eq!(search_history.current(), Some("TypeScript"));
+    }
+
+    #[test]
+    fn test_reset_selection() {
+        let mut search_history = SearchHistory::default();
+        search_history.add("Rust".to_string());
+        search_history.add("JavaScript".to_string());
+        search_history.add("TypeScript".to_string());
+
+        assert_eq!(search_history.current(), Some("TypeScript"));
+        search_history.reset_selection();
+        assert_eq!(search_history.current(), None);
+        assert_eq!(
+            search_history.previous(),
+            Some("TypeScript"),
+            "Should start from the end after reset on previous item query"
+        );
+
+        search_history.previous();
+        assert_eq!(search_history.current(), Some("JavaScript"));
+        search_history.previous();
+        assert_eq!(search_history.current(), Some("Rust"));
+
+        search_history.reset_selection();
+        assert_eq!(search_history.current(), None);
+    }
+}

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

@@ -222,7 +222,7 @@ mod test {
         });
 
         search_bar.read_with(cx.cx, |bar, cx| {
-            assert_eq!(bar.query_editor.read(cx).text(cx), "cc");
+            assert_eq!(bar.query(cx), "cc");
         });
 
         deterministic.run_until_parked();

crates/vim/src/test.rs 🔗

@@ -99,7 +99,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), "");
+        assert_eq!(bar.query(cx), "");
     })
 }
 
@@ -175,7 +175,7 @@ async fn test_selection_on_search(cx: &mut gpui::TestAppContext) {
     });
 
     search_bar.read_with(cx.cx, |bar, cx| {
-        assert_eq!(bar.query_editor.read(cx).text(cx), "cc");
+        assert_eq!(bar.query(cx), "cc");
     });
 
     // wait for the query editor change event to fire.