diff --git a/Cargo.lock b/Cargo.lock index d55254bfbd..f1bfc1575e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -8471,7 +8471,6 @@ dependencies = [ "serde", "serde_json", "settings", - "smallvec", "smol", "theme", "ui", diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index b39fb423b1..ddff16fd5b 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -9,6 +9,7 @@ pub mod terminals; #[cfg(test)] mod project_tests; +pub mod search_history; use anyhow::{anyhow, bail, Context as _, Result}; use async_trait::async_trait; @@ -63,6 +64,7 @@ use postage::watch; use prettier_support::{DefaultPrettier, PrettierInstance}; use project_settings::{LspSettings, ProjectSettings}; use rand::prelude::*; +use search_history::SearchHistory; use worktree::LocalSnapshot; use rpc::{ErrorCode, ErrorExt as _}; @@ -123,6 +125,8 @@ const SERVER_REINSTALL_DEBOUNCE_TIMEOUT: Duration = Duration::from_secs(1); const SERVER_LAUNCHING_BEFORE_SHUTDOWN_TIMEOUT: Duration = Duration::from_secs(5); pub const SERVER_PROGRESS_DEBOUNCE_TIMEOUT: Duration = Duration::from_millis(100); +const MAX_PROJECT_SEARCH_HISTORY_SIZE: usize = 500; + pub trait Item { fn try_open( project: &Model, @@ -205,6 +209,7 @@ pub struct Project { prettier_instances: HashMap, tasks: Model, hosted_project_id: Option, + search_history: SearchHistory, } pub enum LanguageServerToQuery { @@ -670,6 +675,7 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + search_history: Self::new_search_history(), } }) } @@ -805,6 +811,7 @@ impl Project { prettier_instances: HashMap::default(), tasks, hosted_project_id: None, + search_history: Self::new_search_history(), }; this.set_role(role, cx); for worktree in worktrees { @@ -861,6 +868,13 @@ impl Project { .await } + fn new_search_history() -> SearchHistory { + SearchHistory::new( + Some(MAX_PROJECT_SEARCH_HISTORY_SIZE), + search_history::QueryInsertionBehavior::AlwaysInsert, + ) + } + fn release(&mut self, cx: &mut AppContext) { match &self.client_state { ProjectClientState::Local => {} @@ -1127,6 +1141,14 @@ impl Project { &self.tasks } + pub fn search_history(&self) -> &SearchHistory { + &self.search_history + } + + pub fn search_history_mut(&mut self) -> &mut SearchHistory { + &mut self.search_history + } + pub fn collaborators(&self) -> &HashMap { &self.collaborators } diff --git a/crates/project/src/search_history.rs b/crates/project/src/search_history.rs new file mode 100644 index 0000000000..f1f986b243 --- /dev/null +++ b/crates/project/src/search_history.rs @@ -0,0 +1,261 @@ +/// Determines the behavior to use when inserting a new query into the search history. +#[derive(Default, Debug, Clone, PartialEq)] +pub enum QueryInsertionBehavior { + #[default] + /// Always insert the query to the search history. + AlwaysInsert, + /// Replace the previous query in the search history, if the new query contains the previous query. + ReplacePreviousIfContains, +} + +/// A cursor that stores an index to the currently selected query in the search history. +/// This can be passed to the search history to update the selection accordingly, +/// e.g. when using the up and down arrow keys to navigate the search history. +/// +/// Note: The cursor can point to the wrong query, if the maximum length of the history is exceeded +/// and the old query is overwritten. +#[derive(Default, Debug, Clone, PartialEq, Eq, Hash)] +pub struct SearchHistoryCursor { + selection: Option, +} + +impl SearchHistoryCursor { + /// Resets the selection to `None`. + pub fn reset(&mut self) { + self.selection = None; + } +} + +#[derive(Debug, Clone)] +pub struct SearchHistory { + history: Vec, + max_history_len: Option, + insertion_behavior: QueryInsertionBehavior, +} + +impl SearchHistory { + pub fn new(max_history_len: Option, insertion_behavior: QueryInsertionBehavior) -> Self { + SearchHistory { + max_history_len, + insertion_behavior, + history: Vec::new(), + } + } + + pub fn add(&mut self, cursor: &mut SearchHistoryCursor, search_string: String) { + if let Some(selected_ix) = cursor.selection { + if self.history.get(selected_ix) == Some(&search_string) { + return; + } + } + + if self.insertion_behavior == QueryInsertionBehavior::ReplacePreviousIfContains { + if let Some(previously_searched) = self.history.last_mut() { + if search_string.contains(previously_searched.as_str()) { + *previously_searched = search_string; + cursor.selection = Some(self.history.len() - 1); + return; + } + } + } + + self.history.push(search_string); + if let Some(max_history_len) = self.max_history_len { + if self.history.len() > max_history_len { + self.history.remove(0); + } + } + + cursor.selection = Some(self.history.len() - 1); + } + + pub fn next(&mut self, cursor: &mut SearchHistoryCursor) -> Option<&str> { + let history_size = self.history.len(); + if history_size == 0 { + return None; + } + + let selected = cursor.selection?; + if selected == history_size - 1 { + return None; + } + let next_index = selected + 1; + cursor.selection = Some(next_index); + Some(&self.history[next_index]) + } + + pub fn current(&self, cursor: &SearchHistoryCursor) -> Option<&str> { + cursor + .selection + .and_then(|selected_ix| self.history.get(selected_ix).map(|s| s.as_str())) + } + + pub fn previous(&mut self, cursor: &mut SearchHistoryCursor) -> Option<&str> { + let history_size = self.history.len(); + if history_size == 0 { + return None; + } + + let prev_index = match cursor.selection { + Some(selected_index) => { + if selected_index == 0 { + return None; + } else { + selected_index - 1 + } + } + None => history_size - 1, + }; + + cursor.selection = Some(prev_index); + Some(&self.history[prev_index]) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_add() { + const MAX_HISTORY_LEN: usize = 20; + let mut search_history = SearchHistory::new( + Some(MAX_HISTORY_LEN), + QueryInsertionBehavior::ReplacePreviousIfContains, + ); + let mut cursor = SearchHistoryCursor::default(); + + assert_eq!( + search_history.current(&cursor), + None, + "No current selection should be set for the default search history" + ); + + search_history.add(&mut cursor, "rust".to_string()); + assert_eq!( + search_history.current(&cursor), + Some("rust"), + "Newly added item should be selected" + ); + + // check if duplicates are not added + search_history.add(&mut cursor, "rust".to_string()); + assert_eq!( + search_history.history.len(), + 1, + "Should not add a duplicate" + ); + assert_eq!(search_history.current(&cursor), Some("rust")); + + // check if new string containing the previous string replaces it + search_history.add(&mut cursor, "rustlang".to_string()); + assert_eq!( + search_history.history.len(), + 1, + "Should replace previous item if it's a substring" + ); + assert_eq!(search_history.current(&cursor), Some("rustlang")); + + // push enough items to test SEARCH_HISTORY_LIMIT + for i in 0..MAX_HISTORY_LEN * 2 { + search_history.add(&mut cursor, format!("item{i}")); + } + assert!(search_history.history.len() <= MAX_HISTORY_LEN); + } + + #[test] + fn test_next_and_previous() { + let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert); + let mut cursor = SearchHistoryCursor::default(); + + assert_eq!( + search_history.next(&mut cursor), + None, + "Default search history should not have a next item" + ); + + search_history.add(&mut cursor, "Rust".to_string()); + assert_eq!(search_history.next(&mut cursor), None); + search_history.add(&mut cursor, "JavaScript".to_string()); + assert_eq!(search_history.next(&mut cursor), None); + search_history.add(&mut cursor, "TypeScript".to_string()); + assert_eq!(search_history.next(&mut cursor), None); + + assert_eq!(search_history.current(&cursor), Some("TypeScript")); + + assert_eq!(search_history.previous(&mut cursor), Some("JavaScript")); + assert_eq!(search_history.current(&cursor), Some("JavaScript")); + + assert_eq!(search_history.previous(&mut cursor), Some("Rust")); + assert_eq!(search_history.current(&cursor), Some("Rust")); + + assert_eq!(search_history.previous(&mut cursor), None); + assert_eq!(search_history.current(&cursor), Some("Rust")); + + assert_eq!(search_history.next(&mut cursor), Some("JavaScript")); + assert_eq!(search_history.current(&cursor), Some("JavaScript")); + + assert_eq!(search_history.next(&mut cursor), Some("TypeScript")); + assert_eq!(search_history.current(&cursor), Some("TypeScript")); + + assert_eq!(search_history.next(&mut cursor), None); + assert_eq!(search_history.current(&cursor), Some("TypeScript")); + } + + #[test] + fn test_reset_selection() { + let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert); + let mut cursor = SearchHistoryCursor::default(); + + search_history.add(&mut cursor, "Rust".to_string()); + search_history.add(&mut cursor, "JavaScript".to_string()); + search_history.add(&mut cursor, "TypeScript".to_string()); + + assert_eq!(search_history.current(&cursor), Some("TypeScript")); + cursor.reset(); + assert_eq!(search_history.current(&mut cursor), None); + assert_eq!( + search_history.previous(&mut cursor), + Some("TypeScript"), + "Should start from the end after reset on previous item query" + ); + + search_history.previous(&mut cursor); + assert_eq!(search_history.current(&cursor), Some("JavaScript")); + search_history.previous(&mut cursor); + assert_eq!(search_history.current(&cursor), Some("Rust")); + + cursor.reset(); + assert_eq!(search_history.current(&cursor), None); + } + + #[test] + fn test_multiple_cursors() { + let mut search_history = SearchHistory::new(None, QueryInsertionBehavior::AlwaysInsert); + let mut cursor1 = SearchHistoryCursor::default(); + let mut cursor2 = SearchHistoryCursor::default(); + + search_history.add(&mut cursor1, "Rust".to_string()); + search_history.add(&mut cursor1, "JavaScript".to_string()); + search_history.add(&mut cursor1, "TypeScript".to_string()); + + search_history.add(&mut cursor2, "Python".to_string()); + search_history.add(&mut cursor2, "Java".to_string()); + search_history.add(&mut cursor2, "C++".to_string()); + + assert_eq!(search_history.current(&cursor1), Some("TypeScript")); + assert_eq!(search_history.current(&cursor2), Some("C++")); + + assert_eq!(search_history.previous(&mut cursor1), Some("JavaScript")); + assert_eq!(search_history.previous(&mut cursor2), Some("Java")); + + assert_eq!(search_history.next(&mut cursor1), Some("TypeScript")); + assert_eq!(search_history.next(&mut cursor1), Some("Python")); + + cursor1.reset(); + cursor2.reset(); + + assert_eq!(search_history.current(&cursor1), None); + assert_eq!(search_history.current(&cursor2), None); + } +} diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index a6546f6ae7..943bfea56b 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -25,7 +25,6 @@ project.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true -smallvec.workspace = true smol.workspace = true theme.workspace = true ui.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index d1497cf49f..fc10cfd788 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -1,7 +1,6 @@ mod registrar; use crate::{ - history::SearchHistory, mode::{next_mode, SearchMode}, search_bar::render_nav_button, ActivateRegexMode, ActivateTextMode, CycleMode, NextHistoryQuery, PreviousHistoryQuery, @@ -20,7 +19,10 @@ use gpui::{ ParentElement as _, Render, Styled, Subscription, Task, TextStyle, View, ViewContext, VisualContext as _, WhiteSpace, WindowContext, }; -use project::search::SearchQuery; +use project::{ + search::SearchQuery, + search_history::{SearchHistory, SearchHistoryCursor}, +}; use serde::Deserialize; use settings::Settings; use std::{any::Any, sync::Arc}; @@ -39,6 +41,7 @@ use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; const MIN_INPUT_WIDTH_REMS: f32 = 15.; const MAX_INPUT_WIDTH_REMS: f32 = 30.; +const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; #[derive(PartialEq, Clone, Deserialize)] pub struct Deploy { @@ -75,6 +78,7 @@ pub struct BufferSearchBar { query_contains_error: bool, dismissed: bool, search_history: SearchHistory, + search_history_cursor: SearchHistoryCursor, current_mode: SearchMode, replace_enabled: bool, } @@ -526,6 +530,7 @@ impl BufferSearchBar { let replacement_editor = cx.new_view(|cx| Editor::single_line(cx)); cx.subscribe(&replacement_editor, Self::on_replacement_editor_event) .detach(); + Self { query_editor, query_editor_focused: false, @@ -540,7 +545,11 @@ impl BufferSearchBar { pending_search: None, query_contains_error: false, dismissed: true, - search_history: SearchHistory::default(), + search_history: SearchHistory::new( + Some(MAX_BUFFER_SEARCH_HISTORY_SIZE), + project::search_history::QueryInsertionBehavior::ReplacePreviousIfContains, + ), + search_history_cursor: Default::default(), current_mode: SearchMode::default(), active_search: None, replace_enabled: false, @@ -934,7 +943,8 @@ impl BufferSearchBar { .insert(active_searchable_item.downgrade(), matches); this.update_match_index(cx); - this.search_history.add(query_text); + this.search_history + .add(&mut this.search_history_cursor, query_text); if !this.dismissed { let matches = this .searchable_items_with_matches @@ -996,23 +1006,35 @@ impl BufferSearchBar { } fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext) { - if let Some(new_query) = self.search_history.next().map(str::to_string) { + if let Some(new_query) = self + .search_history + .next(&mut self.search_history_cursor) + .map(str::to_string) + { let _ = self.search(&new_query, Some(self.search_options), cx); } else { - self.search_history.reset_selection(); + self.search_history_cursor.reset(); let _ = self.search("", Some(self.search_options), cx); } } fn previous_history_query(&mut self, _: &PreviousHistoryQuery, cx: &mut ViewContext) { if self.query(cx).is_empty() { - if let Some(new_query) = self.search_history.current().map(str::to_string) { + if let Some(new_query) = self + .search_history + .current(&mut self.search_history_cursor) + .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) { + if let Some(new_query) = self + .search_history + .previous(&mut self.search_history_cursor) + .map(str::to_string) + { let _ = self.search(&new_query, Some(self.search_options), cx); } } diff --git a/crates/search/src/history.rs b/crates/search/src/history.rs deleted file mode 100644 index 9d76d48e85..0000000000 --- a/crates/search/src/history.rs +++ /dev/null @@ -1,184 +0,0 @@ -use smallvec::SmallVec; -const SEARCH_HISTORY_LIMIT: usize = 20; - -#[derive(Default, Debug, Clone)] -pub struct SearchHistory { - history: SmallVec<[String; SEARCH_HISTORY_LIMIT]>, - selected: Option, -} - -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.contains(previously_searched.as_str()) { - *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 for 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); - } -} diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 15f3d6184d..1a93669cee 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -1,8 +1,7 @@ use crate::{ - history::SearchHistory, mode::SearchMode, ActivateRegexMode, ActivateTextMode, CycleMode, - NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, - SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleIncludeIgnored, ToggleReplace, - ToggleWholeWord, + mode::SearchMode, ActivateRegexMode, ActivateTextMode, CycleMode, NextHistoryQuery, + PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectNextMatch, SelectPrevMatch, + ToggleCaseSensitive, ToggleIncludeIgnored, ToggleReplace, ToggleWholeWord, }; use anyhow::Context as _; use collections::{HashMap, HashSet}; @@ -20,7 +19,7 @@ use gpui::{ WeakModel, WeakView, WhiteSpace, WindowContext, }; use menu::Confirm; -use project::{search::SearchQuery, Project}; +use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project}; use settings::Settings; use smol::stream::StreamExt; use std::{ @@ -125,10 +124,11 @@ struct ProjectSearch { pending_search: Option>>, match_ranges: Vec>, active_query: Option, + last_search_query_text: Option, search_id: usize, - search_history: SearchHistory, no_results: Option, limit_reached: bool, + search_history_cursor: SearchHistoryCursor, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] @@ -180,10 +180,11 @@ impl ProjectSearch { pending_search: Default::default(), match_ranges: Default::default(), active_query: None, + last_search_query_text: None, search_id: 0, - search_history: SearchHistory::default(), no_results: None, limit_reached: false, + search_history_cursor: Default::default(), } } @@ -196,19 +197,23 @@ impl ProjectSearch { pending_search: Default::default(), match_ranges: self.match_ranges.clone(), active_query: self.active_query.clone(), + last_search_query_text: self.last_search_query_text.clone(), search_id: self.search_id, - search_history: self.search_history.clone(), no_results: self.no_results, limit_reached: self.limit_reached, + search_history_cursor: self.search_history_cursor.clone(), }) } fn search(&mut self, query: SearchQuery, cx: &mut ModelContext) { - let search = self - .project - .update(cx, |project, cx| project.search(query.clone(), cx)); + let search = self.project.update(cx, |project, cx| { + project + .search_history_mut() + .add(&mut self.search_history_cursor, query.as_str().to_string()); + project.search(query.clone(), cx) + }); + self.last_search_query_text = Some(query.as_str().to_string()); 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(|this, mut cx| async move { @@ -368,8 +373,7 @@ impl Item for ProjectSearchView { let last_query: Option = self .model .read(cx) - .search_history - .current() + .last_search_query_text .as_ref() .map(|query| { let query = query.replace('\n', ""); @@ -1270,11 +1274,16 @@ impl ProjectSearchBar { fn next_history_query(&mut self, _: &NextHistoryQuery, cx: &mut ViewContext) { 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) { + let new_query = search_view.model.update(cx, |model, cx| { + if let Some(new_query) = model.project.update(cx, |project, _| { + project + .search_history_mut() + .next(&mut model.search_history_cursor) + .map(str::to_string) + }) { new_query } else { - model.search_history.reset_selection(); + model.search_history_cursor.reset(); String::new() } }); @@ -1290,8 +1299,10 @@ impl ProjectSearchBar { if let Some(new_query) = search_view .model .read(cx) - .search_history - .current() + .project + .read(cx) + .search_history() + .current(&search_view.model.read(cx).search_history_cursor) .map(str::to_string) { search_view.set_query(&new_query, cx); @@ -1299,8 +1310,13 @@ impl ProjectSearchBar { } } - if let Some(new_query) = search_view.model.update(cx, |model, _| { - model.search_history.previous().map(str::to_string) + if let Some(new_query) = search_view.model.update(cx, |model, cx| { + model.project.update(cx, |project, _| { + project + .search_history_mut() + .previous(&mut model.search_history_cursor) + .map(str::to_string) + }) }) { search_view.set_query(&new_query, cx); } @@ -2904,6 +2920,222 @@ pub mod tests { .unwrap(); } + #[gpui::test] + async fn test_search_query_history_with_multiple_views(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/dir", + json!({ + "one.rs": "const ONE: usize = 1;", + }), + ) + .await; + let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await; + let worktree_id = project.update(cx, |this, cx| { + this.worktrees().next().unwrap().read(cx).id() + }); + + let window = cx.add_window(|cx| Workspace::test_new(project, cx)); + let workspace = window.root(cx).unwrap(); + + let panes: Vec<_> = window + .update(cx, |this, _| this.panes().to_owned()) + .unwrap(); + + let search_bar_1 = window.build_view(cx, |_| ProjectSearchBar::new()); + let search_bar_2 = window.build_view(cx, |_| ProjectSearchBar::new()); + + assert_eq!(panes.len(), 1); + let first_pane = panes.get(0).cloned().unwrap(); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 0); + window + .update(cx, |workspace, cx| { + workspace.open_path( + (worktree_id, "one.rs"), + Some(first_pane.downgrade()), + true, + cx, + ) + }) + .unwrap() + .await + .unwrap(); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 1); + + // Add a project search item to the first pane + window + .update(cx, { + let search_bar = search_bar_1.clone(); + let pane = first_pane.clone(); + move |workspace, cx| { + pane.update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx)) + }); + + ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx) + } + }) + .unwrap(); + let search_view_1 = cx.read(|cx| { + workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("Search view expected to appear after new search event trigger") + }); + + let second_pane = window + .update(cx, |workspace, cx| { + workspace.split_and_clone(first_pane.clone(), workspace::SplitDirection::Right, cx) + }) + .unwrap() + .unwrap(); + assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); + + assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 1); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2); + + // Add a project search item to the second pane + window + .update(cx, { + let search_bar = search_bar_2.clone(); + let pane = second_pane.clone(); + move |workspace, cx| { + assert_eq!(workspace.panes().len(), 2); + pane.update(cx, move |pane, cx| { + pane.toolbar() + .update(cx, |toolbar, cx| toolbar.add_item(search_bar, cx)) + }); + + ProjectSearchView::new_search(workspace, &workspace::NewSearch, cx) + } + }) + .unwrap(); + + let search_view_2 = cx.read(|cx| { + workspace + .read(cx) + .active_item(cx) + .and_then(|item| item.downcast::()) + .expect("Search view expected to appear after new search event trigger") + }); + + cx.run_until_parked(); + assert_eq!(cx.update(|cx| first_pane.read(cx).items_len()), 2); + assert_eq!(cx.update(|cx| second_pane.read(cx).items_len()), 2); + + let update_search_view = + |search_view: &View, query: &str, cx: &mut TestAppContext| { + window + .update(cx, |_, cx| { + search_view.update(cx, |search_view, cx| { + search_view + .query_editor + .update(cx, |query_editor, cx| query_editor.set_text(query, cx)); + search_view.search(cx); + }); + }) + .unwrap(); + }; + + let active_query = + |search_view: &View, cx: &mut TestAppContext| -> String { + window + .update(cx, |_, cx| { + search_view.update(cx, |search_view, cx| { + search_view.query_editor.read(cx).text(cx).to_string() + }) + }) + .unwrap() + }; + + let select_prev_history_item = + |search_bar: &View, cx: &mut TestAppContext| { + window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.previous_history_query(&PreviousHistoryQuery, cx); + }) + }) + .unwrap(); + }; + + let select_next_history_item = + |search_bar: &View, cx: &mut TestAppContext| { + window + .update(cx, |_, cx| { + search_bar.update(cx, |search_bar, cx| { + search_bar.next_history_query(&NextHistoryQuery, cx); + }) + }) + .unwrap(); + }; + + update_search_view(&search_view_1, "ONE", cx); + cx.background_executor.run_until_parked(); + + update_search_view(&search_view_2, "TWO", cx); + cx.background_executor.run_until_parked(); + + assert_eq!(active_query(&search_view_1, cx), "ONE"); + assert_eq!(active_query(&search_view_2, cx), "TWO"); + + // Selecting previous history item should select the query from search view 1. + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "ONE"); + + // Selecting the previous history item should not change the query as it is already the first item. + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "ONE"); + + // Changing the query in search view 2 should not affect the history of search view 1. + assert_eq!(active_query(&search_view_1, cx), "ONE"); + + // Deploying a new search in search view 2 + update_search_view(&search_view_2, "THREE", cx); + cx.background_executor.run_until_parked(); + + select_next_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), ""); + + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "THREE"); + + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "TWO"); + + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "ONE"); + + select_prev_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "ONE"); + + // Search view 1 should now see the query from search view 2. + assert_eq!(active_query(&search_view_1, cx), "ONE"); + + select_next_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "TWO"); + + // Here is the new query from search view 2 + select_next_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), "THREE"); + + select_next_history_item(&search_bar_2, cx); + assert_eq!(active_query(&search_view_2, cx), ""); + + select_next_history_item(&search_bar_1, cx); + assert_eq!(active_query(&search_view_1, cx), "TWO"); + + select_next_history_item(&search_bar_1, cx); + assert_eq!(active_query(&search_view_1, cx), "THREE"); + + select_next_history_item(&search_bar_1, cx); + assert_eq!(active_query(&search_view_1, cx), ""); + } + #[gpui::test] async fn test_deploy_search_with_multiple_panes(cx: &mut TestAppContext) { init_test(cx); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index a585c15361..a071f0fd70 100644 --- a/crates/search/src/search.rs +++ b/crates/search/src/search.rs @@ -8,7 +8,6 @@ use ui::{prelude::*, Tooltip}; use ui::{ButtonStyle, IconButton}; pub mod buffer_search; -mod history; mod mode; pub mod project_search; pub(crate) mod search_bar;