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