1use collections::HashMap;
2use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
3use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
4use gpui::{
5 actions, div, AppContext, Component, Dismiss, Div, FocusHandle, InteractiveComponent,
6 ManagedView, Model, ParentComponent, Render, Styled, Task, View, ViewContext, VisualContext,
7 WeakView,
8};
9use picker::{Picker, PickerDelegate};
10use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
11use std::{
12 path::{Path, PathBuf},
13 sync::{
14 atomic::{self, AtomicBool},
15 Arc,
16 },
17};
18use text::Point;
19use theme::ActiveTheme;
20use ui::{v_stack, HighlightedLabel, StyledExt};
21use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
22use workspace::Workspace;
23
24actions!(Toggle);
25
26pub struct FileFinder {
27 picker: View<Picker<FileFinderDelegate>>,
28}
29
30pub fn init(cx: &mut AppContext) {
31 cx.observe_new_views(FileFinder::register).detach();
32}
33
34impl FileFinder {
35 fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
36 workspace.register_action(|workspace, _: &Toggle, cx| {
37 let Some(file_finder) = workspace.active_modal::<Self>(cx) else {
38 Self::open(workspace, cx);
39 return;
40 };
41
42 file_finder.update(cx, |file_finder, cx| {
43 file_finder
44 .picker
45 .update(cx, |picker, cx| picker.cycle_selection(cx))
46 });
47 });
48 }
49
50 fn open(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
51 let project = workspace.project().read(cx);
52
53 let currently_opened_path = workspace
54 .active_item(cx)
55 .and_then(|item| item.project_path(cx))
56 .map(|project_path| {
57 let abs_path = project
58 .worktree_for_id(project_path.worktree_id, cx)
59 .map(|worktree| worktree.read(cx).abs_path().join(&project_path.path));
60 FoundPath::new(project_path, abs_path)
61 });
62
63 // if exists, bubble the currently opened path to the top
64 let history_items = currently_opened_path
65 .clone()
66 .into_iter()
67 .chain(
68 workspace
69 .recent_navigation_history(Some(MAX_RECENT_SELECTIONS), cx)
70 .into_iter()
71 .filter(|(history_path, _)| {
72 Some(history_path)
73 != currently_opened_path
74 .as_ref()
75 .map(|found_path| &found_path.project)
76 })
77 .filter(|(_, history_abs_path)| {
78 history_abs_path.as_ref()
79 != currently_opened_path
80 .as_ref()
81 .and_then(|found_path| found_path.absolute.as_ref())
82 })
83 .filter(|(_, history_abs_path)| match history_abs_path {
84 Some(abs_path) => history_file_exists(abs_path),
85 None => true,
86 })
87 .map(|(history_path, abs_path)| FoundPath::new(history_path, abs_path)),
88 )
89 .collect();
90
91 let project = workspace.project().clone();
92 let weak_workspace = cx.view().downgrade();
93 workspace.toggle_modal(cx, |cx| {
94 let delegate = FileFinderDelegate::new(
95 cx.view().downgrade(),
96 weak_workspace,
97 project,
98 currently_opened_path,
99 history_items,
100 cx,
101 );
102
103 FileFinder::new(delegate, cx)
104 });
105 }
106
107 fn new(delegate: FileFinderDelegate, cx: &mut ViewContext<Self>) -> Self {
108 Self {
109 picker: cx.build_view(|cx| Picker::new(delegate, cx)),
110 }
111 }
112}
113
114impl ManagedView for FileFinder {
115 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
116 self.picker.focus_handle(cx)
117 }
118}
119impl Render for FileFinder {
120 type Element = Div<Self>;
121
122 fn render(&mut self, _cx: &mut ViewContext<Self>) -> Self::Element {
123 v_stack().w_96().child(self.picker.clone())
124 }
125}
126
127pub struct FileFinderDelegate {
128 file_finder: WeakView<FileFinder>,
129 workspace: WeakView<Workspace>,
130 project: Model<Project>,
131 search_count: usize,
132 latest_search_id: usize,
133 latest_search_did_cancel: bool,
134 latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
135 currently_opened_path: Option<FoundPath>,
136 matches: Matches,
137 selected_index: Option<usize>,
138 cancel_flag: Arc<AtomicBool>,
139 history_items: Vec<FoundPath>,
140}
141
142#[derive(Debug, Default)]
143struct Matches {
144 history: Vec<(FoundPath, Option<PathMatch>)>,
145 search: Vec<PathMatch>,
146}
147
148#[derive(Debug)]
149enum Match<'a> {
150 History(&'a FoundPath, Option<&'a PathMatch>),
151 Search(&'a PathMatch),
152}
153
154impl Matches {
155 fn len(&self) -> usize {
156 self.history.len() + self.search.len()
157 }
158
159 fn get(&self, index: usize) -> Option<Match<'_>> {
160 if index < self.history.len() {
161 self.history
162 .get(index)
163 .map(|(path, path_match)| Match::History(path, path_match.as_ref()))
164 } else {
165 self.search
166 .get(index - self.history.len())
167 .map(Match::Search)
168 }
169 }
170
171 fn push_new_matches(
172 &mut self,
173 history_items: &Vec<FoundPath>,
174 query: &PathLikeWithPosition<FileSearchQuery>,
175 mut new_search_matches: Vec<PathMatch>,
176 extend_old_matches: bool,
177 ) {
178 let matching_history_paths = matching_history_item_paths(history_items, query);
179 new_search_matches
180 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
181 let history_items_to_show = history_items
182 .iter()
183 .filter_map(|history_item| {
184 Some((
185 history_item.clone(),
186 Some(
187 matching_history_paths
188 .get(&history_item.project.path)?
189 .clone(),
190 ),
191 ))
192 })
193 .collect::<Vec<_>>();
194 self.history = history_items_to_show;
195 if extend_old_matches {
196 self.search
197 .retain(|path_match| !matching_history_paths.contains_key(&path_match.path));
198 util::extend_sorted(
199 &mut self.search,
200 new_search_matches.into_iter(),
201 100,
202 |a, b| b.cmp(a),
203 )
204 } else {
205 self.search = new_search_matches;
206 }
207 }
208}
209
210fn matching_history_item_paths(
211 history_items: &Vec<FoundPath>,
212 query: &PathLikeWithPosition<FileSearchQuery>,
213) -> HashMap<Arc<Path>, PathMatch> {
214 let history_items_by_worktrees = history_items
215 .iter()
216 .filter_map(|found_path| {
217 let candidate = PathMatchCandidate {
218 path: &found_path.project.path,
219 // Only match history items names, otherwise their paths may match too many queries, producing false positives.
220 // E.g. `foo` would match both `something/foo/bar.rs` and `something/foo/foo.rs` and if the former is a history item,
221 // it would be shown first always, despite the latter being a better match.
222 char_bag: CharBag::from_iter(
223 found_path
224 .project
225 .path
226 .file_name()?
227 .to_string_lossy()
228 .to_lowercase()
229 .chars(),
230 ),
231 };
232 Some((found_path.project.worktree_id, candidate))
233 })
234 .fold(
235 HashMap::default(),
236 |mut candidates, (worktree_id, new_candidate)| {
237 candidates
238 .entry(worktree_id)
239 .or_insert_with(Vec::new)
240 .push(new_candidate);
241 candidates
242 },
243 );
244 let mut matching_history_paths = HashMap::default();
245 for (worktree, candidates) in history_items_by_worktrees {
246 let max_results = candidates.len() + 1;
247 matching_history_paths.extend(
248 fuzzy::match_fixed_path_set(
249 candidates,
250 worktree.to_usize(),
251 query.path_like.path_query(),
252 false,
253 max_results,
254 )
255 .into_iter()
256 .map(|path_match| (Arc::clone(&path_match.path), path_match)),
257 );
258 }
259 matching_history_paths
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
263struct FoundPath {
264 project: ProjectPath,
265 absolute: Option<PathBuf>,
266}
267
268impl FoundPath {
269 fn new(project: ProjectPath, absolute: Option<PathBuf>) -> Self {
270 Self { project, absolute }
271 }
272}
273
274const MAX_RECENT_SELECTIONS: usize = 20;
275
276#[cfg(not(test))]
277fn history_file_exists(abs_path: &PathBuf) -> bool {
278 abs_path.exists()
279}
280
281#[cfg(test)]
282fn history_file_exists(abs_path: &PathBuf) -> bool {
283 !abs_path.ends_with("nonexistent.rs")
284}
285
286pub enum Event {
287 Selected(ProjectPath),
288 Dismissed,
289}
290
291#[derive(Debug, Clone)]
292struct FileSearchQuery {
293 raw_query: String,
294 file_query_end: Option<usize>,
295}
296
297impl FileSearchQuery {
298 fn path_query(&self) -> &str {
299 match self.file_query_end {
300 Some(file_path_end) => &self.raw_query[..file_path_end],
301 None => &self.raw_query,
302 }
303 }
304}
305
306impl FileFinderDelegate {
307 fn new(
308 file_finder: WeakView<FileFinder>,
309 workspace: WeakView<Workspace>,
310 project: Model<Project>,
311 currently_opened_path: Option<FoundPath>,
312 history_items: Vec<FoundPath>,
313 cx: &mut ViewContext<FileFinder>,
314 ) -> Self {
315 cx.observe(&project, |file_finder, _, cx| {
316 //todo!() We should probably not re-render on every project anything
317 file_finder
318 .picker
319 .update(cx, |picker, cx| picker.refresh(cx))
320 })
321 .detach();
322
323 Self {
324 file_finder,
325 workspace,
326 project,
327 search_count: 0,
328 latest_search_id: 0,
329 latest_search_did_cancel: false,
330 latest_search_query: None,
331 currently_opened_path,
332 matches: Matches::default(),
333 selected_index: None,
334 cancel_flag: Arc::new(AtomicBool::new(false)),
335 history_items,
336 }
337 }
338
339 fn spawn_search(
340 &mut self,
341 query: PathLikeWithPosition<FileSearchQuery>,
342 cx: &mut ViewContext<Picker<Self>>,
343 ) -> Task<()> {
344 let relative_to = self
345 .currently_opened_path
346 .as_ref()
347 .map(|found_path| Arc::clone(&found_path.project.path));
348 let worktrees = self
349 .project
350 .read(cx)
351 .visible_worktrees(cx)
352 .collect::<Vec<_>>();
353 let include_root_name = worktrees.len() > 1;
354 let candidate_sets = worktrees
355 .into_iter()
356 .map(|worktree| {
357 let worktree = worktree.read(cx);
358 PathMatchCandidateSet {
359 snapshot: worktree.snapshot(),
360 include_ignored: worktree
361 .root_entry()
362 .map_or(false, |entry| entry.is_ignored),
363 include_root_name,
364 }
365 })
366 .collect::<Vec<_>>();
367
368 let search_id = util::post_inc(&mut self.search_count);
369 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
370 self.cancel_flag = Arc::new(AtomicBool::new(false));
371 let cancel_flag = self.cancel_flag.clone();
372 cx.spawn(|picker, mut cx| async move {
373 let matches = fuzzy::match_path_sets(
374 candidate_sets.as_slice(),
375 query.path_like.path_query(),
376 relative_to,
377 false,
378 100,
379 &cancel_flag,
380 cx.background_executor().clone(),
381 )
382 .await;
383 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
384 picker
385 .update(&mut cx, |picker, cx| {
386 picker
387 .delegate
388 .set_search_matches(search_id, did_cancel, query, matches, cx)
389 })
390 .log_err();
391 })
392 }
393
394 fn set_search_matches(
395 &mut self,
396 search_id: usize,
397 did_cancel: bool,
398 query: PathLikeWithPosition<FileSearchQuery>,
399 matches: Vec<PathMatch>,
400 cx: &mut ViewContext<Picker<Self>>,
401 ) {
402 if search_id >= self.latest_search_id {
403 self.latest_search_id = search_id;
404 let extend_old_matches = self.latest_search_did_cancel
405 && Some(query.path_like.path_query())
406 == self
407 .latest_search_query
408 .as_ref()
409 .map(|query| query.path_like.path_query());
410 self.matches
411 .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
412 self.latest_search_query = Some(query);
413 self.latest_search_did_cancel = did_cancel;
414 cx.notify();
415 }
416 }
417
418 fn labels_for_match(
419 &self,
420 path_match: Match,
421 cx: &AppContext,
422 ix: usize,
423 ) -> (String, Vec<usize>, String, Vec<usize>) {
424 let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
425 Match::History(found_path, found_path_match) => {
426 let worktree_id = found_path.project.worktree_id;
427 let project_relative_path = &found_path.project.path;
428 let has_worktree = self
429 .project
430 .read(cx)
431 .worktree_for_id(worktree_id, cx)
432 .is_some();
433
434 if !has_worktree {
435 if let Some(absolute_path) = &found_path.absolute {
436 return (
437 absolute_path
438 .file_name()
439 .map_or_else(
440 || project_relative_path.to_string_lossy(),
441 |file_name| file_name.to_string_lossy(),
442 )
443 .to_string(),
444 Vec::new(),
445 absolute_path.to_string_lossy().to_string(),
446 Vec::new(),
447 );
448 }
449 }
450
451 let mut path = Arc::clone(project_relative_path);
452 if project_relative_path.as_ref() == Path::new("") {
453 if let Some(absolute_path) = &found_path.absolute {
454 path = Arc::from(absolute_path.as_path());
455 }
456 }
457
458 let mut path_match = PathMatch {
459 score: ix as f64,
460 positions: Vec::new(),
461 worktree_id: worktree_id.to_usize(),
462 path,
463 path_prefix: "".into(),
464 distance_to_relative_ancestor: usize::MAX,
465 };
466 if let Some(found_path_match) = found_path_match {
467 path_match
468 .positions
469 .extend(found_path_match.positions.iter())
470 }
471
472 self.labels_for_path_match(&path_match)
473 }
474 Match::Search(path_match) => self.labels_for_path_match(path_match),
475 };
476
477 if file_name_positions.is_empty() {
478 if let Some(user_home_path) = std::env::var("HOME").ok() {
479 let user_home_path = user_home_path.trim();
480 if !user_home_path.is_empty() {
481 if (&full_path).starts_with(user_home_path) {
482 return (
483 file_name,
484 file_name_positions,
485 full_path.replace(user_home_path, "~"),
486 full_path_positions,
487 );
488 }
489 }
490 }
491 }
492
493 (
494 file_name,
495 file_name_positions,
496 full_path,
497 full_path_positions,
498 )
499 }
500
501 fn labels_for_path_match(
502 &self,
503 path_match: &PathMatch,
504 ) -> (String, Vec<usize>, String, Vec<usize>) {
505 let path = &path_match.path;
506 let path_string = path.to_string_lossy();
507 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
508 let path_positions = path_match.positions.clone();
509
510 let file_name = path.file_name().map_or_else(
511 || path_match.path_prefix.to_string(),
512 |file_name| file_name.to_string_lossy().to_string(),
513 );
514 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
515 - file_name.chars().count();
516 let file_name_positions = path_positions
517 .iter()
518 .filter_map(|pos| {
519 if pos >= &file_name_start {
520 Some(pos - file_name_start)
521 } else {
522 None
523 }
524 })
525 .collect();
526
527 (file_name, file_name_positions, full_path, path_positions)
528 }
529}
530
531impl PickerDelegate for FileFinderDelegate {
532 type ListItem = Div<Picker<Self>>;
533
534 fn placeholder_text(&self) -> Arc<str> {
535 "Search project files...".into()
536 }
537
538 fn match_count(&self) -> usize {
539 self.matches.len()
540 }
541
542 fn selected_index(&self) -> usize {
543 self.selected_index.unwrap_or(0)
544 }
545
546 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
547 self.selected_index = Some(ix);
548 cx.notify();
549 }
550
551 fn update_matches(
552 &mut self,
553 raw_query: String,
554 cx: &mut ViewContext<Picker<Self>>,
555 ) -> Task<()> {
556 if raw_query.is_empty() {
557 let project = self.project.read(cx);
558 self.latest_search_id = post_inc(&mut self.search_count);
559 self.matches = Matches {
560 history: self
561 .history_items
562 .iter()
563 .filter(|history_item| {
564 project
565 .worktree_for_id(history_item.project.worktree_id, cx)
566 .is_some()
567 || (project.is_local() && history_item.absolute.is_some())
568 })
569 .cloned()
570 .map(|p| (p, None))
571 .collect(),
572 search: Vec::new(),
573 };
574 cx.notify();
575 Task::ready(())
576 } else {
577 let raw_query = &raw_query;
578 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
579 Ok::<_, std::convert::Infallible>(FileSearchQuery {
580 raw_query: raw_query.to_owned(),
581 file_query_end: if path_like_str == raw_query {
582 None
583 } else {
584 Some(path_like_str.len())
585 },
586 })
587 })
588 .expect("infallible");
589 self.spawn_search(query, cx)
590 }
591 }
592
593 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
594 if let Some(m) = self.matches.get(self.selected_index()) {
595 if let Some(workspace) = self.workspace.upgrade() {
596 let open_task = workspace.update(cx, move |workspace, cx| {
597 let split_or_open = |workspace: &mut Workspace, project_path, cx| {
598 if secondary {
599 workspace.split_path(project_path, cx)
600 } else {
601 workspace.open_path(project_path, None, true, cx)
602 }
603 };
604 match m {
605 Match::History(history_match, _) => {
606 let worktree_id = history_match.project.worktree_id;
607 if workspace
608 .project()
609 .read(cx)
610 .worktree_for_id(worktree_id, cx)
611 .is_some()
612 {
613 split_or_open(
614 workspace,
615 ProjectPath {
616 worktree_id,
617 path: Arc::clone(&history_match.project.path),
618 },
619 cx,
620 )
621 } else {
622 match history_match.absolute.as_ref() {
623 Some(abs_path) => {
624 if secondary {
625 workspace.split_abs_path(
626 abs_path.to_path_buf(),
627 false,
628 cx,
629 )
630 } else {
631 workspace.open_abs_path(
632 abs_path.to_path_buf(),
633 false,
634 cx,
635 )
636 }
637 }
638 None => split_or_open(
639 workspace,
640 ProjectPath {
641 worktree_id,
642 path: Arc::clone(&history_match.project.path),
643 },
644 cx,
645 ),
646 }
647 }
648 }
649 Match::Search(m) => split_or_open(
650 workspace,
651 ProjectPath {
652 worktree_id: WorktreeId::from_usize(m.worktree_id),
653 path: m.path.clone(),
654 },
655 cx,
656 ),
657 }
658 });
659
660 let row = self
661 .latest_search_query
662 .as_ref()
663 .and_then(|query| query.row)
664 .map(|row| row.saturating_sub(1));
665 let col = self
666 .latest_search_query
667 .as_ref()
668 .and_then(|query| query.column)
669 .unwrap_or(0)
670 .saturating_sub(1);
671 let finder = self.file_finder.clone();
672
673 cx.spawn(|_, mut cx| async move {
674 let item = open_task.await.log_err()?;
675 if let Some(row) = row {
676 if let Some(active_editor) = item.downcast::<Editor>() {
677 active_editor
678 .downgrade()
679 .update(&mut cx, |editor, cx| {
680 let snapshot = editor.snapshot(cx).display_snapshot;
681 let point = snapshot
682 .buffer_snapshot
683 .clip_point(Point::new(row, col), Bias::Left);
684 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
685 s.select_ranges([point..point])
686 });
687 })
688 .log_err();
689 }
690 }
691 finder.update(&mut cx, |_, cx| cx.emit(Dismiss)).ok()?;
692
693 Some(())
694 })
695 .detach();
696 }
697 }
698 }
699
700 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
701 self.file_finder
702 .update(cx, |_, cx| cx.emit(Dismiss))
703 .log_err();
704 }
705
706 fn render_match(
707 &self,
708 ix: usize,
709 selected: bool,
710 cx: &mut ViewContext<Picker<Self>>,
711 ) -> Self::ListItem {
712 let path_match = self
713 .matches
714 .get(ix)
715 .expect("Invalid matches state: no element for index {ix}");
716 let theme = cx.theme();
717 let colors = theme.colors();
718
719 let (file_name, file_name_positions, full_path, full_path_positions) =
720 self.labels_for_match(path_match, cx, ix);
721
722 div()
723 .px_1()
724 .text_color(colors.text)
725 .text_ui()
726 .bg(colors.ghost_element_background)
727 .rounded_md()
728 .when(selected, |this| this.bg(colors.ghost_element_selected))
729 .hover(|this| this.bg(colors.ghost_element_hover))
730 .child(
731 v_stack()
732 .child(HighlightedLabel::new(file_name, file_name_positions))
733 .child(HighlightedLabel::new(full_path, full_path_positions)),
734 )
735 }
736}
737
738#[cfg(test)]
739mod tests {
740 use std::{assert_eq, path::Path, time::Duration};
741
742 use super::*;
743 use editor::Editor;
744 use gpui::{Entity, TestAppContext, VisualTestContext};
745 use menu::{Confirm, SelectNext};
746 use serde_json::json;
747 use workspace::{AppState, Workspace};
748
749 #[ctor::ctor]
750 fn init_logger() {
751 if std::env::var("RUST_LOG").is_ok() {
752 env_logger::init();
753 }
754 }
755
756 #[gpui::test]
757 async fn test_matching_paths(cx: &mut TestAppContext) {
758 let app_state = init_test(cx);
759 app_state
760 .fs
761 .as_fake()
762 .insert_tree(
763 "/root",
764 json!({
765 "a": {
766 "banana": "",
767 "bandana": "",
768 }
769 }),
770 )
771 .await;
772
773 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
774
775 let (picker, workspace, cx) = build_find_picker(project, cx);
776
777 cx.simulate_input("bna");
778
779 picker.update(cx, |picker, _| {
780 assert_eq!(picker.delegate.matches.len(), 2);
781 });
782
783 cx.dispatch_action(SelectNext);
784 cx.dispatch_action(Confirm);
785
786 cx.read(|cx| {
787 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
788 assert_eq!(active_editor.read(cx).title(cx), "bandana");
789 });
790 }
791
792 #[gpui::test]
793 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
794 let app_state = init_test(cx);
795
796 let first_file_name = "first.rs";
797 let first_file_contents = "// First Rust file";
798 app_state
799 .fs
800 .as_fake()
801 .insert_tree(
802 "/src",
803 json!({
804 "test": {
805 first_file_name: first_file_contents,
806 "second.rs": "// Second Rust file",
807 }
808 }),
809 )
810 .await;
811
812 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
813
814 let (picker, workspace, cx) = build_find_picker(project, cx);
815
816 let file_query = &first_file_name[..3];
817 let file_row = 1;
818 let file_column = 3;
819 assert!(file_column <= first_file_contents.len());
820 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
821 picker
822 .update(cx, |finder, cx| {
823 finder
824 .delegate
825 .update_matches(query_inside_file.to_string(), cx)
826 })
827 .await;
828 picker.update(cx, |finder, _| {
829 let finder = &finder.delegate;
830 assert_eq!(finder.matches.len(), 1);
831 let latest_search_query = finder
832 .latest_search_query
833 .as_ref()
834 .expect("Finder should have a query after the update_matches call");
835 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
836 assert_eq!(
837 latest_search_query.path_like.file_query_end,
838 Some(file_query.len())
839 );
840 assert_eq!(latest_search_query.row, Some(file_row));
841 assert_eq!(latest_search_query.column, Some(file_column as u32));
842 });
843
844 cx.dispatch_action(SelectNext);
845 cx.dispatch_action(Confirm);
846
847 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
848 cx.executor().advance_clock(Duration::from_secs(2));
849
850 editor.update(cx, |editor, cx| {
851 let all_selections = editor.selections.all_adjusted(cx);
852 assert_eq!(
853 all_selections.len(),
854 1,
855 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
856 );
857 let caret_selection = all_selections.into_iter().next().unwrap();
858 assert_eq!(caret_selection.start, caret_selection.end,
859 "Caret selection should have its start and end at the same position");
860 assert_eq!(file_row, caret_selection.start.row + 1,
861 "Query inside file should get caret with the same focus row");
862 assert_eq!(file_column, caret_selection.start.column as usize + 1,
863 "Query inside file should get caret with the same focus column");
864 });
865 }
866
867 #[gpui::test]
868 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
869 let app_state = init_test(cx);
870
871 let first_file_name = "first.rs";
872 let first_file_contents = "// First Rust file";
873 app_state
874 .fs
875 .as_fake()
876 .insert_tree(
877 "/src",
878 json!({
879 "test": {
880 first_file_name: first_file_contents,
881 "second.rs": "// Second Rust file",
882 }
883 }),
884 )
885 .await;
886
887 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
888
889 let (picker, workspace, cx) = build_find_picker(project, cx);
890
891 let file_query = &first_file_name[..3];
892 let file_row = 200;
893 let file_column = 300;
894 assert!(file_column > first_file_contents.len());
895 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
896 picker
897 .update(cx, |picker, cx| {
898 picker
899 .delegate
900 .update_matches(query_outside_file.to_string(), cx)
901 })
902 .await;
903 picker.update(cx, |finder, _| {
904 let delegate = &finder.delegate;
905 assert_eq!(delegate.matches.len(), 1);
906 let latest_search_query = delegate
907 .latest_search_query
908 .as_ref()
909 .expect("Finder should have a query after the update_matches call");
910 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
911 assert_eq!(
912 latest_search_query.path_like.file_query_end,
913 Some(file_query.len())
914 );
915 assert_eq!(latest_search_query.row, Some(file_row));
916 assert_eq!(latest_search_query.column, Some(file_column as u32));
917 });
918
919 cx.dispatch_action(SelectNext);
920 cx.dispatch_action(Confirm);
921
922 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
923 cx.executor().advance_clock(Duration::from_secs(2));
924
925 editor.update(cx, |editor, cx| {
926 let all_selections = editor.selections.all_adjusted(cx);
927 assert_eq!(
928 all_selections.len(),
929 1,
930 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
931 );
932 let caret_selection = all_selections.into_iter().next().unwrap();
933 assert_eq!(caret_selection.start, caret_selection.end,
934 "Caret selection should have its start and end at the same position");
935 assert_eq!(0, caret_selection.start.row,
936 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
937 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
938 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
939 });
940 }
941
942 #[gpui::test]
943 async fn test_matching_cancellation(cx: &mut TestAppContext) {
944 let app_state = init_test(cx);
945 app_state
946 .fs
947 .as_fake()
948 .insert_tree(
949 "/dir",
950 json!({
951 "hello": "",
952 "goodbye": "",
953 "halogen-light": "",
954 "happiness": "",
955 "height": "",
956 "hi": "",
957 "hiccup": "",
958 }),
959 )
960 .await;
961
962 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
963
964 let (picker, _, cx) = build_find_picker(project, cx);
965
966 let query = test_path_like("hi");
967 picker
968 .update(cx, |picker, cx| {
969 picker.delegate.spawn_search(query.clone(), cx)
970 })
971 .await;
972
973 picker.update(cx, |picker, _cx| {
974 assert_eq!(picker.delegate.matches.len(), 5)
975 });
976
977 picker.update(cx, |picker, cx| {
978 let delegate = &mut picker.delegate;
979 assert!(
980 delegate.matches.history.is_empty(),
981 "Search matches expected"
982 );
983 let matches = delegate.matches.search.clone();
984
985 // Simulate a search being cancelled after the time limit,
986 // returning only a subset of the matches that would have been found.
987 drop(delegate.spawn_search(query.clone(), cx));
988 delegate.set_search_matches(
989 delegate.latest_search_id,
990 true, // did-cancel
991 query.clone(),
992 vec![matches[1].clone(), matches[3].clone()],
993 cx,
994 );
995
996 // Simulate another cancellation.
997 drop(delegate.spawn_search(query.clone(), cx));
998 delegate.set_search_matches(
999 delegate.latest_search_id,
1000 true, // did-cancel
1001 query.clone(),
1002 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1003 cx,
1004 );
1005
1006 assert!(
1007 delegate.matches.history.is_empty(),
1008 "Search matches expected"
1009 );
1010 assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1011 });
1012 }
1013
1014 #[gpui::test]
1015 async fn test_ignored_files(cx: &mut TestAppContext) {
1016 let app_state = init_test(cx);
1017 app_state
1018 .fs
1019 .as_fake()
1020 .insert_tree(
1021 "/ancestor",
1022 json!({
1023 ".gitignore": "ignored-root",
1024 "ignored-root": {
1025 "happiness": "",
1026 "height": "",
1027 "hi": "",
1028 "hiccup": "",
1029 },
1030 "tracked-root": {
1031 ".gitignore": "height",
1032 "happiness": "",
1033 "height": "",
1034 "hi": "",
1035 "hiccup": "",
1036 },
1037 }),
1038 )
1039 .await;
1040
1041 let project = Project::test(
1042 app_state.fs.clone(),
1043 [
1044 "/ancestor/tracked-root".as_ref(),
1045 "/ancestor/ignored-root".as_ref(),
1046 ],
1047 cx,
1048 )
1049 .await;
1050
1051 let (picker, _, cx) = build_find_picker(project, cx);
1052
1053 picker
1054 .update(cx, |picker, cx| {
1055 picker.delegate.spawn_search(test_path_like("hi"), cx)
1056 })
1057 .await;
1058 picker.update(cx, |picker, _| assert_eq!(picker.delegate.matches.len(), 7));
1059 }
1060
1061 #[gpui::test]
1062 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1063 let app_state = init_test(cx);
1064 app_state
1065 .fs
1066 .as_fake()
1067 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1068 .await;
1069
1070 let project = Project::test(
1071 app_state.fs.clone(),
1072 ["/root/the-parent-dir/the-file".as_ref()],
1073 cx,
1074 )
1075 .await;
1076
1077 let (picker, _, cx) = build_find_picker(project, cx);
1078
1079 // Even though there is only one worktree, that worktree's filename
1080 // is included in the matching, because the worktree is a single file.
1081 picker
1082 .update(cx, |picker, cx| {
1083 picker.delegate.spawn_search(test_path_like("thf"), cx)
1084 })
1085 .await;
1086 cx.read(|cx| {
1087 let picker = picker.read(cx);
1088 let delegate = &picker.delegate;
1089 assert!(
1090 delegate.matches.history.is_empty(),
1091 "Search matches expected"
1092 );
1093 let matches = delegate.matches.search.clone();
1094 assert_eq!(matches.len(), 1);
1095
1096 let (file_name, file_name_positions, full_path, full_path_positions) =
1097 delegate.labels_for_path_match(&matches[0]);
1098 assert_eq!(file_name, "the-file");
1099 assert_eq!(file_name_positions, &[0, 1, 4]);
1100 assert_eq!(full_path, "the-file");
1101 assert_eq!(full_path_positions, &[0, 1, 4]);
1102 });
1103
1104 // Since the worktree root is a file, searching for its name followed by a slash does
1105 // not match anything.
1106 picker
1107 .update(cx, |f, cx| {
1108 f.delegate.spawn_search(test_path_like("thf/"), cx)
1109 })
1110 .await;
1111 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1112 }
1113
1114 #[gpui::test]
1115 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1116 let app_state = init_test(cx);
1117 app_state
1118 .fs
1119 .as_fake()
1120 .insert_tree(
1121 "/root",
1122 json!({
1123 "dir1": { "a.txt": "" },
1124 "dir2": {
1125 "a.txt": "",
1126 "b.txt": ""
1127 }
1128 }),
1129 )
1130 .await;
1131
1132 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1133 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1134
1135 let worktree_id = cx.read(|cx| {
1136 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1137 assert_eq!(worktrees.len(), 1);
1138 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1139 });
1140
1141 // When workspace has an active item, sort items which are closer to that item
1142 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1143 // so that one should be sorted earlier
1144 let b_path = ProjectPath {
1145 worktree_id,
1146 path: Arc::from(Path::new("/root/dir2/b.txt")),
1147 };
1148 workspace
1149 .update(cx, |workspace, cx| {
1150 workspace.open_path(b_path, None, true, cx)
1151 })
1152 .await
1153 .unwrap();
1154 let finder = open_file_picker(&workspace, cx);
1155 finder
1156 .update(cx, |f, cx| {
1157 f.delegate.spawn_search(test_path_like("a.txt"), cx)
1158 })
1159 .await;
1160
1161 finder.update(cx, |f, _| {
1162 let delegate = &f.delegate;
1163 assert!(
1164 delegate.matches.history.is_empty(),
1165 "Search matches expected"
1166 );
1167 let matches = delegate.matches.search.clone();
1168 assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1169 assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1170 });
1171 }
1172
1173 #[gpui::test]
1174 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1175 let app_state = init_test(cx);
1176 app_state
1177 .fs
1178 .as_fake()
1179 .insert_tree(
1180 "/root",
1181 json!({
1182 "dir1": {},
1183 "dir2": {
1184 "dir3": {}
1185 }
1186 }),
1187 )
1188 .await;
1189
1190 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1191 let (picker, _workspace, cx) = build_find_picker(project, cx);
1192
1193 picker
1194 .update(cx, |f, cx| {
1195 f.delegate.spawn_search(test_path_like("dir"), cx)
1196 })
1197 .await;
1198 cx.read(|cx| {
1199 let finder = picker.read(cx);
1200 assert_eq!(finder.delegate.matches.len(), 0);
1201 });
1202 }
1203
1204 #[gpui::test]
1205 async fn test_query_history(cx: &mut gpui::TestAppContext) {
1206 let app_state = init_test(cx);
1207
1208 app_state
1209 .fs
1210 .as_fake()
1211 .insert_tree(
1212 "/src",
1213 json!({
1214 "test": {
1215 "first.rs": "// First Rust file",
1216 "second.rs": "// Second Rust file",
1217 "third.rs": "// Third Rust file",
1218 }
1219 }),
1220 )
1221 .await;
1222
1223 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1224 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1225 let worktree_id = cx.read(|cx| {
1226 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1227 assert_eq!(worktrees.len(), 1);
1228 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1229 });
1230
1231 // Open and close panels, getting their history items afterwards.
1232 // Ensure history items get populated with opened items, and items are kept in a certain order.
1233 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1234 //
1235 // TODO: without closing, the opened items do not propagate their history changes for some reason
1236 // it does work in real app though, only tests do not propagate.
1237 workspace.update(cx, |_, cx| dbg!(cx.focused()));
1238
1239 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1240 assert!(
1241 initial_history.is_empty(),
1242 "Should have no history before opening any files"
1243 );
1244
1245 let history_after_first =
1246 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1247 assert_eq!(
1248 history_after_first,
1249 vec![FoundPath::new(
1250 ProjectPath {
1251 worktree_id,
1252 path: Arc::from(Path::new("test/first.rs")),
1253 },
1254 Some(PathBuf::from("/src/test/first.rs"))
1255 )],
1256 "Should show 1st opened item in the history when opening the 2nd item"
1257 );
1258
1259 let history_after_second =
1260 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1261 assert_eq!(
1262 history_after_second,
1263 vec![
1264 FoundPath::new(
1265 ProjectPath {
1266 worktree_id,
1267 path: Arc::from(Path::new("test/second.rs")),
1268 },
1269 Some(PathBuf::from("/src/test/second.rs"))
1270 ),
1271 FoundPath::new(
1272 ProjectPath {
1273 worktree_id,
1274 path: Arc::from(Path::new("test/first.rs")),
1275 },
1276 Some(PathBuf::from("/src/test/first.rs"))
1277 ),
1278 ],
1279 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1280 2nd item should be the first in the history, as the last opened."
1281 );
1282
1283 let history_after_third =
1284 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1285 assert_eq!(
1286 history_after_third,
1287 vec![
1288 FoundPath::new(
1289 ProjectPath {
1290 worktree_id,
1291 path: Arc::from(Path::new("test/third.rs")),
1292 },
1293 Some(PathBuf::from("/src/test/third.rs"))
1294 ),
1295 FoundPath::new(
1296 ProjectPath {
1297 worktree_id,
1298 path: Arc::from(Path::new("test/second.rs")),
1299 },
1300 Some(PathBuf::from("/src/test/second.rs"))
1301 ),
1302 FoundPath::new(
1303 ProjectPath {
1304 worktree_id,
1305 path: Arc::from(Path::new("test/first.rs")),
1306 },
1307 Some(PathBuf::from("/src/test/first.rs"))
1308 ),
1309 ],
1310 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1311 3rd item should be the first in the history, as the last opened."
1312 );
1313
1314 let history_after_second_again =
1315 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1316 assert_eq!(
1317 history_after_second_again,
1318 vec![
1319 FoundPath::new(
1320 ProjectPath {
1321 worktree_id,
1322 path: Arc::from(Path::new("test/second.rs")),
1323 },
1324 Some(PathBuf::from("/src/test/second.rs"))
1325 ),
1326 FoundPath::new(
1327 ProjectPath {
1328 worktree_id,
1329 path: Arc::from(Path::new("test/third.rs")),
1330 },
1331 Some(PathBuf::from("/src/test/third.rs"))
1332 ),
1333 FoundPath::new(
1334 ProjectPath {
1335 worktree_id,
1336 path: Arc::from(Path::new("test/first.rs")),
1337 },
1338 Some(PathBuf::from("/src/test/first.rs"))
1339 ),
1340 ],
1341 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1342 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1343 );
1344 }
1345
1346 #[gpui::test]
1347 async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1348 let app_state = init_test(cx);
1349
1350 app_state
1351 .fs
1352 .as_fake()
1353 .insert_tree(
1354 "/src",
1355 json!({
1356 "test": {
1357 "first.rs": "// First Rust file",
1358 "second.rs": "// Second Rust file",
1359 }
1360 }),
1361 )
1362 .await;
1363
1364 app_state
1365 .fs
1366 .as_fake()
1367 .insert_tree(
1368 "/external-src",
1369 json!({
1370 "test": {
1371 "third.rs": "// Third Rust file",
1372 "fourth.rs": "// Fourth Rust file",
1373 }
1374 }),
1375 )
1376 .await;
1377
1378 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1379 cx.update(|cx| {
1380 project.update(cx, |project, cx| {
1381 project.find_or_create_local_worktree("/external-src", false, cx)
1382 })
1383 })
1384 .detach();
1385 cx.background_executor.run_until_parked();
1386
1387 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1388 let worktree_id = cx.read(|cx| {
1389 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1390 assert_eq!(worktrees.len(), 1,);
1391
1392 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1393 });
1394 workspace
1395 .update(cx, |workspace, cx| {
1396 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1397 })
1398 .detach();
1399 cx.background_executor.run_until_parked();
1400 let external_worktree_id = cx.read(|cx| {
1401 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1402 assert_eq!(
1403 worktrees.len(),
1404 2,
1405 "External file should get opened in a new worktree"
1406 );
1407
1408 WorktreeId::from_usize(
1409 worktrees
1410 .into_iter()
1411 .find(|worktree| {
1412 worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1413 })
1414 .expect("New worktree should have a different id")
1415 .entity_id()
1416 .as_u64() as usize,
1417 )
1418 });
1419 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1420
1421 let initial_history_items =
1422 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1423 assert_eq!(
1424 initial_history_items,
1425 vec![FoundPath::new(
1426 ProjectPath {
1427 worktree_id: external_worktree_id,
1428 path: Arc::from(Path::new("")),
1429 },
1430 Some(PathBuf::from("/external-src/test/third.rs"))
1431 )],
1432 "Should show external file with its full path in the history after it was open"
1433 );
1434
1435 let updated_history_items =
1436 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1437 assert_eq!(
1438 updated_history_items,
1439 vec![
1440 FoundPath::new(
1441 ProjectPath {
1442 worktree_id,
1443 path: Arc::from(Path::new("test/second.rs")),
1444 },
1445 Some(PathBuf::from("/src/test/second.rs"))
1446 ),
1447 FoundPath::new(
1448 ProjectPath {
1449 worktree_id: external_worktree_id,
1450 path: Arc::from(Path::new("")),
1451 },
1452 Some(PathBuf::from("/external-src/test/third.rs"))
1453 ),
1454 ],
1455 "Should keep external file with history updates",
1456 );
1457 }
1458
1459 #[gpui::test]
1460 async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1461 let app_state = init_test(cx);
1462
1463 app_state
1464 .fs
1465 .as_fake()
1466 .insert_tree(
1467 "/src",
1468 json!({
1469 "test": {
1470 "first.rs": "// First Rust file",
1471 "second.rs": "// Second Rust file",
1472 "third.rs": "// Third Rust file",
1473 }
1474 }),
1475 )
1476 .await;
1477
1478 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1479 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1480
1481 // generate some history to select from
1482 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1483 cx.executor().run_until_parked();
1484 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1485 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1486 let current_history =
1487 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1488
1489 for expected_selected_index in 0..current_history.len() {
1490 cx.dispatch_action(Toggle);
1491 let picker = active_file_picker(&workspace, cx);
1492 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1493 assert_eq!(
1494 selected_index, expected_selected_index,
1495 "Should select the next item in the history"
1496 );
1497 }
1498
1499 cx.dispatch_action(Toggle);
1500 let selected_index = workspace.update(cx, |workspace, cx| {
1501 workspace
1502 .active_modal::<FileFinder>(cx)
1503 .unwrap()
1504 .read(cx)
1505 .picker
1506 .read(cx)
1507 .delegate
1508 .selected_index()
1509 });
1510 assert_eq!(
1511 selected_index, 0,
1512 "Should wrap around the history and start all over"
1513 );
1514 }
1515
1516 #[gpui::test]
1517 async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1518 let app_state = init_test(cx);
1519
1520 app_state
1521 .fs
1522 .as_fake()
1523 .insert_tree(
1524 "/src",
1525 json!({
1526 "test": {
1527 "first.rs": "// First Rust file",
1528 "second.rs": "// Second Rust file",
1529 "third.rs": "// Third Rust file",
1530 "fourth.rs": "// Fourth Rust file",
1531 }
1532 }),
1533 )
1534 .await;
1535
1536 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1537 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1538 let worktree_id = cx.read(|cx| {
1539 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1540 assert_eq!(worktrees.len(), 1,);
1541
1542 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1543 });
1544
1545 // generate some history to select from
1546 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1547 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1548 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1549 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1550
1551 let finder = open_file_picker(&workspace, cx);
1552 let first_query = "f";
1553 finder
1554 .update(cx, |finder, cx| {
1555 finder.delegate.update_matches(first_query.to_string(), cx)
1556 })
1557 .await;
1558 finder.update(cx, |finder, _| {
1559 let delegate = &finder.delegate;
1560 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1561 let history_match = delegate.matches.history.first().unwrap();
1562 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1563 assert_eq!(history_match.0, FoundPath::new(
1564 ProjectPath {
1565 worktree_id,
1566 path: Arc::from(Path::new("test/first.rs")),
1567 },
1568 Some(PathBuf::from("/src/test/first.rs"))
1569 ));
1570 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1571 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1572 });
1573
1574 let second_query = "fsdasdsa";
1575 let finder = active_file_picker(&workspace, cx);
1576 finder
1577 .update(cx, |finder, cx| {
1578 finder.delegate.update_matches(second_query.to_string(), cx)
1579 })
1580 .await;
1581 finder.update(cx, |finder, _| {
1582 let delegate = &finder.delegate;
1583 assert!(
1584 delegate.matches.history.is_empty(),
1585 "No history entries should match {second_query}"
1586 );
1587 assert!(
1588 delegate.matches.search.is_empty(),
1589 "No search entries should match {second_query}"
1590 );
1591 });
1592
1593 let first_query_again = first_query;
1594
1595 let finder = active_file_picker(&workspace, cx);
1596 finder
1597 .update(cx, |finder, cx| {
1598 finder
1599 .delegate
1600 .update_matches(first_query_again.to_string(), cx)
1601 })
1602 .await;
1603 finder.update(cx, |finder, _| {
1604 let delegate = &finder.delegate;
1605 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query_again}, it should be present and others should be filtered out, even after non-matching query");
1606 let history_match = delegate.matches.history.first().unwrap();
1607 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1608 assert_eq!(history_match.0, FoundPath::new(
1609 ProjectPath {
1610 worktree_id,
1611 path: Arc::from(Path::new("test/first.rs")),
1612 },
1613 Some(PathBuf::from("/src/test/first.rs"))
1614 ));
1615 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query_again}, it should be present, even after non-matching query");
1616 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1617 });
1618 }
1619
1620 #[gpui::test]
1621 async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1622 let app_state = init_test(cx);
1623
1624 app_state
1625 .fs
1626 .as_fake()
1627 .insert_tree(
1628 "/src",
1629 json!({
1630 "collab_ui": {
1631 "first.rs": "// First Rust file",
1632 "second.rs": "// Second Rust file",
1633 "third.rs": "// Third Rust file",
1634 "collab_ui.rs": "// Fourth Rust file",
1635 }
1636 }),
1637 )
1638 .await;
1639
1640 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1641 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1642 // generate some history to select from
1643 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1644 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1645 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1646 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1647
1648 let finder = open_file_picker(&workspace, cx);
1649 let query = "collab_ui";
1650 cx.simulate_input(query);
1651 finder.update(cx, |finder, _| {
1652 let delegate = &finder.delegate;
1653 assert!(
1654 delegate.matches.history.is_empty(),
1655 "History items should not math query {query}, they should be matched by name only"
1656 );
1657
1658 let search_entries = delegate
1659 .matches
1660 .search
1661 .iter()
1662 .map(|path_match| path_match.path.to_path_buf())
1663 .collect::<Vec<_>>();
1664 assert_eq!(
1665 search_entries,
1666 vec![
1667 PathBuf::from("collab_ui/collab_ui.rs"),
1668 PathBuf::from("collab_ui/third.rs"),
1669 PathBuf::from("collab_ui/first.rs"),
1670 PathBuf::from("collab_ui/second.rs"),
1671 ],
1672 "Despite all search results having the same directory name, the most matching one should be on top"
1673 );
1674 });
1675 }
1676
1677 #[gpui::test]
1678 async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1679 let app_state = init_test(cx);
1680
1681 app_state
1682 .fs
1683 .as_fake()
1684 .insert_tree(
1685 "/src",
1686 json!({
1687 "test": {
1688 "first.rs": "// First Rust file",
1689 "nonexistent.rs": "// Second Rust file",
1690 "third.rs": "// Third Rust file",
1691 }
1692 }),
1693 )
1694 .await;
1695
1696 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1697 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1698 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1699 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1700 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1701 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1702
1703 let picker = open_file_picker(&workspace, cx);
1704 cx.simulate_input("rs");
1705
1706 picker.update(cx, |finder, _| {
1707 let history_entries = finder.delegate
1708 .matches
1709 .history
1710 .iter()
1711 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1712 .collect::<Vec<_>>();
1713 assert_eq!(
1714 history_entries,
1715 vec![
1716 PathBuf::from("test/first.rs"),
1717 PathBuf::from("test/third.rs"),
1718 ],
1719 "Should have all opened files in the history, except the ones that do not exist on disk"
1720 );
1721 });
1722 }
1723
1724 async fn open_close_queried_buffer(
1725 input: &str,
1726 expected_matches: usize,
1727 expected_editor_title: &str,
1728 workspace: &View<Workspace>,
1729 cx: &mut gpui::VisualTestContext<'_>,
1730 ) -> Vec<FoundPath> {
1731 let picker = open_file_picker(&workspace, cx);
1732 cx.simulate_input(input);
1733
1734 let history_items = picker.update(cx, |finder, _| {
1735 assert_eq!(
1736 finder.delegate.matches.len(),
1737 expected_matches,
1738 "Unexpected number of matches found for query {input}"
1739 );
1740 finder.delegate.history_items.clone()
1741 });
1742
1743 cx.dispatch_action(SelectNext);
1744 cx.dispatch_action(Confirm);
1745
1746 cx.read(|cx| {
1747 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1748 let active_editor_title = active_editor.read(cx).title(cx);
1749 assert_eq!(
1750 expected_editor_title, active_editor_title,
1751 "Unexpected editor title for query {input}"
1752 );
1753 });
1754
1755 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1756
1757 history_items
1758 }
1759
1760 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1761 cx.update(|cx| {
1762 let state = AppState::test(cx);
1763 theme::init(theme::LoadThemes::JustBase, cx);
1764 language::init(cx);
1765 super::init(cx);
1766 editor::init(cx);
1767 workspace::init_settings(cx);
1768 Project::init_settings(cx);
1769 state
1770 })
1771 }
1772
1773 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1774 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1775 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1776 raw_query: test_str.to_owned(),
1777 file_query_end: if path_like_str == test_str {
1778 None
1779 } else {
1780 Some(path_like_str.len())
1781 },
1782 })
1783 })
1784 .unwrap()
1785 }
1786
1787 fn build_find_picker(
1788 project: Model<Project>,
1789 cx: &mut TestAppContext,
1790 ) -> (
1791 View<Picker<FileFinderDelegate>>,
1792 View<Workspace>,
1793 &mut VisualTestContext,
1794 ) {
1795 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1796 let picker = open_file_picker(&workspace, cx);
1797 (picker, workspace, cx)
1798 }
1799
1800 #[track_caller]
1801 fn open_file_picker(
1802 workspace: &View<Workspace>,
1803 cx: &mut VisualTestContext,
1804 ) -> View<Picker<FileFinderDelegate>> {
1805 cx.dispatch_action(Toggle);
1806 active_file_picker(workspace, cx)
1807 }
1808
1809 #[track_caller]
1810 fn active_file_picker(
1811 workspace: &View<Workspace>,
1812 cx: &mut VisualTestContext,
1813 ) -> View<Picker<FileFinderDelegate>> {
1814 workspace.update(cx, |workspace, cx| {
1815 workspace
1816 .active_modal::<FileFinder>(cx)
1817 .unwrap()
1818 .read(cx)
1819 .picker
1820 .clone()
1821 })
1822 }
1823}