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