Detailed changes
@@ -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": {
@@ -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| {
@@ -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);
+ });
+ }
}
@@ -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, _| {
@@ -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);
+ }
+}
@@ -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();
@@ -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.