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