project search: Persist search history across session (#9932)

Partially implements #9717, persistence between restarts is currently
missing, but I would like to get feedback on the implementation first.

Previously the search history was not saved across different project
searches. As the `SearchHistory` is now maintained inside of the
project, it can be persisted across different project searches.

I also removed the behavior that a new query replaces the previous
search query, if it contains the text of the previous query.
I believe this was only intended to make buffer search work, therefore I
disabled this behavior but only for the project search.

Currently when you navigated through the queries the tab title changed
even if the search was not started, which doesn't make sense to me.
Current behavior:


https://github.com/zed-industries/zed/assets/53836821/1c365702-e93c-4cab-a1eb-0af3fef95476


With this PR the tab header will actually keep the search name until you
start another search again.

---

Showcase:


https://github.com/zed-industries/zed/assets/53836821/c0d6e496-915f-44bc-be16-12d7c3cda2d7


Release Notes:

- Added support for persisting project search history across a session
- Fixed tab header of project search changing when cycling through
search history, even when there is no search submitted
This commit is contained in:
Bennet Bo Fenner 2024-04-02 11:13:18 +02:00 committed by GitHub
parent c15b9d4e1c
commit 1dbd520cc9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 566 additions and 216 deletions

View file

@ -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<Project>,
@ -205,6 +209,7 @@ pub struct Project {
prettier_instances: HashMap<PathBuf, PrettierInstance>,
tasks: Model<Inventory>,
hosted_project_id: Option<ProjectId>,
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<proto::PeerId, Collaborator> {
&self.collaborators
}

View file

@ -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<usize>,
}
impl SearchHistoryCursor {
/// Resets the selection to `None`.
pub fn reset(&mut self) {
self.selection = None;
}
}
#[derive(Debug, Clone)]
pub struct SearchHistory {
history: Vec<String>,
max_history_len: Option<usize>,
insertion_behavior: QueryInsertionBehavior,
}
impl SearchHistory {
pub fn new(max_history_len: Option<usize>, 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);
}
}