1use collections::HashMap;
2use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
3use fuzzy::{CharBag, PathMatch, PathMatchCandidate};
4use gpui::{
5 actions, rems, AppContext, DismissEvent, 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::{prelude::*, HighlightedLabel, ListItem, ListItemSpacing};
19use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
20use workspace::{ModalView, Workspace};
21
22actions!(file_finder, [Toggle]);
23
24impl ModalView for FileFinder {}
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.new_view(|cx| Picker::new(delegate, cx)),
110 }
111 }
112}
113
114impl EventEmitter<DismissEvent> for FileFinder {}
115impl FocusableView for FileFinder {
116 fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
117 self.picker.focus_handle(cx)
118 }
119}
120impl Render for FileFinder {
121 fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
122 v_stack().w(rems(34.)).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| PathMatchCandidateSet {
356 snapshot: worktree.read(cx).snapshot(),
357 include_ignored: true,
358 include_root_name,
359 })
360 .collect::<Vec<_>>();
361
362 let search_id = util::post_inc(&mut self.search_count);
363 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
364 self.cancel_flag = Arc::new(AtomicBool::new(false));
365 let cancel_flag = self.cancel_flag.clone();
366 cx.spawn(|picker, mut cx| async move {
367 let matches = fuzzy::match_path_sets(
368 candidate_sets.as_slice(),
369 query.path_like.path_query(),
370 relative_to,
371 false,
372 100,
373 &cancel_flag,
374 cx.background_executor().clone(),
375 )
376 .await;
377 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
378 picker
379 .update(&mut cx, |picker, cx| {
380 picker
381 .delegate
382 .set_search_matches(search_id, did_cancel, query, matches, cx)
383 })
384 .log_err();
385 })
386 }
387
388 fn set_search_matches(
389 &mut self,
390 search_id: usize,
391 did_cancel: bool,
392 query: PathLikeWithPosition<FileSearchQuery>,
393 matches: Vec<PathMatch>,
394 cx: &mut ViewContext<Picker<Self>>,
395 ) {
396 if search_id >= self.latest_search_id {
397 self.latest_search_id = search_id;
398 let extend_old_matches = self.latest_search_did_cancel
399 && Some(query.path_like.path_query())
400 == self
401 .latest_search_query
402 .as_ref()
403 .map(|query| query.path_like.path_query());
404 self.matches
405 .push_new_matches(&self.history_items, &query, matches, extend_old_matches);
406 self.latest_search_query = Some(query);
407 self.latest_search_did_cancel = did_cancel;
408 cx.notify();
409 }
410 }
411
412 fn labels_for_match(
413 &self,
414 path_match: Match,
415 cx: &AppContext,
416 ix: usize,
417 ) -> (String, Vec<usize>, String, Vec<usize>) {
418 let (file_name, file_name_positions, full_path, full_path_positions) = match path_match {
419 Match::History(found_path, found_path_match) => {
420 let worktree_id = found_path.project.worktree_id;
421 let project_relative_path = &found_path.project.path;
422 let has_worktree = self
423 .project
424 .read(cx)
425 .worktree_for_id(worktree_id, cx)
426 .is_some();
427
428 if !has_worktree {
429 if let Some(absolute_path) = &found_path.absolute {
430 return (
431 absolute_path
432 .file_name()
433 .map_or_else(
434 || project_relative_path.to_string_lossy(),
435 |file_name| file_name.to_string_lossy(),
436 )
437 .to_string(),
438 Vec::new(),
439 absolute_path.to_string_lossy().to_string(),
440 Vec::new(),
441 );
442 }
443 }
444
445 let mut path = Arc::clone(project_relative_path);
446 if project_relative_path.as_ref() == Path::new("") {
447 if let Some(absolute_path) = &found_path.absolute {
448 path = Arc::from(absolute_path.as_path());
449 }
450 }
451
452 let mut path_match = PathMatch {
453 score: ix as f64,
454 positions: Vec::new(),
455 worktree_id: worktree_id.to_usize(),
456 path,
457 path_prefix: "".into(),
458 distance_to_relative_ancestor: usize::MAX,
459 };
460 if let Some(found_path_match) = found_path_match {
461 path_match
462 .positions
463 .extend(found_path_match.positions.iter())
464 }
465
466 self.labels_for_path_match(&path_match)
467 }
468 Match::Search(path_match) => self.labels_for_path_match(path_match),
469 };
470
471 if file_name_positions.is_empty() {
472 if let Some(user_home_path) = std::env::var("HOME").ok() {
473 let user_home_path = user_home_path.trim();
474 if !user_home_path.is_empty() {
475 if (&full_path).starts_with(user_home_path) {
476 return (
477 file_name,
478 file_name_positions,
479 full_path.replace(user_home_path, "~"),
480 full_path_positions,
481 );
482 }
483 }
484 }
485 }
486
487 (
488 file_name,
489 file_name_positions,
490 full_path,
491 full_path_positions,
492 )
493 }
494
495 fn labels_for_path_match(
496 &self,
497 path_match: &PathMatch,
498 ) -> (String, Vec<usize>, String, Vec<usize>) {
499 let path = &path_match.path;
500 let path_string = path.to_string_lossy();
501 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
502 let path_positions = path_match.positions.clone();
503
504 let file_name = path.file_name().map_or_else(
505 || path_match.path_prefix.to_string(),
506 |file_name| file_name.to_string_lossy().to_string(),
507 );
508 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
509 - file_name.chars().count();
510 let file_name_positions = path_positions
511 .iter()
512 .filter_map(|pos| {
513 if pos >= &file_name_start {
514 Some(pos - file_name_start)
515 } else {
516 None
517 }
518 })
519 .collect();
520
521 (file_name, file_name_positions, full_path, path_positions)
522 }
523}
524
525impl PickerDelegate for FileFinderDelegate {
526 type ListItem = ListItem;
527
528 fn placeholder_text(&self) -> Arc<str> {
529 "Search project files...".into()
530 }
531
532 fn match_count(&self) -> usize {
533 self.matches.len()
534 }
535
536 fn selected_index(&self) -> usize {
537 self.selected_index.unwrap_or(0)
538 }
539
540 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<Self>>) {
541 self.selected_index = Some(ix);
542 cx.notify();
543 }
544
545 fn separators_after_indices(&self) -> Vec<usize> {
546 let history_items = self.matches.history.len();
547 if history_items == 0 || self.matches.search.is_empty() {
548 Vec::new()
549 } else {
550 vec![history_items - 1]
551 }
552 }
553
554 fn update_matches(
555 &mut self,
556 raw_query: String,
557 cx: &mut ViewContext<Picker<Self>>,
558 ) -> Task<()> {
559 let raw_query = raw_query.trim();
560 if raw_query.is_empty() {
561 let project = self.project.read(cx);
562 self.latest_search_id = post_inc(&mut self.search_count);
563 self.matches = Matches {
564 history: self
565 .history_items
566 .iter()
567 .filter(|history_item| {
568 project
569 .worktree_for_id(history_item.project.worktree_id, cx)
570 .is_some()
571 || (project.is_local() && history_item.absolute.is_some())
572 })
573 .cloned()
574 .map(|p| (p, None))
575 .collect(),
576 search: Vec::new(),
577 };
578 cx.notify();
579 Task::ready(())
580 } else {
581 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
582 Ok::<_, std::convert::Infallible>(FileSearchQuery {
583 raw_query: raw_query.to_owned(),
584 file_query_end: if path_like_str == raw_query {
585 None
586 } else {
587 Some(path_like_str.len())
588 },
589 })
590 })
591 .expect("infallible");
592 self.spawn_search(query, cx)
593 }
594 }
595
596 fn confirm(&mut self, secondary: bool, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
597 if let Some(m) = self.matches.get(self.selected_index()) {
598 if let Some(workspace) = self.workspace.upgrade() {
599 let open_task = workspace.update(cx, move |workspace, cx| {
600 let split_or_open = |workspace: &mut Workspace, project_path, cx| {
601 if secondary {
602 workspace.split_path(project_path, cx)
603 } else {
604 workspace.open_path(project_path, None, true, cx)
605 }
606 };
607 match m {
608 Match::History(history_match, _) => {
609 let worktree_id = history_match.project.worktree_id;
610 if workspace
611 .project()
612 .read(cx)
613 .worktree_for_id(worktree_id, cx)
614 .is_some()
615 {
616 split_or_open(
617 workspace,
618 ProjectPath {
619 worktree_id,
620 path: Arc::clone(&history_match.project.path),
621 },
622 cx,
623 )
624 } else {
625 match history_match.absolute.as_ref() {
626 Some(abs_path) => {
627 if secondary {
628 workspace.split_abs_path(
629 abs_path.to_path_buf(),
630 false,
631 cx,
632 )
633 } else {
634 workspace.open_abs_path(
635 abs_path.to_path_buf(),
636 false,
637 cx,
638 )
639 }
640 }
641 None => split_or_open(
642 workspace,
643 ProjectPath {
644 worktree_id,
645 path: Arc::clone(&history_match.project.path),
646 },
647 cx,
648 ),
649 }
650 }
651 }
652 Match::Search(m) => split_or_open(
653 workspace,
654 ProjectPath {
655 worktree_id: WorktreeId::from_usize(m.worktree_id),
656 path: m.path.clone(),
657 },
658 cx,
659 ),
660 }
661 });
662
663 let row = self
664 .latest_search_query
665 .as_ref()
666 .and_then(|query| query.row)
667 .map(|row| row.saturating_sub(1));
668 let col = self
669 .latest_search_query
670 .as_ref()
671 .and_then(|query| query.column)
672 .unwrap_or(0)
673 .saturating_sub(1);
674 let finder = self.file_finder.clone();
675
676 cx.spawn(|_, mut cx| async move {
677 let item = open_task.await.log_err()?;
678 if let Some(row) = row {
679 if let Some(active_editor) = item.downcast::<Editor>() {
680 active_editor
681 .downgrade()
682 .update(&mut cx, |editor, cx| {
683 let snapshot = editor.snapshot(cx).display_snapshot;
684 let point = snapshot
685 .buffer_snapshot
686 .clip_point(Point::new(row, col), Bias::Left);
687 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
688 s.select_ranges([point..point])
689 });
690 })
691 .log_err();
692 }
693 }
694 finder.update(&mut cx, |_, cx| cx.emit(DismissEvent)).ok()?;
695
696 Some(())
697 })
698 .detach();
699 }
700 }
701 }
702
703 fn dismissed(&mut self, cx: &mut ViewContext<Picker<FileFinderDelegate>>) {
704 self.file_finder
705 .update(cx, |_, cx| cx.emit(DismissEvent))
706 .log_err();
707 }
708
709 fn render_match(
710 &self,
711 ix: usize,
712 selected: bool,
713 cx: &mut ViewContext<Picker<Self>>,
714 ) -> Option<Self::ListItem> {
715 let path_match = self
716 .matches
717 .get(ix)
718 .expect("Invalid matches state: no element for index {ix}");
719
720 let (file_name, file_name_positions, full_path, full_path_positions) =
721 self.labels_for_match(path_match, cx, ix);
722
723 Some(
724 ListItem::new(ix)
725 .spacing(ListItemSpacing::Sparse)
726 .inset(true)
727 .selected(selected)
728 .child(
729 v_stack()
730 .child(HighlightedLabel::new(file_name, file_name_positions))
731 .child(HighlightedLabel::new(full_path, full_path_positions)),
732 ),
733 )
734 }
735}
736
737#[cfg(test)]
738mod tests {
739 use std::{assert_eq, path::Path, time::Duration};
740
741 use super::*;
742 use editor::Editor;
743 use gpui::{Entity, TestAppContext, VisualTestContext};
744 use menu::{Confirm, SelectNext};
745 use serde_json::json;
746 use workspace::{AppState, Workspace};
747
748 #[ctor::ctor]
749 fn init_logger() {
750 if std::env::var("RUST_LOG").is_ok() {
751 env_logger::init();
752 }
753 }
754
755 #[gpui::test]
756 async fn test_matching_paths(cx: &mut TestAppContext) {
757 let app_state = init_test(cx);
758 app_state
759 .fs
760 .as_fake()
761 .insert_tree(
762 "/root",
763 json!({
764 "a": {
765 "banana": "",
766 "bandana": "",
767 }
768 }),
769 )
770 .await;
771
772 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
773
774 let (picker, workspace, cx) = build_find_picker(project, cx);
775
776 cx.simulate_input("bna");
777 picker.update(cx, |picker, _| {
778 assert_eq!(picker.delegate.matches.len(), 2);
779 });
780 cx.dispatch_action(SelectNext);
781 cx.dispatch_action(Confirm);
782 cx.read(|cx| {
783 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
784 assert_eq!(active_editor.read(cx).title(cx), "bandana");
785 });
786
787 for bandana_query in [
788 "bandana",
789 " bandana",
790 "bandana ",
791 " bandana ",
792 " ndan ",
793 " band ",
794 ] {
795 picker
796 .update(cx, |picker, cx| {
797 picker
798 .delegate
799 .update_matches(bandana_query.to_string(), cx)
800 })
801 .await;
802 picker.update(cx, |picker, _| {
803 assert_eq!(
804 picker.delegate.matches.len(),
805 1,
806 "Wrong number of matches for bandana query '{bandana_query}'"
807 );
808 });
809 cx.dispatch_action(SelectNext);
810 cx.dispatch_action(Confirm);
811 cx.read(|cx| {
812 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
813 assert_eq!(
814 active_editor.read(cx).title(cx),
815 "bandana",
816 "Wrong match for bandana query '{bandana_query}'"
817 );
818 });
819 }
820 }
821
822 #[gpui::test]
823 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
824 let app_state = init_test(cx);
825
826 let first_file_name = "first.rs";
827 let first_file_contents = "// First Rust file";
828 app_state
829 .fs
830 .as_fake()
831 .insert_tree(
832 "/src",
833 json!({
834 "test": {
835 first_file_name: first_file_contents,
836 "second.rs": "// Second Rust file",
837 }
838 }),
839 )
840 .await;
841
842 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
843
844 let (picker, workspace, cx) = build_find_picker(project, cx);
845
846 let file_query = &first_file_name[..3];
847 let file_row = 1;
848 let file_column = 3;
849 assert!(file_column <= first_file_contents.len());
850 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
851 picker
852 .update(cx, |finder, cx| {
853 finder
854 .delegate
855 .update_matches(query_inside_file.to_string(), cx)
856 })
857 .await;
858 picker.update(cx, |finder, _| {
859 let finder = &finder.delegate;
860 assert_eq!(finder.matches.len(), 1);
861 let latest_search_query = finder
862 .latest_search_query
863 .as_ref()
864 .expect("Finder should have a query after the update_matches call");
865 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
866 assert_eq!(
867 latest_search_query.path_like.file_query_end,
868 Some(file_query.len())
869 );
870 assert_eq!(latest_search_query.row, Some(file_row));
871 assert_eq!(latest_search_query.column, Some(file_column as u32));
872 });
873
874 cx.dispatch_action(SelectNext);
875 cx.dispatch_action(Confirm);
876
877 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
878 cx.executor().advance_clock(Duration::from_secs(2));
879
880 editor.update(cx, |editor, cx| {
881 let all_selections = editor.selections.all_adjusted(cx);
882 assert_eq!(
883 all_selections.len(),
884 1,
885 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
886 );
887 let caret_selection = all_selections.into_iter().next().unwrap();
888 assert_eq!(caret_selection.start, caret_selection.end,
889 "Caret selection should have its start and end at the same position");
890 assert_eq!(file_row, caret_selection.start.row + 1,
891 "Query inside file should get caret with the same focus row");
892 assert_eq!(file_column, caret_selection.start.column as usize + 1,
893 "Query inside file should get caret with the same focus column");
894 });
895 }
896
897 #[gpui::test]
898 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
899 let app_state = init_test(cx);
900
901 let first_file_name = "first.rs";
902 let first_file_contents = "// First Rust file";
903 app_state
904 .fs
905 .as_fake()
906 .insert_tree(
907 "/src",
908 json!({
909 "test": {
910 first_file_name: first_file_contents,
911 "second.rs": "// Second Rust file",
912 }
913 }),
914 )
915 .await;
916
917 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
918
919 let (picker, workspace, cx) = build_find_picker(project, cx);
920
921 let file_query = &first_file_name[..3];
922 let file_row = 200;
923 let file_column = 300;
924 assert!(file_column > first_file_contents.len());
925 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
926 picker
927 .update(cx, |picker, cx| {
928 picker
929 .delegate
930 .update_matches(query_outside_file.to_string(), cx)
931 })
932 .await;
933 picker.update(cx, |finder, _| {
934 let delegate = &finder.delegate;
935 assert_eq!(delegate.matches.len(), 1);
936 let latest_search_query = delegate
937 .latest_search_query
938 .as_ref()
939 .expect("Finder should have a query after the update_matches call");
940 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
941 assert_eq!(
942 latest_search_query.path_like.file_query_end,
943 Some(file_query.len())
944 );
945 assert_eq!(latest_search_query.row, Some(file_row));
946 assert_eq!(latest_search_query.column, Some(file_column as u32));
947 });
948
949 cx.dispatch_action(SelectNext);
950 cx.dispatch_action(Confirm);
951
952 let editor = cx.update(|cx| workspace.read(cx).active_item_as::<Editor>(cx).unwrap());
953 cx.executor().advance_clock(Duration::from_secs(2));
954
955 editor.update(cx, |editor, cx| {
956 let all_selections = editor.selections.all_adjusted(cx);
957 assert_eq!(
958 all_selections.len(),
959 1,
960 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
961 );
962 let caret_selection = all_selections.into_iter().next().unwrap();
963 assert_eq!(caret_selection.start, caret_selection.end,
964 "Caret selection should have its start and end at the same position");
965 assert_eq!(0, caret_selection.start.row,
966 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
967 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
968 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
969 });
970 }
971
972 #[gpui::test]
973 async fn test_matching_cancellation(cx: &mut TestAppContext) {
974 let app_state = init_test(cx);
975 app_state
976 .fs
977 .as_fake()
978 .insert_tree(
979 "/dir",
980 json!({
981 "hello": "",
982 "goodbye": "",
983 "halogen-light": "",
984 "happiness": "",
985 "height": "",
986 "hi": "",
987 "hiccup": "",
988 }),
989 )
990 .await;
991
992 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
993
994 let (picker, _, cx) = build_find_picker(project, cx);
995
996 let query = test_path_like("hi");
997 picker
998 .update(cx, |picker, cx| {
999 picker.delegate.spawn_search(query.clone(), cx)
1000 })
1001 .await;
1002
1003 picker.update(cx, |picker, _cx| {
1004 assert_eq!(picker.delegate.matches.len(), 5)
1005 });
1006
1007 picker.update(cx, |picker, cx| {
1008 let delegate = &mut picker.delegate;
1009 assert!(
1010 delegate.matches.history.is_empty(),
1011 "Search matches expected"
1012 );
1013 let matches = delegate.matches.search.clone();
1014
1015 // Simulate a search being cancelled after the time limit,
1016 // returning only a subset of the matches that would have been found.
1017 drop(delegate.spawn_search(query.clone(), cx));
1018 delegate.set_search_matches(
1019 delegate.latest_search_id,
1020 true, // did-cancel
1021 query.clone(),
1022 vec![matches[1].clone(), matches[3].clone()],
1023 cx,
1024 );
1025
1026 // Simulate another cancellation.
1027 drop(delegate.spawn_search(query.clone(), cx));
1028 delegate.set_search_matches(
1029 delegate.latest_search_id,
1030 true, // did-cancel
1031 query.clone(),
1032 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1033 cx,
1034 );
1035
1036 assert!(
1037 delegate.matches.history.is_empty(),
1038 "Search matches expected"
1039 );
1040 assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1041 });
1042 }
1043
1044 #[gpui::test]
1045 async fn test_ignored_root(cx: &mut TestAppContext) {
1046 let app_state = init_test(cx);
1047 app_state
1048 .fs
1049 .as_fake()
1050 .insert_tree(
1051 "/ancestor",
1052 json!({
1053 ".gitignore": "ignored-root",
1054 "ignored-root": {
1055 "happiness": "",
1056 "height": "",
1057 "hi": "",
1058 "hiccup": "",
1059 },
1060 "tracked-root": {
1061 ".gitignore": "height",
1062 "happiness": "",
1063 "height": "",
1064 "hi": "",
1065 "hiccup": "",
1066 },
1067 }),
1068 )
1069 .await;
1070
1071 let project = Project::test(
1072 app_state.fs.clone(),
1073 [
1074 "/ancestor/tracked-root".as_ref(),
1075 "/ancestor/ignored-root".as_ref(),
1076 ],
1077 cx,
1078 )
1079 .await;
1080
1081 let (picker, _, cx) = build_find_picker(project, cx);
1082
1083 picker
1084 .update(cx, |picker, cx| {
1085 picker.delegate.spawn_search(test_path_like("hi"), cx)
1086 })
1087 .await;
1088 picker.update(cx, |picker, _| {
1089 assert_eq!(
1090 collect_search_results(picker),
1091 vec![
1092 PathBuf::from("ignored-root/happiness"),
1093 PathBuf::from("ignored-root/height"),
1094 PathBuf::from("ignored-root/hi"),
1095 PathBuf::from("ignored-root/hiccup"),
1096 PathBuf::from("tracked-root/happiness"),
1097 PathBuf::from("tracked-root/height"),
1098 PathBuf::from("tracked-root/hi"),
1099 PathBuf::from("tracked-root/hiccup"),
1100 ],
1101 "All files in all roots (including gitignored) should be searched"
1102 )
1103 });
1104 }
1105
1106 #[gpui::test]
1107 async fn test_ignored_files(cx: &mut TestAppContext) {
1108 let app_state = init_test(cx);
1109 app_state
1110 .fs
1111 .as_fake()
1112 .insert_tree(
1113 "/root",
1114 json!({
1115 ".git": {},
1116 ".gitignore": "ignored_a\n.env\n",
1117 "a": {
1118 "banana_env": "11",
1119 "bandana_env": "12",
1120 },
1121 "ignored_a": {
1122 "ignored_banana_env": "21",
1123 "ignored_bandana_env": "22",
1124 "ignored_nested": {
1125 "ignored_nested_banana_env": "31",
1126 "ignored_nested_bandana_env": "32",
1127 },
1128 },
1129 ".env": "something",
1130 }),
1131 )
1132 .await;
1133
1134 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1135
1136 let (picker, workspace, cx) = build_find_picker(project, cx);
1137
1138 cx.simulate_input("env");
1139 picker.update(cx, |picker, _| {
1140 assert_eq!(
1141 collect_search_results(picker),
1142 vec![
1143 PathBuf::from(".env"),
1144 PathBuf::from("a/banana_env"),
1145 PathBuf::from("a/bandana_env"),
1146 ],
1147 "Root gitignored files and all non-gitignored files should be searched"
1148 )
1149 });
1150
1151 let _ = workspace
1152 .update(cx, |workspace, cx| {
1153 workspace.open_abs_path(
1154 PathBuf::from("/root/ignored_a/ignored_banana_env"),
1155 true,
1156 cx,
1157 )
1158 })
1159 .await
1160 .unwrap();
1161 cx.run_until_parked();
1162 cx.simulate_input("env");
1163 picker.update(cx, |picker, _| {
1164 assert_eq!(
1165 collect_search_results(picker),
1166 vec![
1167 PathBuf::from(".env"),
1168 PathBuf::from("a/banana_env"),
1169 PathBuf::from("a/bandana_env"),
1170 PathBuf::from("ignored_a/ignored_banana_env"),
1171 PathBuf::from("ignored_a/ignored_bandana_env"),
1172 ],
1173 "Root gitignored dir got listed and its entries got into worktree, but all gitignored dirs below it were not listed. Old entries + new listed gitignored entries should be searched"
1174 )
1175 });
1176 }
1177
1178 #[gpui::test]
1179 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1180 let app_state = init_test(cx);
1181 app_state
1182 .fs
1183 .as_fake()
1184 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1185 .await;
1186
1187 let project = Project::test(
1188 app_state.fs.clone(),
1189 ["/root/the-parent-dir/the-file".as_ref()],
1190 cx,
1191 )
1192 .await;
1193
1194 let (picker, _, cx) = build_find_picker(project, cx);
1195
1196 // Even though there is only one worktree, that worktree's filename
1197 // is included in the matching, because the worktree is a single file.
1198 picker
1199 .update(cx, |picker, cx| {
1200 picker.delegate.spawn_search(test_path_like("thf"), cx)
1201 })
1202 .await;
1203 cx.read(|cx| {
1204 let picker = picker.read(cx);
1205 let delegate = &picker.delegate;
1206 assert!(
1207 delegate.matches.history.is_empty(),
1208 "Search matches expected"
1209 );
1210 let matches = delegate.matches.search.clone();
1211 assert_eq!(matches.len(), 1);
1212
1213 let (file_name, file_name_positions, full_path, full_path_positions) =
1214 delegate.labels_for_path_match(&matches[0]);
1215 assert_eq!(file_name, "the-file");
1216 assert_eq!(file_name_positions, &[0, 1, 4]);
1217 assert_eq!(full_path, "the-file");
1218 assert_eq!(full_path_positions, &[0, 1, 4]);
1219 });
1220
1221 // Since the worktree root is a file, searching for its name followed by a slash does
1222 // not match anything.
1223 picker
1224 .update(cx, |f, cx| {
1225 f.delegate.spawn_search(test_path_like("thf/"), cx)
1226 })
1227 .await;
1228 picker.update(cx, |f, _| assert_eq!(f.delegate.matches.len(), 0));
1229 }
1230
1231 #[gpui::test]
1232 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1233 let app_state = init_test(cx);
1234 app_state
1235 .fs
1236 .as_fake()
1237 .insert_tree(
1238 "/root",
1239 json!({
1240 "dir1": { "a.txt": "" },
1241 "dir2": {
1242 "a.txt": "",
1243 "b.txt": ""
1244 }
1245 }),
1246 )
1247 .await;
1248
1249 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1250 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1251
1252 let worktree_id = cx.read(|cx| {
1253 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1254 assert_eq!(worktrees.len(), 1);
1255 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1256 });
1257
1258 // When workspace has an active item, sort items which are closer to that item
1259 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1260 // so that one should be sorted earlier
1261 let b_path = ProjectPath {
1262 worktree_id,
1263 path: Arc::from(Path::new("/root/dir2/b.txt")),
1264 };
1265 workspace
1266 .update(cx, |workspace, cx| {
1267 workspace.open_path(b_path, None, true, cx)
1268 })
1269 .await
1270 .unwrap();
1271 let finder = open_file_picker(&workspace, cx);
1272 finder
1273 .update(cx, |f, cx| {
1274 f.delegate.spawn_search(test_path_like("a.txt"), cx)
1275 })
1276 .await;
1277
1278 finder.update(cx, |f, _| {
1279 let delegate = &f.delegate;
1280 assert!(
1281 delegate.matches.history.is_empty(),
1282 "Search matches expected"
1283 );
1284 let matches = delegate.matches.search.clone();
1285 assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1286 assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1287 });
1288 }
1289
1290 #[gpui::test]
1291 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1292 let app_state = init_test(cx);
1293 app_state
1294 .fs
1295 .as_fake()
1296 .insert_tree(
1297 "/root",
1298 json!({
1299 "dir1": {},
1300 "dir2": {
1301 "dir3": {}
1302 }
1303 }),
1304 )
1305 .await;
1306
1307 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1308 let (picker, _workspace, cx) = build_find_picker(project, cx);
1309
1310 picker
1311 .update(cx, |f, cx| {
1312 f.delegate.spawn_search(test_path_like("dir"), cx)
1313 })
1314 .await;
1315 cx.read(|cx| {
1316 let finder = picker.read(cx);
1317 assert_eq!(finder.delegate.matches.len(), 0);
1318 });
1319 }
1320
1321 #[gpui::test]
1322 async fn test_query_history(cx: &mut gpui::TestAppContext) {
1323 let app_state = init_test(cx);
1324
1325 app_state
1326 .fs
1327 .as_fake()
1328 .insert_tree(
1329 "/src",
1330 json!({
1331 "test": {
1332 "first.rs": "// First Rust file",
1333 "second.rs": "// Second Rust file",
1334 "third.rs": "// Third Rust file",
1335 }
1336 }),
1337 )
1338 .await;
1339
1340 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1341 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1342 let worktree_id = cx.read(|cx| {
1343 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1344 assert_eq!(worktrees.len(), 1);
1345 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1346 });
1347
1348 // Open and close panels, getting their history items afterwards.
1349 // Ensure history items get populated with opened items, and items are kept in a certain order.
1350 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1351 //
1352 // TODO: without closing, the opened items do not propagate their history changes for some reason
1353 // it does work in real app though, only tests do not propagate.
1354 workspace.update(cx, |_, cx| cx.focused());
1355
1356 let initial_history = open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1357 assert!(
1358 initial_history.is_empty(),
1359 "Should have no history before opening any files"
1360 );
1361
1362 let history_after_first =
1363 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1364 assert_eq!(
1365 history_after_first,
1366 vec![FoundPath::new(
1367 ProjectPath {
1368 worktree_id,
1369 path: Arc::from(Path::new("test/first.rs")),
1370 },
1371 Some(PathBuf::from("/src/test/first.rs"))
1372 )],
1373 "Should show 1st opened item in the history when opening the 2nd item"
1374 );
1375
1376 let history_after_second =
1377 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1378 assert_eq!(
1379 history_after_second,
1380 vec![
1381 FoundPath::new(
1382 ProjectPath {
1383 worktree_id,
1384 path: Arc::from(Path::new("test/second.rs")),
1385 },
1386 Some(PathBuf::from("/src/test/second.rs"))
1387 ),
1388 FoundPath::new(
1389 ProjectPath {
1390 worktree_id,
1391 path: Arc::from(Path::new("test/first.rs")),
1392 },
1393 Some(PathBuf::from("/src/test/first.rs"))
1394 ),
1395 ],
1396 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
1397 2nd item should be the first in the history, as the last opened."
1398 );
1399
1400 let history_after_third =
1401 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1402 assert_eq!(
1403 history_after_third,
1404 vec![
1405 FoundPath::new(
1406 ProjectPath {
1407 worktree_id,
1408 path: Arc::from(Path::new("test/third.rs")),
1409 },
1410 Some(PathBuf::from("/src/test/third.rs"))
1411 ),
1412 FoundPath::new(
1413 ProjectPath {
1414 worktree_id,
1415 path: Arc::from(Path::new("test/second.rs")),
1416 },
1417 Some(PathBuf::from("/src/test/second.rs"))
1418 ),
1419 FoundPath::new(
1420 ProjectPath {
1421 worktree_id,
1422 path: Arc::from(Path::new("test/first.rs")),
1423 },
1424 Some(PathBuf::from("/src/test/first.rs"))
1425 ),
1426 ],
1427 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
1428 3rd item should be the first in the history, as the last opened."
1429 );
1430
1431 let history_after_second_again =
1432 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1433 assert_eq!(
1434 history_after_second_again,
1435 vec![
1436 FoundPath::new(
1437 ProjectPath {
1438 worktree_id,
1439 path: Arc::from(Path::new("test/second.rs")),
1440 },
1441 Some(PathBuf::from("/src/test/second.rs"))
1442 ),
1443 FoundPath::new(
1444 ProjectPath {
1445 worktree_id,
1446 path: Arc::from(Path::new("test/third.rs")),
1447 },
1448 Some(PathBuf::from("/src/test/third.rs"))
1449 ),
1450 FoundPath::new(
1451 ProjectPath {
1452 worktree_id,
1453 path: Arc::from(Path::new("test/first.rs")),
1454 },
1455 Some(PathBuf::from("/src/test/first.rs"))
1456 ),
1457 ],
1458 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
1459 2nd item, as the last opened, 3rd item should go next as it was opened right before."
1460 );
1461 }
1462
1463 #[gpui::test]
1464 async fn test_external_files_history(cx: &mut gpui::TestAppContext) {
1465 let app_state = init_test(cx);
1466
1467 app_state
1468 .fs
1469 .as_fake()
1470 .insert_tree(
1471 "/src",
1472 json!({
1473 "test": {
1474 "first.rs": "// First Rust file",
1475 "second.rs": "// Second Rust file",
1476 }
1477 }),
1478 )
1479 .await;
1480
1481 app_state
1482 .fs
1483 .as_fake()
1484 .insert_tree(
1485 "/external-src",
1486 json!({
1487 "test": {
1488 "third.rs": "// Third Rust file",
1489 "fourth.rs": "// Fourth Rust file",
1490 }
1491 }),
1492 )
1493 .await;
1494
1495 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1496 cx.update(|cx| {
1497 project.update(cx, |project, cx| {
1498 project.find_or_create_local_worktree("/external-src", false, cx)
1499 })
1500 })
1501 .detach();
1502 cx.background_executor.run_until_parked();
1503
1504 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1505 let worktree_id = cx.read(|cx| {
1506 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1507 assert_eq!(worktrees.len(), 1,);
1508
1509 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1510 });
1511 workspace
1512 .update(cx, |workspace, cx| {
1513 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1514 })
1515 .detach();
1516 cx.background_executor.run_until_parked();
1517 let external_worktree_id = cx.read(|cx| {
1518 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1519 assert_eq!(
1520 worktrees.len(),
1521 2,
1522 "External file should get opened in a new worktree"
1523 );
1524
1525 WorktreeId::from_usize(
1526 worktrees
1527 .into_iter()
1528 .find(|worktree| {
1529 worktree.entity_id().as_u64() as usize != worktree_id.to_usize()
1530 })
1531 .expect("New worktree should have a different id")
1532 .entity_id()
1533 .as_u64() as usize,
1534 )
1535 });
1536 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1537
1538 let initial_history_items =
1539 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1540 assert_eq!(
1541 initial_history_items,
1542 vec![FoundPath::new(
1543 ProjectPath {
1544 worktree_id: external_worktree_id,
1545 path: Arc::from(Path::new("")),
1546 },
1547 Some(PathBuf::from("/external-src/test/third.rs"))
1548 )],
1549 "Should show external file with its full path in the history after it was open"
1550 );
1551
1552 let updated_history_items =
1553 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1554 assert_eq!(
1555 updated_history_items,
1556 vec![
1557 FoundPath::new(
1558 ProjectPath {
1559 worktree_id,
1560 path: Arc::from(Path::new("test/second.rs")),
1561 },
1562 Some(PathBuf::from("/src/test/second.rs"))
1563 ),
1564 FoundPath::new(
1565 ProjectPath {
1566 worktree_id: external_worktree_id,
1567 path: Arc::from(Path::new("")),
1568 },
1569 Some(PathBuf::from("/external-src/test/third.rs"))
1570 ),
1571 ],
1572 "Should keep external file with history updates",
1573 );
1574 }
1575
1576 #[gpui::test]
1577 async fn test_toggle_panel_new_selections(cx: &mut gpui::TestAppContext) {
1578 let app_state = init_test(cx);
1579
1580 app_state
1581 .fs
1582 .as_fake()
1583 .insert_tree(
1584 "/src",
1585 json!({
1586 "test": {
1587 "first.rs": "// First Rust file",
1588 "second.rs": "// Second Rust file",
1589 "third.rs": "// Third Rust file",
1590 }
1591 }),
1592 )
1593 .await;
1594
1595 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1596 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1597
1598 // generate some history to select from
1599 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1600 cx.executor().run_until_parked();
1601 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1602 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1603 let current_history =
1604 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1605
1606 for expected_selected_index in 0..current_history.len() {
1607 cx.dispatch_action(Toggle);
1608 let picker = active_file_picker(&workspace, cx);
1609 let selected_index = picker.update(cx, |picker, _| picker.delegate.selected_index());
1610 assert_eq!(
1611 selected_index, expected_selected_index,
1612 "Should select the next item in the history"
1613 );
1614 }
1615
1616 cx.dispatch_action(Toggle);
1617 let selected_index = workspace.update(cx, |workspace, cx| {
1618 workspace
1619 .active_modal::<FileFinder>(cx)
1620 .unwrap()
1621 .read(cx)
1622 .picker
1623 .read(cx)
1624 .delegate
1625 .selected_index()
1626 });
1627 assert_eq!(
1628 selected_index, 0,
1629 "Should wrap around the history and start all over"
1630 );
1631 }
1632
1633 #[gpui::test]
1634 async fn test_search_preserves_history_items(cx: &mut gpui::TestAppContext) {
1635 let app_state = init_test(cx);
1636
1637 app_state
1638 .fs
1639 .as_fake()
1640 .insert_tree(
1641 "/src",
1642 json!({
1643 "test": {
1644 "first.rs": "// First Rust file",
1645 "second.rs": "// Second Rust file",
1646 "third.rs": "// Third Rust file",
1647 "fourth.rs": "// Fourth Rust file",
1648 }
1649 }),
1650 )
1651 .await;
1652
1653 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1654 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1655 let worktree_id = cx.read(|cx| {
1656 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1657 assert_eq!(worktrees.len(), 1,);
1658
1659 WorktreeId::from_usize(worktrees[0].entity_id().as_u64() as usize)
1660 });
1661
1662 // generate some history to select from
1663 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1664 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1665 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1666 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1667
1668 let finder = open_file_picker(&workspace, cx);
1669 let first_query = "f";
1670 finder
1671 .update(cx, |finder, cx| {
1672 finder.delegate.update_matches(first_query.to_string(), cx)
1673 })
1674 .await;
1675 finder.update(cx, |finder, _| {
1676 let delegate = &finder.delegate;
1677 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1678 let history_match = delegate.matches.history.first().unwrap();
1679 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1680 assert_eq!(history_match.0, FoundPath::new(
1681 ProjectPath {
1682 worktree_id,
1683 path: Arc::from(Path::new("test/first.rs")),
1684 },
1685 Some(PathBuf::from("/src/test/first.rs"))
1686 ));
1687 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1688 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1689 });
1690
1691 let second_query = "fsdasdsa";
1692 let finder = active_file_picker(&workspace, cx);
1693 finder
1694 .update(cx, |finder, cx| {
1695 finder.delegate.update_matches(second_query.to_string(), cx)
1696 })
1697 .await;
1698 finder.update(cx, |finder, _| {
1699 let delegate = &finder.delegate;
1700 assert!(
1701 delegate.matches.history.is_empty(),
1702 "No history entries should match {second_query}"
1703 );
1704 assert!(
1705 delegate.matches.search.is_empty(),
1706 "No search entries should match {second_query}"
1707 );
1708 });
1709
1710 let first_query_again = first_query;
1711
1712 let finder = active_file_picker(&workspace, cx);
1713 finder
1714 .update(cx, |finder, cx| {
1715 finder
1716 .delegate
1717 .update_matches(first_query_again.to_string(), cx)
1718 })
1719 .await;
1720 finder.update(cx, |finder, _| {
1721 let delegate = &finder.delegate;
1722 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");
1723 let history_match = delegate.matches.history.first().unwrap();
1724 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1725 assert_eq!(history_match.0, FoundPath::new(
1726 ProjectPath {
1727 worktree_id,
1728 path: Arc::from(Path::new("test/first.rs")),
1729 },
1730 Some(PathBuf::from("/src/test/first.rs"))
1731 ));
1732 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");
1733 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1734 });
1735 }
1736
1737 #[gpui::test]
1738 async fn test_history_items_vs_very_good_external_match(cx: &mut gpui::TestAppContext) {
1739 let app_state = init_test(cx);
1740
1741 app_state
1742 .fs
1743 .as_fake()
1744 .insert_tree(
1745 "/src",
1746 json!({
1747 "collab_ui": {
1748 "first.rs": "// First Rust file",
1749 "second.rs": "// Second Rust file",
1750 "third.rs": "// Third Rust file",
1751 "collab_ui.rs": "// Fourth Rust file",
1752 }
1753 }),
1754 )
1755 .await;
1756
1757 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1758 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1759 // generate some history to select from
1760 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1761 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1762 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1763 open_close_queried_buffer("sec", 1, "second.rs", &workspace, cx).await;
1764
1765 let finder = open_file_picker(&workspace, cx);
1766 let query = "collab_ui";
1767 cx.simulate_input(query);
1768 finder.update(cx, |finder, _| {
1769 let delegate = &finder.delegate;
1770 assert!(
1771 delegate.matches.history.is_empty(),
1772 "History items should not math query {query}, they should be matched by name only"
1773 );
1774
1775 let search_entries = delegate
1776 .matches
1777 .search
1778 .iter()
1779 .map(|path_match| path_match.path.to_path_buf())
1780 .collect::<Vec<_>>();
1781 assert_eq!(
1782 search_entries,
1783 vec![
1784 PathBuf::from("collab_ui/collab_ui.rs"),
1785 PathBuf::from("collab_ui/third.rs"),
1786 PathBuf::from("collab_ui/first.rs"),
1787 PathBuf::from("collab_ui/second.rs"),
1788 ],
1789 "Despite all search results having the same directory name, the most matching one should be on top"
1790 );
1791 });
1792 }
1793
1794 #[gpui::test]
1795 async fn test_nonexistent_history_items_not_shown(cx: &mut gpui::TestAppContext) {
1796 let app_state = init_test(cx);
1797
1798 app_state
1799 .fs
1800 .as_fake()
1801 .insert_tree(
1802 "/src",
1803 json!({
1804 "test": {
1805 "first.rs": "// First Rust file",
1806 "nonexistent.rs": "// Second Rust file",
1807 "third.rs": "// Third Rust file",
1808 }
1809 }),
1810 )
1811 .await;
1812
1813 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1814 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx)); // generate some history to select from
1815 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1816 open_close_queried_buffer("non", 1, "nonexistent.rs", &workspace, cx).await;
1817 open_close_queried_buffer("thi", 1, "third.rs", &workspace, cx).await;
1818 open_close_queried_buffer("fir", 1, "first.rs", &workspace, cx).await;
1819
1820 let picker = open_file_picker(&workspace, cx);
1821 cx.simulate_input("rs");
1822
1823 picker.update(cx, |finder, _| {
1824 let history_entries = finder.delegate
1825 .matches
1826 .history
1827 .iter()
1828 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
1829 .collect::<Vec<_>>();
1830 assert_eq!(
1831 history_entries,
1832 vec![
1833 PathBuf::from("test/first.rs"),
1834 PathBuf::from("test/third.rs"),
1835 ],
1836 "Should have all opened files in the history, except the ones that do not exist on disk"
1837 );
1838 });
1839 }
1840
1841 async fn open_close_queried_buffer(
1842 input: &str,
1843 expected_matches: usize,
1844 expected_editor_title: &str,
1845 workspace: &View<Workspace>,
1846 cx: &mut gpui::VisualTestContext<'_>,
1847 ) -> Vec<FoundPath> {
1848 let picker = open_file_picker(&workspace, cx);
1849 cx.simulate_input(input);
1850
1851 let history_items = picker.update(cx, |finder, _| {
1852 assert_eq!(
1853 finder.delegate.matches.len(),
1854 expected_matches,
1855 "Unexpected number of matches found for query {input}"
1856 );
1857 finder.delegate.history_items.clone()
1858 });
1859
1860 cx.dispatch_action(SelectNext);
1861 cx.dispatch_action(Confirm);
1862
1863 cx.read(|cx| {
1864 let active_editor = workspace.read(cx).active_item_as::<Editor>(cx).unwrap();
1865 let active_editor_title = active_editor.read(cx).title(cx);
1866 assert_eq!(
1867 expected_editor_title, active_editor_title,
1868 "Unexpected editor title for query {input}"
1869 );
1870 });
1871
1872 cx.dispatch_action(workspace::CloseActiveItem { save_intent: None });
1873
1874 history_items
1875 }
1876
1877 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
1878 cx.update(|cx| {
1879 let state = AppState::test(cx);
1880 theme::init(theme::LoadThemes::JustBase, cx);
1881 language::init(cx);
1882 super::init(cx);
1883 editor::init(cx);
1884 workspace::init_settings(cx);
1885 Project::init_settings(cx);
1886 state
1887 })
1888 }
1889
1890 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
1891 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
1892 Ok::<_, std::convert::Infallible>(FileSearchQuery {
1893 raw_query: test_str.to_owned(),
1894 file_query_end: if path_like_str == test_str {
1895 None
1896 } else {
1897 Some(path_like_str.len())
1898 },
1899 })
1900 })
1901 .unwrap()
1902 }
1903
1904 fn build_find_picker(
1905 project: Model<Project>,
1906 cx: &mut TestAppContext,
1907 ) -> (
1908 View<Picker<FileFinderDelegate>>,
1909 View<Workspace>,
1910 &mut VisualTestContext,
1911 ) {
1912 let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project, cx));
1913 let picker = open_file_picker(&workspace, cx);
1914 (picker, workspace, cx)
1915 }
1916
1917 #[track_caller]
1918 fn open_file_picker(
1919 workspace: &View<Workspace>,
1920 cx: &mut VisualTestContext,
1921 ) -> View<Picker<FileFinderDelegate>> {
1922 cx.dispatch_action(Toggle);
1923 active_file_picker(workspace, cx)
1924 }
1925
1926 #[track_caller]
1927 fn active_file_picker(
1928 workspace: &View<Workspace>,
1929 cx: &mut VisualTestContext,
1930 ) -> View<Picker<FileFinderDelegate>> {
1931 workspace.update(cx, |workspace, cx| {
1932 workspace
1933 .active_modal::<FileFinder>(cx)
1934 .unwrap()
1935 .read(cx)
1936 .picker
1937 .clone()
1938 })
1939 }
1940
1941 fn collect_search_results(picker: &Picker<FileFinderDelegate>) -> Vec<PathBuf> {
1942 let matches = &picker.delegate.matches;
1943 assert!(
1944 matches.history.is_empty(),
1945 "Should have no history matches, but got: {:?}",
1946 matches.history
1947 );
1948 let mut results = matches
1949 .search
1950 .iter()
1951 .map(|path_match| Path::new(path_match.path_prefix.as_ref()).join(&path_match.path))
1952 .collect::<Vec<_>>();
1953 results.sort();
1954 results
1955 }
1956}