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 if raw_query.is_empty() {
522 let project = self.project.read(cx);
523 self.latest_search_id = post_inc(&mut self.search_count);
524 self.matches = Matches {
525 history: self
526 .history_items
527 .iter()
528 .filter(|history_item| {
529 project
530 .worktree_for_id(history_item.project.worktree_id, cx)
531 .is_some()
532 || (project.is_local() && history_item.absolute.is_some())
533 })
534 .cloned()
535 .map(|p| (p, None))
536 .collect(),
537 search: Vec::new(),
538 };
539 cx.notify();
540 Task::ready(())
541 } else {
542 let raw_query = &raw_query;
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 finder
739 .update(cx, |finder, cx| {
740 finder.delegate_mut().update_matches("bna".to_string(), cx)
741 })
742 .await;
743 finder.read_with(cx, |finder, _| {
744 assert_eq!(finder.delegate().matches.len(), 2);
745 });
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
767 #[gpui::test]
768 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
769 let app_state = init_test(cx);
770
771 let first_file_name = "first.rs";
772 let first_file_contents = "// First Rust file";
773 app_state
774 .fs
775 .as_fake()
776 .insert_tree(
777 "/src",
778 json!({
779 "test": {
780 first_file_name: first_file_contents,
781 "second.rs": "// Second Rust file",
782 }
783 }),
784 )
785 .await;
786
787 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
788 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
789 let workspace = window.root(cx);
790 cx.dispatch_action(window.into(), Toggle);
791 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
792
793 let file_query = &first_file_name[..3];
794 let file_row = 1;
795 let file_column = 3;
796 assert!(file_column <= first_file_contents.len());
797 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
798 finder
799 .update(cx, |finder, cx| {
800 finder
801 .delegate_mut()
802 .update_matches(query_inside_file.to_string(), cx)
803 })
804 .await;
805 finder.read_with(cx, |finder, _| {
806 let finder = finder.delegate();
807 assert_eq!(finder.matches.len(), 1);
808 let latest_search_query = finder
809 .latest_search_query
810 .as_ref()
811 .expect("Finder should have a query after the update_matches call");
812 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
813 assert_eq!(
814 latest_search_query.path_like.file_query_end,
815 Some(file_query.len())
816 );
817 assert_eq!(latest_search_query.row, Some(file_row));
818 assert_eq!(latest_search_query.column, Some(file_column as u32));
819 });
820
821 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
822 cx.dispatch_action(window.into(), SelectNext);
823 cx.dispatch_action(window.into(), Confirm);
824 active_pane
825 .condition(cx, |pane, _| pane.active_item().is_some())
826 .await;
827 let editor = cx.update(|cx| {
828 let active_item = active_pane.read(cx).active_item().unwrap();
829 active_item.downcast::<Editor>().unwrap()
830 });
831 cx.foreground().advance_clock(Duration::from_secs(2));
832 cx.foreground().start_waiting();
833 cx.foreground().finish_waiting();
834 editor.update(cx, |editor, cx| {
835 let all_selections = editor.selections.all_adjusted(cx);
836 assert_eq!(
837 all_selections.len(),
838 1,
839 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
840 );
841 let caret_selection = all_selections.into_iter().next().unwrap();
842 assert_eq!(caret_selection.start, caret_selection.end,
843 "Caret selection should have its start and end at the same position");
844 assert_eq!(file_row, caret_selection.start.row + 1,
845 "Query inside file should get caret with the same focus row");
846 assert_eq!(file_column, caret_selection.start.column as usize + 1,
847 "Query inside file should get caret with the same focus column");
848 });
849 }
850
851 #[gpui::test]
852 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
853 let app_state = init_test(cx);
854
855 let first_file_name = "first.rs";
856 let first_file_contents = "// First Rust file";
857 app_state
858 .fs
859 .as_fake()
860 .insert_tree(
861 "/src",
862 json!({
863 "test": {
864 first_file_name: first_file_contents,
865 "second.rs": "// Second Rust file",
866 }
867 }),
868 )
869 .await;
870
871 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
872 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
873 let workspace = window.root(cx);
874 cx.dispatch_action(window.into(), Toggle);
875 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
876
877 let file_query = &first_file_name[..3];
878 let file_row = 200;
879 let file_column = 300;
880 assert!(file_column > first_file_contents.len());
881 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
882 finder
883 .update(cx, |finder, cx| {
884 finder
885 .delegate_mut()
886 .update_matches(query_outside_file.to_string(), cx)
887 })
888 .await;
889 finder.read_with(cx, |finder, _| {
890 let finder = finder.delegate();
891 assert_eq!(finder.matches.len(), 1);
892 let latest_search_query = finder
893 .latest_search_query
894 .as_ref()
895 .expect("Finder should have a query after the update_matches call");
896 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
897 assert_eq!(
898 latest_search_query.path_like.file_query_end,
899 Some(file_query.len())
900 );
901 assert_eq!(latest_search_query.row, Some(file_row));
902 assert_eq!(latest_search_query.column, Some(file_column as u32));
903 });
904
905 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
906 cx.dispatch_action(window.into(), SelectNext);
907 cx.dispatch_action(window.into(), Confirm);
908 active_pane
909 .condition(cx, |pane, _| pane.active_item().is_some())
910 .await;
911 let editor = cx.update(|cx| {
912 let active_item = active_pane.read(cx).active_item().unwrap();
913 active_item.downcast::<Editor>().unwrap()
914 });
915 cx.foreground().advance_clock(Duration::from_secs(2));
916 cx.foreground().start_waiting();
917 cx.foreground().finish_waiting();
918 editor.update(cx, |editor, cx| {
919 let all_selections = editor.selections.all_adjusted(cx);
920 assert_eq!(
921 all_selections.len(),
922 1,
923 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
924 );
925 let caret_selection = all_selections.into_iter().next().unwrap();
926 assert_eq!(caret_selection.start, caret_selection.end,
927 "Caret selection should have its start and end at the same position");
928 assert_eq!(0, caret_selection.start.row,
929 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
930 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
931 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
932 });
933 }
934
935 #[gpui::test]
936 async fn test_matching_cancellation(cx: &mut TestAppContext) {
937 let app_state = init_test(cx);
938 app_state
939 .fs
940 .as_fake()
941 .insert_tree(
942 "/dir",
943 json!({
944 "hello": "",
945 "goodbye": "",
946 "halogen-light": "",
947 "happiness": "",
948 "height": "",
949 "hi": "",
950 "hiccup": "",
951 }),
952 )
953 .await;
954
955 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
956 let workspace = cx
957 .add_window(|cx| Workspace::test_new(project, cx))
958 .root(cx);
959 let finder = cx
960 .add_window(|cx| {
961 Picker::new(
962 FileFinderDelegate::new(
963 workspace.downgrade(),
964 workspace.read(cx).project().clone(),
965 None,
966 Vec::new(),
967 cx,
968 ),
969 cx,
970 )
971 })
972 .root(cx);
973
974 let query = test_path_like("hi");
975 finder
976 .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
977 .await;
978 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
979
980 finder.update(cx, |finder, cx| {
981 let delegate = finder.delegate_mut();
982 assert!(
983 delegate.matches.history.is_empty(),
984 "Search matches expected"
985 );
986 let matches = delegate.matches.search.clone();
987
988 // Simulate a search being cancelled after the time limit,
989 // returning only a subset of the matches that would have been found.
990 drop(delegate.spawn_search(query.clone(), cx));
991 delegate.set_search_matches(
992 delegate.latest_search_id,
993 true, // did-cancel
994 query.clone(),
995 vec![matches[1].clone(), matches[3].clone()],
996 cx,
997 );
998
999 // Simulate another cancellation.
1000 drop(delegate.spawn_search(query.clone(), cx));
1001 delegate.set_search_matches(
1002 delegate.latest_search_id,
1003 true, // did-cancel
1004 query.clone(),
1005 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
1006 cx,
1007 );
1008
1009 assert!(
1010 delegate.matches.history.is_empty(),
1011 "Search matches expected"
1012 );
1013 assert_eq!(delegate.matches.search.as_slice(), &matches[0..4]);
1014 });
1015 }
1016
1017 #[gpui::test]
1018 async fn test_ignored_files(cx: &mut TestAppContext) {
1019 let app_state = init_test(cx);
1020 app_state
1021 .fs
1022 .as_fake()
1023 .insert_tree(
1024 "/ancestor",
1025 json!({
1026 ".gitignore": "ignored-root",
1027 "ignored-root": {
1028 "happiness": "",
1029 "height": "",
1030 "hi": "",
1031 "hiccup": "",
1032 },
1033 "tracked-root": {
1034 ".gitignore": "height",
1035 "happiness": "",
1036 "height": "",
1037 "hi": "",
1038 "hiccup": "",
1039 },
1040 }),
1041 )
1042 .await;
1043
1044 let project = Project::test(
1045 app_state.fs.clone(),
1046 [
1047 "/ancestor/tracked-root".as_ref(),
1048 "/ancestor/ignored-root".as_ref(),
1049 ],
1050 cx,
1051 )
1052 .await;
1053 let workspace = cx
1054 .add_window(|cx| Workspace::test_new(project, cx))
1055 .root(cx);
1056 let finder = cx
1057 .add_window(|cx| {
1058 Picker::new(
1059 FileFinderDelegate::new(
1060 workspace.downgrade(),
1061 workspace.read(cx).project().clone(),
1062 None,
1063 Vec::new(),
1064 cx,
1065 ),
1066 cx,
1067 )
1068 })
1069 .root(cx);
1070 finder
1071 .update(cx, |f, cx| {
1072 f.delegate_mut().spawn_search(test_path_like("hi"), cx)
1073 })
1074 .await;
1075 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
1076 }
1077
1078 #[gpui::test]
1079 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
1080 let app_state = init_test(cx);
1081 app_state
1082 .fs
1083 .as_fake()
1084 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
1085 .await;
1086
1087 let project = Project::test(
1088 app_state.fs.clone(),
1089 ["/root/the-parent-dir/the-file".as_ref()],
1090 cx,
1091 )
1092 .await;
1093 let workspace = cx
1094 .add_window(|cx| Workspace::test_new(project, cx))
1095 .root(cx);
1096 let finder = cx
1097 .add_window(|cx| {
1098 Picker::new(
1099 FileFinderDelegate::new(
1100 workspace.downgrade(),
1101 workspace.read(cx).project().clone(),
1102 None,
1103 Vec::new(),
1104 cx,
1105 ),
1106 cx,
1107 )
1108 })
1109 .root(cx);
1110
1111 // Even though there is only one worktree, that worktree's filename
1112 // is included in the matching, because the worktree is a single file.
1113 finder
1114 .update(cx, |f, cx| {
1115 f.delegate_mut().spawn_search(test_path_like("thf"), cx)
1116 })
1117 .await;
1118 cx.read(|cx| {
1119 let finder = finder.read(cx);
1120 let delegate = finder.delegate();
1121 assert!(
1122 delegate.matches.history.is_empty(),
1123 "Search matches expected"
1124 );
1125 let matches = delegate.matches.search.clone();
1126 assert_eq!(matches.len(), 1);
1127
1128 let (file_name, file_name_positions, full_path, full_path_positions) =
1129 delegate.labels_for_path_match(&matches[0]);
1130 assert_eq!(file_name, "the-file");
1131 assert_eq!(file_name_positions, &[0, 1, 4]);
1132 assert_eq!(full_path, "the-file");
1133 assert_eq!(full_path_positions, &[0, 1, 4]);
1134 });
1135
1136 // Since the worktree root is a file, searching for its name followed by a slash does
1137 // not match anything.
1138 finder
1139 .update(cx, |f, cx| {
1140 f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
1141 })
1142 .await;
1143 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
1144 }
1145
1146 #[gpui::test]
1147 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
1148 let app_state = init_test(cx);
1149 app_state
1150 .fs
1151 .as_fake()
1152 .insert_tree(
1153 "/root",
1154 json!({
1155 "dir1": { "a.txt": "" },
1156 "dir2": {
1157 "a.txt": "",
1158 "b.txt": ""
1159 }
1160 }),
1161 )
1162 .await;
1163
1164 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1165 let workspace = cx
1166 .add_window(|cx| Workspace::test_new(project, cx))
1167 .root(cx);
1168 let worktree_id = cx.read(|cx| {
1169 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1170 assert_eq!(worktrees.len(), 1);
1171 WorktreeId::from_usize(worktrees[0].id())
1172 });
1173
1174 // When workspace has an active item, sort items which are closer to that item
1175 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
1176 // so that one should be sorted earlier
1177 let b_path = Some(dummy_found_path(ProjectPath {
1178 worktree_id,
1179 path: Arc::from(Path::new("/root/dir2/b.txt")),
1180 }));
1181 let finder = cx
1182 .add_window(|cx| {
1183 Picker::new(
1184 FileFinderDelegate::new(
1185 workspace.downgrade(),
1186 workspace.read(cx).project().clone(),
1187 b_path,
1188 Vec::new(),
1189 cx,
1190 ),
1191 cx,
1192 )
1193 })
1194 .root(cx);
1195
1196 finder
1197 .update(cx, |f, cx| {
1198 f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
1199 })
1200 .await;
1201
1202 finder.read_with(cx, |f, _| {
1203 let delegate = f.delegate();
1204 assert!(
1205 delegate.matches.history.is_empty(),
1206 "Search matches expected"
1207 );
1208 let matches = delegate.matches.search.clone();
1209 assert_eq!(matches[0].path.as_ref(), Path::new("dir2/a.txt"));
1210 assert_eq!(matches[1].path.as_ref(), Path::new("dir1/a.txt"));
1211 });
1212 }
1213
1214 #[gpui::test]
1215 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
1216 let app_state = init_test(cx);
1217 app_state
1218 .fs
1219 .as_fake()
1220 .insert_tree(
1221 "/root",
1222 json!({
1223 "dir1": {},
1224 "dir2": {
1225 "dir3": {}
1226 }
1227 }),
1228 )
1229 .await;
1230
1231 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
1232 let workspace = cx
1233 .add_window(|cx| Workspace::test_new(project, cx))
1234 .root(cx);
1235 let finder = cx
1236 .add_window(|cx| {
1237 Picker::new(
1238 FileFinderDelegate::new(
1239 workspace.downgrade(),
1240 workspace.read(cx).project().clone(),
1241 None,
1242 Vec::new(),
1243 cx,
1244 ),
1245 cx,
1246 )
1247 })
1248 .root(cx);
1249 finder
1250 .update(cx, |f, cx| {
1251 f.delegate_mut().spawn_search(test_path_like("dir"), cx)
1252 })
1253 .await;
1254 cx.read(|cx| {
1255 let finder = finder.read(cx);
1256 assert_eq!(finder.delegate().matches.len(), 0);
1257 });
1258 }
1259
1260 #[gpui::test]
1261 async fn test_query_history(
1262 deterministic: Arc<gpui::executor::Deterministic>,
1263 cx: &mut gpui::TestAppContext,
1264 ) {
1265 let app_state = init_test(cx);
1266
1267 app_state
1268 .fs
1269 .as_fake()
1270 .insert_tree(
1271 "/src",
1272 json!({
1273 "test": {
1274 "first.rs": "// First Rust file",
1275 "second.rs": "// Second Rust file",
1276 "third.rs": "// Third Rust file",
1277 }
1278 }),
1279 )
1280 .await;
1281
1282 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1283 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1284 let workspace = window.root(cx);
1285 let worktree_id = cx.read(|cx| {
1286 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1287 assert_eq!(worktrees.len(), 1);
1288 WorktreeId::from_usize(worktrees[0].id())
1289 });
1290
1291 // Open and close panels, getting their history items afterwards.
1292 // Ensure history items get populated with opened items, and items are kept in a certain order.
1293 // The history lags one opened buffer behind, since it's updated in the search panel only on its reopen.
1294 //
1295 // TODO: without closing, the opened items do not propagate their history changes for some reason
1296 // it does work in real app though, only tests do not propagate.
1297
1298 let initial_history = open_close_queried_buffer(
1299 "fir",
1300 1,
1301 "first.rs",
1302 window.into(),
1303 &workspace,
1304 &deterministic,
1305 cx,
1306 )
1307 .await;
1308 assert!(
1309 initial_history.is_empty(),
1310 "Should have no history before opening any files"
1311 );
1312
1313 let history_after_first = open_close_queried_buffer(
1314 "sec",
1315 1,
1316 "second.rs",
1317 window.into(),
1318 &workspace,
1319 &deterministic,
1320 cx,
1321 )
1322 .await;
1323 assert_eq!(
1324 history_after_first,
1325 vec![FoundPath::new(
1326 ProjectPath {
1327 worktree_id,
1328 path: Arc::from(Path::new("test/first.rs")),
1329 },
1330 Some(PathBuf::from("/src/test/first.rs"))
1331 )],
1332 "Should show 1st opened item in the history when opening the 2nd item"
1333 );
1334
1335 let history_after_second = open_close_queried_buffer(
1336 "thi",
1337 1,
1338 "third.rs",
1339 window.into(),
1340 &workspace,
1341 &deterministic,
1342 cx,
1343 )
1344 .await;
1345 assert_eq!(
1346 history_after_second,
1347 vec![
1348 FoundPath::new(
1349 ProjectPath {
1350 worktree_id,
1351 path: Arc::from(Path::new("test/second.rs")),
1352 },
1353 Some(PathBuf::from("/src/test/second.rs"))
1354 ),
1355 FoundPath::new(
1356 ProjectPath {
1357 worktree_id,
1358 path: Arc::from(Path::new("test/first.rs")),
1359 },
1360 Some(PathBuf::from("/src/test/first.rs"))
1361 ),
1362 ],
1363 "Should show 1st and 2nd opened items in the history when opening the 3rd item. \
13642nd item should be the first in the history, as the last opened."
1365 );
1366
1367 let history_after_third = open_close_queried_buffer(
1368 "sec",
1369 1,
1370 "second.rs",
1371 window.into(),
1372 &workspace,
1373 &deterministic,
1374 cx,
1375 )
1376 .await;
1377 assert_eq!(
1378 history_after_third,
1379 vec![
1380 FoundPath::new(
1381 ProjectPath {
1382 worktree_id,
1383 path: Arc::from(Path::new("test/third.rs")),
1384 },
1385 Some(PathBuf::from("/src/test/third.rs"))
1386 ),
1387 FoundPath::new(
1388 ProjectPath {
1389 worktree_id,
1390 path: Arc::from(Path::new("test/second.rs")),
1391 },
1392 Some(PathBuf::from("/src/test/second.rs"))
1393 ),
1394 FoundPath::new(
1395 ProjectPath {
1396 worktree_id,
1397 path: Arc::from(Path::new("test/first.rs")),
1398 },
1399 Some(PathBuf::from("/src/test/first.rs"))
1400 ),
1401 ],
1402 "Should show 1st, 2nd and 3rd opened items in the history when opening the 2nd item again. \
14033rd item should be the first in the history, as the last opened."
1404 );
1405
1406 let history_after_second_again = open_close_queried_buffer(
1407 "thi",
1408 1,
1409 "third.rs",
1410 window.into(),
1411 &workspace,
1412 &deterministic,
1413 cx,
1414 )
1415 .await;
1416 assert_eq!(
1417 history_after_second_again,
1418 vec![
1419 FoundPath::new(
1420 ProjectPath {
1421 worktree_id,
1422 path: Arc::from(Path::new("test/second.rs")),
1423 },
1424 Some(PathBuf::from("/src/test/second.rs"))
1425 ),
1426 FoundPath::new(
1427 ProjectPath {
1428 worktree_id,
1429 path: Arc::from(Path::new("test/third.rs")),
1430 },
1431 Some(PathBuf::from("/src/test/third.rs"))
1432 ),
1433 FoundPath::new(
1434 ProjectPath {
1435 worktree_id,
1436 path: Arc::from(Path::new("test/first.rs")),
1437 },
1438 Some(PathBuf::from("/src/test/first.rs"))
1439 ),
1440 ],
1441 "Should show 1st, 2nd and 3rd opened items in the history when opening the 3rd item again. \
14422nd item, as the last opened, 3rd item should go next as it was opened right before."
1443 );
1444 }
1445
1446 #[gpui::test]
1447 async fn test_external_files_history(
1448 deterministic: Arc<gpui::executor::Deterministic>,
1449 cx: &mut gpui::TestAppContext,
1450 ) {
1451 let app_state = init_test(cx);
1452
1453 app_state
1454 .fs
1455 .as_fake()
1456 .insert_tree(
1457 "/src",
1458 json!({
1459 "test": {
1460 "first.rs": "// First Rust file",
1461 "second.rs": "// Second Rust file",
1462 }
1463 }),
1464 )
1465 .await;
1466
1467 app_state
1468 .fs
1469 .as_fake()
1470 .insert_tree(
1471 "/external-src",
1472 json!({
1473 "test": {
1474 "third.rs": "// Third Rust file",
1475 "fourth.rs": "// Fourth Rust file",
1476 }
1477 }),
1478 )
1479 .await;
1480
1481 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1482 cx.update(|cx| {
1483 project.update(cx, |project, cx| {
1484 project.find_or_create_local_worktree("/external-src", false, cx)
1485 })
1486 })
1487 .detach();
1488 deterministic.run_until_parked();
1489
1490 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1491 let workspace = window.root(cx);
1492 let worktree_id = cx.read(|cx| {
1493 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1494 assert_eq!(worktrees.len(), 1,);
1495
1496 WorktreeId::from_usize(worktrees[0].id())
1497 });
1498 workspace
1499 .update(cx, |workspace, cx| {
1500 workspace.open_abs_path(PathBuf::from("/external-src/test/third.rs"), false, cx)
1501 })
1502 .detach();
1503 deterministic.run_until_parked();
1504 let external_worktree_id = cx.read(|cx| {
1505 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1506 assert_eq!(
1507 worktrees.len(),
1508 2,
1509 "External file should get opened in a new worktree"
1510 );
1511
1512 WorktreeId::from_usize(
1513 worktrees
1514 .into_iter()
1515 .find(|worktree| worktree.id() != worktree_id.to_usize())
1516 .expect("New worktree should have a different id")
1517 .id(),
1518 )
1519 });
1520 close_active_item(&workspace, &deterministic, cx).await;
1521
1522 let initial_history_items = open_close_queried_buffer(
1523 "sec",
1524 1,
1525 "second.rs",
1526 window.into(),
1527 &workspace,
1528 &deterministic,
1529 cx,
1530 )
1531 .await;
1532 assert_eq!(
1533 initial_history_items,
1534 vec![FoundPath::new(
1535 ProjectPath {
1536 worktree_id: external_worktree_id,
1537 path: Arc::from(Path::new("")),
1538 },
1539 Some(PathBuf::from("/external-src/test/third.rs"))
1540 )],
1541 "Should show external file with its full path in the history after it was open"
1542 );
1543
1544 let updated_history_items = open_close_queried_buffer(
1545 "fir",
1546 1,
1547 "first.rs",
1548 window.into(),
1549 &workspace,
1550 &deterministic,
1551 cx,
1552 )
1553 .await;
1554 assert_eq!(
1555 updated_history_items,
1556 vec![
1557 FoundPath::new(
1558 ProjectPath {
1559 worktree_id,
1560 path: Arc::from(Path::new("test/second.rs")),
1561 },
1562 Some(PathBuf::from("/src/test/second.rs"))
1563 ),
1564 FoundPath::new(
1565 ProjectPath {
1566 worktree_id: external_worktree_id,
1567 path: Arc::from(Path::new("")),
1568 },
1569 Some(PathBuf::from("/external-src/test/third.rs"))
1570 ),
1571 ],
1572 "Should keep external file with history updates",
1573 );
1574 }
1575
1576 #[gpui::test]
1577 async fn test_toggle_panel_new_selections(
1578 deterministic: Arc<gpui::executor::Deterministic>,
1579 cx: &mut gpui::TestAppContext,
1580 ) {
1581 let app_state = init_test(cx);
1582
1583 app_state
1584 .fs
1585 .as_fake()
1586 .insert_tree(
1587 "/src",
1588 json!({
1589 "test": {
1590 "first.rs": "// First Rust file",
1591 "second.rs": "// Second Rust file",
1592 "third.rs": "// Third Rust file",
1593 }
1594 }),
1595 )
1596 .await;
1597
1598 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1599 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1600 let workspace = window.root(cx);
1601
1602 // generate some history to select from
1603 open_close_queried_buffer(
1604 "fir",
1605 1,
1606 "first.rs",
1607 window.into(),
1608 &workspace,
1609 &deterministic,
1610 cx,
1611 )
1612 .await;
1613 open_close_queried_buffer(
1614 "sec",
1615 1,
1616 "second.rs",
1617 window.into(),
1618 &workspace,
1619 &deterministic,
1620 cx,
1621 )
1622 .await;
1623 open_close_queried_buffer(
1624 "thi",
1625 1,
1626 "third.rs",
1627 window.into(),
1628 &workspace,
1629 &deterministic,
1630 cx,
1631 )
1632 .await;
1633 let current_history = open_close_queried_buffer(
1634 "sec",
1635 1,
1636 "second.rs",
1637 window.into(),
1638 &workspace,
1639 &deterministic,
1640 cx,
1641 )
1642 .await;
1643
1644 for expected_selected_index in 0..current_history.len() {
1645 cx.dispatch_action(window.into(), Toggle);
1646 let selected_index = cx.read(|cx| {
1647 workspace
1648 .read(cx)
1649 .modal::<FileFinder>()
1650 .unwrap()
1651 .read(cx)
1652 .delegate()
1653 .selected_index()
1654 });
1655 assert_eq!(
1656 selected_index, expected_selected_index,
1657 "Should select the next item in the history"
1658 );
1659 }
1660
1661 cx.dispatch_action(window.into(), Toggle);
1662 let selected_index = cx.read(|cx| {
1663 workspace
1664 .read(cx)
1665 .modal::<FileFinder>()
1666 .unwrap()
1667 .read(cx)
1668 .delegate()
1669 .selected_index()
1670 });
1671 assert_eq!(
1672 selected_index, 0,
1673 "Should wrap around the history and start all over"
1674 );
1675 }
1676
1677 #[gpui::test]
1678 async fn test_search_preserves_history_items(
1679 deterministic: Arc<gpui::executor::Deterministic>,
1680 cx: &mut gpui::TestAppContext,
1681 ) {
1682 let app_state = init_test(cx);
1683
1684 app_state
1685 .fs
1686 .as_fake()
1687 .insert_tree(
1688 "/src",
1689 json!({
1690 "test": {
1691 "first.rs": "// First Rust file",
1692 "second.rs": "// Second Rust file",
1693 "third.rs": "// Third Rust file",
1694 "fourth.rs": "// Fourth Rust file",
1695 }
1696 }),
1697 )
1698 .await;
1699
1700 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1701 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1702 let workspace = window.root(cx);
1703 let worktree_id = cx.read(|cx| {
1704 let worktrees = workspace.read(cx).worktrees(cx).collect::<Vec<_>>();
1705 assert_eq!(worktrees.len(), 1,);
1706
1707 WorktreeId::from_usize(worktrees[0].id())
1708 });
1709
1710 // generate some history to select from
1711 open_close_queried_buffer(
1712 "fir",
1713 1,
1714 "first.rs",
1715 window.into(),
1716 &workspace,
1717 &deterministic,
1718 cx,
1719 )
1720 .await;
1721 open_close_queried_buffer(
1722 "sec",
1723 1,
1724 "second.rs",
1725 window.into(),
1726 &workspace,
1727 &deterministic,
1728 cx,
1729 )
1730 .await;
1731 open_close_queried_buffer(
1732 "thi",
1733 1,
1734 "third.rs",
1735 window.into(),
1736 &workspace,
1737 &deterministic,
1738 cx,
1739 )
1740 .await;
1741 open_close_queried_buffer(
1742 "sec",
1743 1,
1744 "second.rs",
1745 window.into(),
1746 &workspace,
1747 &deterministic,
1748 cx,
1749 )
1750 .await;
1751
1752 cx.dispatch_action(window.into(), Toggle);
1753 let first_query = "f";
1754 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1755 finder
1756 .update(cx, |finder, cx| {
1757 finder
1758 .delegate_mut()
1759 .update_matches(first_query.to_string(), cx)
1760 })
1761 .await;
1762 finder.read_with(cx, |finder, _| {
1763 let delegate = finder.delegate();
1764 assert_eq!(delegate.matches.history.len(), 1, "Only one history item contains {first_query}, it should be present and others should be filtered out");
1765 let history_match = delegate.matches.history.first().unwrap();
1766 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1767 assert_eq!(history_match.0, FoundPath::new(
1768 ProjectPath {
1769 worktree_id,
1770 path: Arc::from(Path::new("test/first.rs")),
1771 },
1772 Some(PathBuf::from("/src/test/first.rs"))
1773 ));
1774 assert_eq!(delegate.matches.search.len(), 1, "Only one non-history item contains {first_query}, it should be present");
1775 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1776 });
1777
1778 let second_query = "fsdasdsa";
1779 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1780 finder
1781 .update(cx, |finder, cx| {
1782 finder
1783 .delegate_mut()
1784 .update_matches(second_query.to_string(), cx)
1785 })
1786 .await;
1787 finder.read_with(cx, |finder, _| {
1788 let delegate = finder.delegate();
1789 assert!(
1790 delegate.matches.history.is_empty(),
1791 "No history entries should match {second_query}"
1792 );
1793 assert!(
1794 delegate.matches.search.is_empty(),
1795 "No search entries should match {second_query}"
1796 );
1797 });
1798
1799 let first_query_again = first_query;
1800 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1801 finder
1802 .update(cx, |finder, cx| {
1803 finder
1804 .delegate_mut()
1805 .update_matches(first_query_again.to_string(), cx)
1806 })
1807 .await;
1808 finder.read_with(cx, |finder, _| {
1809 let delegate = finder.delegate();
1810 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");
1811 let history_match = delegate.matches.history.first().unwrap();
1812 assert!(history_match.1.is_some(), "Should have path matches for history items after querying");
1813 assert_eq!(history_match.0, FoundPath::new(
1814 ProjectPath {
1815 worktree_id,
1816 path: Arc::from(Path::new("test/first.rs")),
1817 },
1818 Some(PathBuf::from("/src/test/first.rs"))
1819 ));
1820 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");
1821 assert_eq!(delegate.matches.search.first().unwrap().path.as_ref(), Path::new("test/fourth.rs"));
1822 });
1823 }
1824
1825 #[gpui::test]
1826 async fn test_history_items_vs_very_good_external_match(
1827 deterministic: Arc<gpui::executor::Deterministic>,
1828 cx: &mut gpui::TestAppContext,
1829 ) {
1830 let app_state = init_test(cx);
1831
1832 app_state
1833 .fs
1834 .as_fake()
1835 .insert_tree(
1836 "/src",
1837 json!({
1838 "collab_ui": {
1839 "first.rs": "// First Rust file",
1840 "second.rs": "// Second Rust file",
1841 "third.rs": "// Third Rust file",
1842 "collab_ui.rs": "// Fourth Rust file",
1843 }
1844 }),
1845 )
1846 .await;
1847
1848 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1849 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1850 let workspace = window.root(cx);
1851 // generate some history to select from
1852 open_close_queried_buffer(
1853 "fir",
1854 1,
1855 "first.rs",
1856 window.into(),
1857 &workspace,
1858 &deterministic,
1859 cx,
1860 )
1861 .await;
1862 open_close_queried_buffer(
1863 "sec",
1864 1,
1865 "second.rs",
1866 window.into(),
1867 &workspace,
1868 &deterministic,
1869 cx,
1870 )
1871 .await;
1872 open_close_queried_buffer(
1873 "thi",
1874 1,
1875 "third.rs",
1876 window.into(),
1877 &workspace,
1878 &deterministic,
1879 cx,
1880 )
1881 .await;
1882 open_close_queried_buffer(
1883 "sec",
1884 1,
1885 "second.rs",
1886 window.into(),
1887 &workspace,
1888 &deterministic,
1889 cx,
1890 )
1891 .await;
1892
1893 cx.dispatch_action(window.into(), Toggle);
1894 let query = "collab_ui";
1895 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1896 finder
1897 .update(cx, |finder, cx| {
1898 finder.delegate_mut().update_matches(query.to_string(), cx)
1899 })
1900 .await;
1901 finder.read_with(cx, |finder, _| {
1902 let delegate = finder.delegate();
1903 assert!(
1904 delegate.matches.history.is_empty(),
1905 "History items should not math query {query}, they should be matched by name only"
1906 );
1907
1908 let search_entries = delegate
1909 .matches
1910 .search
1911 .iter()
1912 .map(|path_match| path_match.path.to_path_buf())
1913 .collect::<Vec<_>>();
1914 assert_eq!(
1915 search_entries,
1916 vec![
1917 PathBuf::from("collab_ui/collab_ui.rs"),
1918 PathBuf::from("collab_ui/third.rs"),
1919 PathBuf::from("collab_ui/first.rs"),
1920 PathBuf::from("collab_ui/second.rs"),
1921 ],
1922 "Despite all search results having the same directory name, the most matching one should be on top"
1923 );
1924 });
1925 }
1926
1927 #[gpui::test]
1928 async fn test_nonexistent_history_items_not_shown(
1929 deterministic: Arc<gpui::executor::Deterministic>,
1930 cx: &mut gpui::TestAppContext,
1931 ) {
1932 let app_state = init_test(cx);
1933
1934 app_state
1935 .fs
1936 .as_fake()
1937 .insert_tree(
1938 "/src",
1939 json!({
1940 "test": {
1941 "first.rs": "// First Rust file",
1942 "nonexistent.rs": "// Second Rust file",
1943 "third.rs": "// Third Rust file",
1944 }
1945 }),
1946 )
1947 .await;
1948
1949 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
1950 let window = cx.add_window(|cx| Workspace::test_new(project, cx));
1951 let workspace = window.root(cx);
1952 // generate some history to select from
1953 open_close_queried_buffer(
1954 "fir",
1955 1,
1956 "first.rs",
1957 window.into(),
1958 &workspace,
1959 &deterministic,
1960 cx,
1961 )
1962 .await;
1963 open_close_queried_buffer(
1964 "non",
1965 1,
1966 "nonexistent.rs",
1967 window.into(),
1968 &workspace,
1969 &deterministic,
1970 cx,
1971 )
1972 .await;
1973 open_close_queried_buffer(
1974 "thi",
1975 1,
1976 "third.rs",
1977 window.into(),
1978 &workspace,
1979 &deterministic,
1980 cx,
1981 )
1982 .await;
1983 open_close_queried_buffer(
1984 "fir",
1985 1,
1986 "first.rs",
1987 window.into(),
1988 &workspace,
1989 &deterministic,
1990 cx,
1991 )
1992 .await;
1993
1994 cx.dispatch_action(window.into(), Toggle);
1995 let query = "rs";
1996 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
1997 finder
1998 .update(cx, |finder, cx| {
1999 finder.delegate_mut().update_matches(query.to_string(), cx)
2000 })
2001 .await;
2002 finder.read_with(cx, |finder, _| {
2003 let delegate = finder.delegate();
2004 let history_entries = delegate
2005 .matches
2006 .history
2007 .iter()
2008 .map(|(_, path_match)| path_match.as_ref().expect("should have a path match").path.to_path_buf())
2009 .collect::<Vec<_>>();
2010 assert_eq!(
2011 history_entries,
2012 vec![
2013 PathBuf::from("test/first.rs"),
2014 PathBuf::from("test/third.rs"),
2015 ],
2016 "Should have all opened files in the history, except the ones that do not exist on disk"
2017 );
2018 });
2019 }
2020
2021 async fn open_close_queried_buffer(
2022 input: &str,
2023 expected_matches: usize,
2024 expected_editor_title: &str,
2025 window: gpui::AnyWindowHandle,
2026 workspace: &ViewHandle<Workspace>,
2027 deterministic: &gpui::executor::Deterministic,
2028 cx: &mut gpui::TestAppContext,
2029 ) -> Vec<FoundPath> {
2030 cx.dispatch_action(window, Toggle);
2031 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
2032 finder
2033 .update(cx, |finder, cx| {
2034 finder.delegate_mut().update_matches(input.to_string(), cx)
2035 })
2036 .await;
2037 let history_items = finder.read_with(cx, |finder, _| {
2038 assert_eq!(
2039 finder.delegate().matches.len(),
2040 expected_matches,
2041 "Unexpected number of matches found for query {input}"
2042 );
2043 finder.delegate().history_items.clone()
2044 });
2045
2046 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
2047 cx.dispatch_action(window, SelectNext);
2048 cx.dispatch_action(window, Confirm);
2049 deterministic.run_until_parked();
2050 active_pane
2051 .condition(cx, |pane, _| pane.active_item().is_some())
2052 .await;
2053 cx.read(|cx| {
2054 let active_item = active_pane.read(cx).active_item().unwrap();
2055 let active_editor_title = active_item
2056 .as_any()
2057 .downcast_ref::<Editor>()
2058 .unwrap()
2059 .read(cx)
2060 .title(cx);
2061 assert_eq!(
2062 expected_editor_title, active_editor_title,
2063 "Unexpected editor title for query {input}"
2064 );
2065 });
2066
2067 close_active_item(workspace, deterministic, cx).await;
2068
2069 history_items
2070 }
2071
2072 async fn close_active_item(
2073 workspace: &ViewHandle<Workspace>,
2074 deterministic: &gpui::executor::Deterministic,
2075 cx: &mut TestAppContext,
2076 ) {
2077 let mut original_items = HashMap::new();
2078 cx.read(|cx| {
2079 for pane in workspace.read(cx).panes() {
2080 let pane_id = pane.id();
2081 let pane = pane.read(cx);
2082 let insertion_result = original_items.insert(pane_id, pane.items().count());
2083 assert!(insertion_result.is_none(), "Pane id {pane_id} collision");
2084 }
2085 });
2086
2087 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
2088 active_pane
2089 .update(cx, |pane, cx| {
2090 pane.close_active_item(&workspace::CloseActiveItem { save_intent: None }, cx)
2091 .unwrap()
2092 })
2093 .await
2094 .unwrap();
2095 deterministic.run_until_parked();
2096 cx.read(|cx| {
2097 for pane in workspace.read(cx).panes() {
2098 let pane_id = pane.id();
2099 let pane = pane.read(cx);
2100 match original_items.remove(&pane_id) {
2101 Some(original_items) => {
2102 assert_eq!(
2103 pane.items().count(),
2104 original_items.saturating_sub(1),
2105 "Pane id {pane_id} should have item closed"
2106 );
2107 }
2108 None => panic!("Pane id {pane_id} not found in original items"),
2109 }
2110 }
2111 });
2112 assert!(
2113 original_items.len() <= 1,
2114 "At most one panel should got closed"
2115 );
2116 }
2117
2118 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
2119 cx.foreground().forbid_parking();
2120 cx.update(|cx| {
2121 let state = AppState::test(cx);
2122 theme::init((), cx);
2123 language::init(cx);
2124 super::init(cx);
2125 editor::init(cx);
2126 workspace::init_settings(cx);
2127 Project::init_settings(cx);
2128 state
2129 })
2130 }
2131
2132 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
2133 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
2134 Ok::<_, std::convert::Infallible>(FileSearchQuery {
2135 raw_query: test_str.to_owned(),
2136 file_query_end: if path_like_str == test_str {
2137 None
2138 } else {
2139 Some(path_like_str.len())
2140 },
2141 })
2142 })
2143 .unwrap()
2144 }
2145
2146 fn dummy_found_path(project_path: ProjectPath) -> FoundPath {
2147 FoundPath {
2148 project: project_path,
2149 absolute: None,
2150 }
2151 }
2152}