Cargo.lock 🔗
@@ -4126,7 +4126,6 @@ dependencies = [
"futures 0.3.30",
"fuzzy",
"gpui",
- "itertools 0.11.0",
"language",
"menu",
"picker",
kshokhin created
Closes #11737
If the query has not changed and entry is still in the matches list keep
it selected
Release Notes:
- Fixes file finder jumping during background updates ([#11737](https://github.com/zed-industries/zed/issues/11737))
Cargo.lock | 1
crates/file_finder/Cargo.toml | 1
crates/file_finder/src/file_finder.rs | 328 ++++++++++++++--------
crates/file_finder/src/file_finder_tests.rs | 168 +++++++++++
4 files changed, 373 insertions(+), 125 deletions(-)
@@ -4126,7 +4126,6 @@ dependencies = [
"futures 0.3.30",
"fuzzy",
"gpui",
- "itertools 0.11.0",
"language",
"menu",
"picker",
@@ -19,7 +19,6 @@ editor.workspace = true
futures.workspace = true
fuzzy.workspace = true
gpui.workspace = true
-itertools = "0.11"
menu.workspace = true
picker.workspace = true
project.workspace = true
@@ -4,7 +4,7 @@ mod file_finder_tests;
mod new_path_prompt;
mod open_path_prompt;
-use collections::{BTreeSet, HashMap};
+use collections::HashMap;
use editor::{scroll::Autoscroll, Bias, Editor};
use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
use gpui::{
@@ -12,7 +12,6 @@ use gpui::{
FocusableView, Model, Modifiers, ModifiersChangedEvent, ParentElement, Render, Styled, Task,
View, ViewContext, VisualContext, WeakView,
};
-use itertools::Itertools;
use new_path_prompt::NewPathPrompt;
use open_path_prompt::OpenPathPrompt;
use picker::{Picker, PickerDelegate};
@@ -166,6 +165,7 @@ pub struct FileFinderDelegate {
cancel_flag: Arc<AtomicBool>,
history_items: Vec<FoundPath>,
separate_history: bool,
+ first_update: bool,
}
/// Use a custom ordering for file finder: the regular one
@@ -209,10 +209,29 @@ struct Matches {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord, Clone)]
enum Match {
- History(FoundPath, Option<ProjectPanelOrdMatch>),
+ History {
+ path: FoundPath,
+ panel_match: Option<ProjectPanelOrdMatch>,
+ },
Search(ProjectPanelOrdMatch),
}
+impl Match {
+ fn path(&self) -> &Arc<Path> {
+ match self {
+ Match::History { path, .. } => &path.project.path,
+ Match::Search(panel_match) => &panel_match.0.path,
+ }
+ }
+
+ fn panel_match(&self) -> Option<&ProjectPanelOrdMatch> {
+ match self {
+ Match::History { panel_match, .. } => panel_match.as_ref(),
+ Match::Search(panel_match) => Some(&panel_match),
+ }
+ }
+}
+
impl Matches {
fn len(&self) -> usize {
self.matches.len()
@@ -222,6 +241,33 @@ impl Matches {
self.matches.get(index)
}
+ fn position(
+ &self,
+ entry: &Match,
+ currently_opened: Option<&FoundPath>,
+ ) -> Result<usize, usize> {
+ if let Match::History {
+ path,
+ panel_match: None,
+ } = entry
+ {
+ // Slow case: linear search by path. Should not happen actually,
+ // since we call `position` only if matches set changed, but the query has not changed.
+ // And History entries do not have panel_match if query is empty, so there's no
+ // reason for the matches set to change.
+ self.matches
+ .iter()
+ .position(|m| path.project.path == *m.path())
+ .ok_or(0)
+ } else {
+ self.matches.binary_search_by(|m| {
+ // `reverse()` since if cmp_matches(a, b) == Ordering::Greater, then a is better than b.
+ // And we want the better entries go first.
+ Self::cmp_matches(self.separate_history, currently_opened, &m, &entry).reverse()
+ })
+ }
+ }
+
fn push_new_matches<'a>(
&'a mut self,
history_items: impl IntoIterator<Item = &'a FoundPath> + Clone,
@@ -230,88 +276,95 @@ impl Matches {
new_search_matches: impl Iterator<Item = ProjectPanelOrdMatch>,
extend_old_matches: bool,
) {
- let no_history_score = 0;
- let matching_history_paths =
- matching_history_item_paths(history_items.clone(), currently_opened, query);
- let new_search_matches = new_search_matches
- .filter(|path_match| !matching_history_paths.contains_key(&path_match.0.path))
- .map(Match::Search)
- .map(|m| (no_history_score, m));
- let old_search_matches = self
- .matches
- .drain(..)
- .filter(|_| extend_old_matches)
- .filter(|m| matches!(m, Match::Search(_)))
- .map(|m| (no_history_score, m));
- let history_matches = history_items
- .into_iter()
- .chain(currently_opened)
- .enumerate()
- .filter_map(|(i, history_item)| {
- let query_match = matching_history_paths
- .get(&history_item.project.path)
- .cloned();
- let query_match = if query.is_some() {
- query_match?
- } else {
- query_match.flatten()
- };
- Some((i + 1, Match::History(history_item.clone(), query_match)))
- });
+ let Some(query) = query else {
+ // assuming that if there's no query, then there's no search matches.
+ self.matches.clear();
+ let path_to_entry = |found_path: &FoundPath| Match::History {
+ path: found_path.clone(),
+ panel_match: None,
+ };
+ self.matches
+ .extend(currently_opened.into_iter().map(path_to_entry));
+
+ self.matches.extend(
+ history_items
+ .into_iter()
+ .filter(|found_path| Some(*found_path) != currently_opened)
+ .map(path_to_entry),
+ );
+ return;
+ };
- let mut unique_matches = BTreeSet::new();
- self.matches = old_search_matches
- .chain(history_matches)
- .chain(new_search_matches)
- .filter(|(_, m)| unique_matches.insert(m.clone()))
- .sorted_by(|(history_score_a, a), (history_score_b, b)| {
- match (a, b) {
- // bubble currently opened files to the top
- (Match::History(path, _), _) if Some(path) == currently_opened => {
- cmp::Ordering::Less
- }
- (_, Match::History(path, _)) if Some(path) == currently_opened => {
- cmp::Ordering::Greater
- }
+ let new_history_matches = matching_history_items(history_items, currently_opened, query);
+ let new_search_matches: Vec<Match> = new_search_matches
+ .filter(|path_match| !new_history_matches.contains_key(&path_match.0.path))
+ .map(Match::Search)
+ .collect();
- (Match::History(_, _), Match::Search(_)) if self.separate_history => {
- cmp::Ordering::Less
- }
- (Match::Search(_), Match::History(_, _)) if self.separate_history => {
- cmp::Ordering::Greater
- }
+ if extend_old_matches {
+ // since we take history matches instead of new search matches
+ // and history matches has not changed(since the query has not changed and we do not extend old matches otherwise),
+ // old matches can't contain paths present in history_matches as well.
+ self.matches.retain(|m| matches!(m, Match::Search(_)));
+ } else {
+ self.matches.clear();
+ }
- (Match::History(_, match_a), Match::History(_, match_b)) => {
- match_b.cmp(match_a)
- }
- (Match::History(_, match_a), Match::Search(match_b)) => {
- Some(match_b).cmp(&match_a.as_ref())
- }
- (Match::Search(match_a), Match::History(_, match_b)) => {
- match_b.as_ref().cmp(&Some(match_a))
+ // At this point we have an unsorted set of new history matches, an unsorted set of new search matches
+ // and a sorted set of old search matches.
+ // It is possible that the new search matches' paths contain some of the old search matches' paths.
+ // History matches' paths are unique, since store in a HashMap by path.
+ // We build a sorted Vec<Match>, eliminating duplicate search matches.
+ // Search matches with the same paths should have equal `ProjectPanelOrdMatch`, so we should
+ // not have any duplicates after building the final list.
+ for new_match in new_history_matches
+ .into_values()
+ .chain(new_search_matches.into_iter())
+ {
+ match self.position(&new_match, currently_opened) {
+ Ok(_duplicate) => continue,
+ Err(i) => {
+ self.matches.insert(i, new_match);
+ if self.matches.len() == 100 {
+ break;
}
- (Match::Search(match_a), Match::Search(match_b)) => match_b.cmp(match_a),
}
- .then(history_score_a.cmp(history_score_b))
- })
- .take(100)
- .map(|(_, m)| m)
- .collect();
+ }
+ }
+ }
+
+ /// If a < b, then a is a worse match, aligning with the `ProjectPanelOrdMatch` ordering.
+ fn cmp_matches(
+ separate_history: bool,
+ currently_opened: Option<&FoundPath>,
+ a: &Match,
+ b: &Match,
+ ) -> cmp::Ordering {
+ debug_assert!(a.panel_match().is_some() && b.panel_match().is_some());
+
+ match (&a, &b) {
+ // bubble currently opened files to the top
+ (Match::History { path, .. }, _) if Some(path) == currently_opened => {
+ cmp::Ordering::Greater
+ }
+ (_, Match::History { path, .. }) if Some(path) == currently_opened => {
+ cmp::Ordering::Less
+ }
+
+ (Match::History { .. }, Match::Search(_)) if separate_history => cmp::Ordering::Greater,
+ (Match::Search(_), Match::History { .. }) if separate_history => cmp::Ordering::Less,
+
+ _ => a.panel_match().cmp(&b.panel_match()),
+ }
}
}
-fn matching_history_item_paths<'a>(
+fn matching_history_items<'a>(
history_items: impl IntoIterator<Item = &'a FoundPath>,
currently_opened: Option<&'a FoundPath>,
- query: Option<&FileSearchQuery>,
-) -> HashMap<Arc<Path>, Option<ProjectPanelOrdMatch>> {
- let Some(query) = query else {
- return history_items
- .into_iter()
- .chain(currently_opened)
- .map(|found_path| (Arc::clone(&found_path.project.path), None))
- .collect();
- };
+ query: &FileSearchQuery,
+) -> HashMap<Arc<Path>, Match> {
+ let mut candidates_paths = HashMap::default();
let history_items_by_worktrees = history_items
.into_iter()
@@ -333,6 +386,7 @@ fn matching_history_item_paths<'a>(
.chars(),
),
};
+ candidates_paths.insert(Arc::clone(&found_path.project.path), found_path);
Some((found_path.project.worktree_id, candidate))
})
.fold(
@@ -358,9 +412,15 @@ fn matching_history_item_paths<'a>(
)
.into_iter()
.map(|path_match| {
+ let (_, found_path) = candidates_paths
+ .remove_entry(&path_match.path)
+ .expect("candidate info not found");
(
Arc::clone(&path_match.path),
- Some(ProjectPanelOrdMatch(path_match)),
+ Match::History {
+ path: found_path.clone(),
+ panel_match: Some(ProjectPanelOrdMatch(path_match)),
+ },
)
}),
);
@@ -439,6 +499,7 @@ impl FileFinderDelegate {
cancel_flag: Arc::new(AtomicBool::new(false)),
history_items,
separate_history,
+ first_update: true,
}
}
@@ -524,12 +585,19 @@ impl FileFinderDelegate {
) {
if search_id >= self.latest_search_id {
self.latest_search_id = search_id;
- let extend_old_matches = self.latest_search_did_cancel
- && Some(query.path_query())
- == self
- .latest_search_query
- .as_ref()
- .map(|query| query.path_query());
+ let query_changed = Some(query.path_query())
+ != self
+ .latest_search_query
+ .as_ref()
+ .map(|query| query.path_query());
+ let extend_old_matches = self.latest_search_did_cancel && !query_changed;
+
+ let selected_match = if query_changed {
+ None
+ } else {
+ self.matches.get(self.selected_index).cloned()
+ };
+
self.matches.push_new_matches(
&self.history_items,
self.currently_opened_path.as_ref(),
@@ -537,9 +605,19 @@ impl FileFinderDelegate {
matches.into_iter(),
extend_old_matches,
);
+
+ self.selected_index = selected_match.map_or_else(
+ || self.calculate_selected_index(),
+ |m| {
+ self.matches
+ .position(&m, self.currently_opened_path.as_ref())
+ .unwrap_or(0)
+ },
+ );
+
self.latest_search_query = Some(query);
self.latest_search_did_cancel = did_cancel;
- self.selected_index = self.calculate_selected_index();
+
cx.notify();
}
}
@@ -550,10 +628,13 @@ impl FileFinderDelegate {
cx: &AppContext,
ix: usize,
) -> (String, Vec<usize>, String, Vec<usize>) {
- let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
- Match::History(found_path, found_path_match) => {
- let worktree_id = found_path.project.worktree_id;
- let project_relative_path = &found_path.project.path;
+ let (file_name, file_name_positions, full_path, full_path_positions) = match &path_match {
+ Match::History {
+ path: entry_path,
+ panel_match,
+ } => {
+ let worktree_id = entry_path.project.worktree_id;
+ let project_relative_path = &entry_path.project.path;
let has_worktree = self
.project
.read(cx)
@@ -561,7 +642,7 @@ impl FileFinderDelegate {
.is_some();
if !has_worktree {
- if let Some(absolute_path) = &found_path.absolute {
+ if let Some(absolute_path) = &entry_path.absolute {
return (
absolute_path
.file_name()
@@ -579,7 +660,7 @@ impl FileFinderDelegate {
let mut path = Arc::clone(project_relative_path);
if project_relative_path.as_ref() == Path::new("") {
- if let Some(absolute_path) = &found_path.absolute {
+ if let Some(absolute_path) = &entry_path.absolute {
path = Arc::from(absolute_path.as_path());
}
}
@@ -593,7 +674,7 @@ impl FileFinderDelegate {
path_prefix: "".into(),
distance_to_relative_ancestor: usize::MAX,
};
- if let Some(found_path_match) = found_path_match {
+ if let Some(found_path_match) = &panel_match {
path_match
.positions
.extend(found_path_match.0.positions.iter())
@@ -718,7 +799,7 @@ impl FileFinderDelegate {
/// Skips first history match (that is displayed topmost) if it's currently opened.
fn calculate_selected_index(&self) -> usize {
- if let Some(Match::History(path, _)) = self.matches.get(0) {
+ if let Some(Match::History { path, .. }) = self.matches.get(0) {
if Some(path) == self.currently_opened_path.as_ref() {
let elements_after_first = self.matches.len() - 1;
if elements_after_first > 0 {
@@ -726,6 +807,7 @@ impl FileFinderDelegate {
}
}
}
+
0
}
}
@@ -758,7 +840,7 @@ impl PickerDelegate for FileFinderDelegate {
.matches
.iter()
.enumerate()
- .find(|(_, m)| !matches!(m, Match::History(_, _)))
+ .find(|(_, m)| !matches!(m, Match::History { .. }))
.map(|(i, _)| i);
if let Some(first_non_history_index) = first_non_history_index {
if first_non_history_index > 0 {
@@ -777,26 +859,34 @@ impl PickerDelegate for FileFinderDelegate {
let raw_query = raw_query.replace(' ', "");
let raw_query = raw_query.trim();
if raw_query.is_empty() {
- let project = self.project.read(cx);
- self.latest_search_id = post_inc(&mut self.search_count);
- self.matches = Matches {
- separate_history: self.separate_history,
- ..Matches::default()
- };
- self.matches.push_new_matches(
- self.history_items.iter().filter(|history_item| {
- project
- .worktree_for_id(history_item.project.worktree_id, cx)
- .is_some()
- || (project.is_local_or_ssh() && history_item.absolute.is_some())
- }),
- self.currently_opened_path.as_ref(),
- None,
- None.into_iter(),
- false,
- );
-
- self.selected_index = 0;
+ // if there was no query before, and we already have some (history) matches
+ // there's no need to update anything, since nothing has changed.
+ // We also want to populate matches set from history entries on the first update.
+ if self.latest_search_query.is_some() || self.first_update {
+ let project = self.project.read(cx);
+
+ self.latest_search_id = post_inc(&mut self.search_count);
+ self.latest_search_query = None;
+ self.matches = Matches {
+ separate_history: self.separate_history,
+ ..Matches::default()
+ };
+ self.matches.push_new_matches(
+ self.history_items.iter().filter(|history_item| {
+ project
+ .worktree_for_id(history_item.project.worktree_id, cx)
+ .is_some()
+ || (project.is_local_or_ssh() && history_item.absolute.is_some())
+ }),
+ self.currently_opened_path.as_ref(),
+ None,
+ None.into_iter(),
+ false,
+ );
+
+ self.first_update = false;
+ self.selected_index = 0;
+ }
cx.notify();
Task::ready(())
} else {
@@ -843,9 +933,9 @@ impl PickerDelegate for FileFinderDelegate {
)
}
};
- match m {
- Match::History(history_match, _) => {
- let worktree_id = history_match.project.worktree_id;
+ match &m {
+ Match::History { path, .. } => {
+ let worktree_id = path.project.worktree_id;
if workspace
.project()
.read(cx)
@@ -856,12 +946,12 @@ impl PickerDelegate for FileFinderDelegate {
workspace,
ProjectPath {
worktree_id,
- path: Arc::clone(&history_match.project.path),
+ path: Arc::clone(&path.project.path),
},
cx,
)
} else {
- match history_match.absolute.as_ref() {
+ match path.absolute.as_ref() {
Some(abs_path) => {
if secondary {
workspace.split_abs_path(
@@ -881,7 +971,7 @@ impl PickerDelegate for FileFinderDelegate {
workspace,
ProjectPath {
worktree_id,
- path: Arc::clone(&history_match.project.path),
+ path: Arc::clone(&path.project.path),
},
cx,
),
@@ -957,7 +1047,7 @@ impl PickerDelegate for FileFinderDelegate {
.expect("Invalid matches state: no element for index {ix}");
let icon = match &path_match {
- Match::History(_, _) => Icon::new(IconName::HistoryRerun)
+ Match::History { .. } => Icon::new(IconName::HistoryRerun)
.color(Color::Muted)
.size(IconSize::Small)
.into_any_element(),
@@ -1323,6 +1323,62 @@ async fn test_history_items_shown_in_order_of_open(cx: &mut TestAppContext) {
});
}
+#[gpui::test]
+async fn test_selected_history_item_stays_selected_on_worktree_updated(cx: &mut TestAppContext) {
+ let app_state = init_test(cx);
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/test",
+ json!({
+ "test": {
+ "1.txt": "// One",
+ "2.txt": "// Two",
+ "3.txt": "// Three",
+ }
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), ["/test".as_ref()], cx).await;
+ let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
+
+ open_close_queried_buffer("1", 1, "1.txt", &workspace, cx).await;
+ open_close_queried_buffer("2", 1, "2.txt", &workspace, cx).await;
+ open_close_queried_buffer("3", 1, "3.txt", &workspace, cx).await;
+
+ let picker = open_file_picker(&workspace, cx);
+ picker.update(cx, |finder, _| {
+ assert_eq!(finder.delegate.matches.len(), 3);
+ assert_match_selection(finder, 0, "3.txt");
+ assert_match_at_position(finder, 1, "2.txt");
+ assert_match_at_position(finder, 2, "1.txt");
+ });
+
+ cx.dispatch_action(SelectNext);
+
+ // Add more files to the worktree to trigger update matches
+ for i in 0..5 {
+ let filename = format!("/test/{}.txt", 4 + i);
+ app_state
+ .fs
+ .create_file(Path::new(&filename), Default::default())
+ .await
+ .expect("unable to create file");
+ }
+
+ cx.executor().advance_clock(FS_WATCH_LATENCY);
+
+ picker.update(cx, |finder, _| {
+ assert_eq!(finder.delegate.matches.len(), 3);
+ assert_match_at_position(finder, 0, "3.txt");
+ assert_match_selection(finder, 1, "2.txt");
+ assert_match_at_position(finder, 2, "1.txt");
+ });
+}
+
#[gpui::test]
async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
@@ -1541,6 +1597,107 @@ async fn test_search_results_refreshed_on_adding_and_removing_worktrees(
});
}
+#[gpui::test]
+async fn test_selected_match_stays_selected_after_matches_refreshed(cx: &mut gpui::TestAppContext) {
+ let app_state = init_test(cx);
+
+ app_state.fs.as_fake().insert_tree("/src", json!({})).await;
+
+ app_state
+ .fs
+ .create_dir("/src/even".as_ref())
+ .await
+ .expect("unable to create dir");
+
+ let initial_files_num = 5;
+ for i in 0..initial_files_num {
+ let filename = format!("/src/even/file_{}.txt", 10 + i);
+ app_state
+ .fs
+ .create_file(Path::new(&filename), Default::default())
+ .await
+ .expect("unable to create file");
+ }
+
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+
+ // Initial state
+ let picker = open_file_picker(&workspace, cx);
+ cx.simulate_input("file");
+ let selected_index = 3;
+ // Checking only the filename, not the whole path
+ let selected_file = format!("file_{}.txt", 10 + selected_index);
+ // Select even/file_13.txt
+ for _ in 0..selected_index {
+ cx.dispatch_action(SelectNext);
+ }
+
+ picker.update(cx, |finder, _| {
+ assert_match_selection(finder, selected_index, &selected_file)
+ });
+
+ // Add more matches to the search results
+ let files_to_add = 10;
+ for i in 0..files_to_add {
+ let filename = format!("/src/file_{}.txt", 20 + i);
+ app_state
+ .fs
+ .create_file(Path::new(&filename), Default::default())
+ .await
+ .expect("unable to create file");
+ }
+ cx.executor().advance_clock(FS_WATCH_LATENCY);
+
+ // file_13.txt is still selected
+ picker.update(cx, |finder, _| {
+ let expected_selected_index = selected_index + files_to_add;
+ assert_match_selection(finder, expected_selected_index, &selected_file);
+ });
+}
+
+#[gpui::test]
+async fn test_first_match_selected_if_previous_one_is_not_in_the_match_list(
+ cx: &mut gpui::TestAppContext,
+) {
+ let app_state = init_test(cx);
+
+ app_state
+ .fs
+ .as_fake()
+ .insert_tree(
+ "/src",
+ json!({
+ "file_1.txt": "// file_1",
+ "file_2.txt": "// file_2",
+ "file_3.txt": "// file_3",
+ }),
+ )
+ .await;
+
+ let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
+ let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
+
+ // Initial state
+ let picker = open_file_picker(&workspace, cx);
+ cx.simulate_input("file");
+ // Select even/file_2.txt
+ cx.dispatch_action(SelectNext);
+
+ // Remove the selected entry
+ app_state
+ .fs
+ .remove_file("/src/file_2.txt".as_ref(), Default::default())
+ .await
+ .expect("unable to remove file");
+ cx.executor().advance_clock(FS_WATCH_LATENCY);
+
+ // file_1.txt is now selected
+ picker.update(cx, |finder, _| {
+ assert_match_selection(finder, 0, "file_1.txt");
+ });
+}
+
#[gpui::test]
async fn test_keeps_file_finder_open_after_modifier_keys_release(cx: &mut gpui::TestAppContext) {
let app_state = init_test(cx);
@@ -1940,8 +2097,11 @@ impl SearchEntries {
fn collect_search_matches(picker: &Picker<FileFinderDelegate>) -> SearchEntries {
let mut search_entries = SearchEntries::default();
for m in &picker.delegate.matches.matches {
- match m {
- Match::History(history_path, path_match) => {
+ match &m {
+ Match::History {
+ path: history_path,
+ panel_match: path_match,
+ } => {
search_entries.history.push(
path_match
.as_ref()
@@ -1996,8 +2156,8 @@ fn assert_match_at_position(
.matches
.get(match_index)
.unwrap_or_else(|| panic!("Finder has no match for index {match_index}"));
- let match_file_name = match match_item {
- Match::History(found_path, _) => found_path.absolute.as_deref().unwrap().file_name(),
+ let match_file_name = match &match_item {
+ Match::History { path, .. } => path.absolute.as_deref().unwrap().file_name(),
Match::Search(path_match) => path_match.0.path.file_name(),
}
.unwrap()