1use editor::{scroll::autoscroll::Autoscroll, Bias, Editor};
2use fuzzy::PathMatch;
3use gpui::{
4 actions, elements::*, AppContext, ModelHandle, MouseState, Task, ViewContext, WeakViewHandle,
5};
6use picker::{Picker, PickerDelegate};
7use project::{PathMatchCandidateSet, Project, ProjectPath, WorktreeId};
8use std::{
9 path::Path,
10 sync::{
11 atomic::{self, AtomicBool},
12 Arc,
13 },
14};
15use text::Point;
16use util::{paths::PathLikeWithPosition, post_inc, ResultExt};
17use workspace::Workspace;
18
19pub type FileFinder = Picker<FileFinderDelegate>;
20
21pub struct FileFinderDelegate {
22 workspace: WeakViewHandle<Workspace>,
23 project: ModelHandle<Project>,
24 search_count: usize,
25 latest_search_id: usize,
26 latest_search_did_cancel: bool,
27 latest_search_query: Option<PathLikeWithPosition<FileSearchQuery>>,
28 relative_to: Option<Arc<Path>>,
29 matches: Vec<PathMatch>,
30 selected: Option<(usize, Arc<Path>)>,
31 cancel_flag: Arc<AtomicBool>,
32}
33
34actions!(file_finder, [Toggle]);
35
36pub fn init(cx: &mut AppContext) {
37 cx.add_action(toggle_file_finder);
38 FileFinder::init(cx);
39}
40
41fn toggle_file_finder(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
42 workspace.toggle_modal(cx, |workspace, cx| {
43 let relative_to = workspace
44 .active_item(cx)
45 .and_then(|item| item.project_path(cx))
46 .map(|project_path| project_path.path.clone());
47 let project = workspace.project().clone();
48 let workspace = cx.handle().downgrade();
49 let finder = cx.add_view(|cx| {
50 Picker::new(
51 FileFinderDelegate::new(workspace, project, relative_to, cx),
52 cx,
53 )
54 });
55 finder
56 });
57}
58
59pub enum Event {
60 Selected(ProjectPath),
61 Dismissed,
62}
63
64#[derive(Debug, Clone)]
65struct FileSearchQuery {
66 raw_query: String,
67 file_query_end: Option<usize>,
68}
69
70impl FileSearchQuery {
71 fn path_query(&self) -> &str {
72 match self.file_query_end {
73 Some(file_path_end) => &self.raw_query[..file_path_end],
74 None => &self.raw_query,
75 }
76 }
77}
78
79impl FileFinderDelegate {
80 fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
81 let path = &path_match.path;
82 let path_string = path.to_string_lossy();
83 let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
84 let path_positions = path_match.positions.clone();
85
86 let file_name = path.file_name().map_or_else(
87 || path_match.path_prefix.to_string(),
88 |file_name| file_name.to_string_lossy().to_string(),
89 );
90 let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
91 - file_name.chars().count();
92 let file_name_positions = path_positions
93 .iter()
94 .filter_map(|pos| {
95 if pos >= &file_name_start {
96 Some(pos - file_name_start)
97 } else {
98 None
99 }
100 })
101 .collect();
102
103 (file_name, file_name_positions, full_path, path_positions)
104 }
105
106 pub fn new(
107 workspace: WeakViewHandle<Workspace>,
108 project: ModelHandle<Project>,
109 relative_to: Option<Arc<Path>>,
110 cx: &mut ViewContext<FileFinder>,
111 ) -> Self {
112 cx.observe(&project, |picker, _, cx| {
113 picker.update_matches(picker.query(cx), cx);
114 })
115 .detach();
116 Self {
117 workspace,
118 project,
119 search_count: 0,
120 latest_search_id: 0,
121 latest_search_did_cancel: false,
122 latest_search_query: None,
123 relative_to,
124 matches: Vec::new(),
125 selected: None,
126 cancel_flag: Arc::new(AtomicBool::new(false)),
127 }
128 }
129
130 fn spawn_search(
131 &mut self,
132 query: PathLikeWithPosition<FileSearchQuery>,
133 cx: &mut ViewContext<FileFinder>,
134 ) -> Task<()> {
135 let relative_to = self.relative_to.clone();
136 let worktrees = self
137 .project
138 .read(cx)
139 .visible_worktrees(cx)
140 .collect::<Vec<_>>();
141 let include_root_name = worktrees.len() > 1;
142 let candidate_sets = worktrees
143 .into_iter()
144 .map(|worktree| {
145 let worktree = worktree.read(cx);
146 PathMatchCandidateSet {
147 snapshot: worktree.snapshot(),
148 include_ignored: worktree
149 .root_entry()
150 .map_or(false, |entry| entry.is_ignored),
151 include_root_name,
152 }
153 })
154 .collect::<Vec<_>>();
155
156 let search_id = util::post_inc(&mut self.search_count);
157 self.cancel_flag.store(true, atomic::Ordering::Relaxed);
158 self.cancel_flag = Arc::new(AtomicBool::new(false));
159 let cancel_flag = self.cancel_flag.clone();
160 cx.spawn(|picker, mut cx| async move {
161 let matches = fuzzy::match_path_sets(
162 candidate_sets.as_slice(),
163 query.path_like.path_query(),
164 relative_to,
165 false,
166 100,
167 &cancel_flag,
168 cx.background(),
169 )
170 .await;
171 let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
172 picker
173 .update(&mut cx, |picker, cx| {
174 picker
175 .delegate_mut()
176 .set_matches(search_id, did_cancel, query, matches, cx)
177 })
178 .log_err();
179 })
180 }
181
182 fn set_matches(
183 &mut self,
184 search_id: usize,
185 did_cancel: bool,
186 query: PathLikeWithPosition<FileSearchQuery>,
187 matches: Vec<PathMatch>,
188 cx: &mut ViewContext<FileFinder>,
189 ) {
190 if search_id >= self.latest_search_id {
191 self.latest_search_id = search_id;
192 if self.latest_search_did_cancel
193 && Some(query.path_like.path_query())
194 == self
195 .latest_search_query
196 .as_ref()
197 .map(|query| query.path_like.path_query())
198 {
199 util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
200 } else {
201 self.matches = matches;
202 }
203 self.latest_search_query = Some(query);
204 self.latest_search_did_cancel = did_cancel;
205 cx.notify();
206 }
207 }
208}
209
210impl PickerDelegate for FileFinderDelegate {
211 fn placeholder_text(&self) -> Arc<str> {
212 "Search project files...".into()
213 }
214
215 fn match_count(&self) -> usize {
216 self.matches.len()
217 }
218
219 fn selected_index(&self) -> usize {
220 if let Some(selected) = self.selected.as_ref() {
221 for (ix, path_match) in self.matches.iter().enumerate() {
222 if (path_match.worktree_id, path_match.path.as_ref())
223 == (selected.0, selected.1.as_ref())
224 {
225 return ix;
226 }
227 }
228 }
229 0
230 }
231
232 fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<FileFinder>) {
233 let mat = &self.matches[ix];
234 self.selected = Some((mat.worktree_id, mat.path.clone()));
235 cx.notify();
236 }
237
238 fn update_matches(&mut self, raw_query: String, cx: &mut ViewContext<FileFinder>) -> Task<()> {
239 if raw_query.is_empty() {
240 self.latest_search_id = post_inc(&mut self.search_count);
241 self.matches.clear();
242 cx.notify();
243 Task::ready(())
244 } else {
245 let raw_query = &raw_query;
246 let query = PathLikeWithPosition::parse_str(raw_query, |path_like_str| {
247 Ok::<_, std::convert::Infallible>(FileSearchQuery {
248 raw_query: raw_query.to_owned(),
249 file_query_end: if path_like_str == raw_query {
250 None
251 } else {
252 Some(path_like_str.len())
253 },
254 })
255 })
256 .expect("infallible");
257 self.spawn_search(query, cx)
258 }
259 }
260
261 fn confirm(&mut self, cx: &mut ViewContext<FileFinder>) {
262 if let Some(m) = self.matches.get(self.selected_index()) {
263 if let Some(workspace) = self.workspace.upgrade(cx) {
264 let project_path = ProjectPath {
265 worktree_id: WorktreeId::from_usize(m.worktree_id),
266 path: m.path.clone(),
267 };
268
269 let open_task = workspace.update(cx, |workspace, cx| {
270 workspace.open_path(project_path.clone(), None, true, cx)
271 });
272
273 let workspace = workspace.downgrade();
274
275 let row = self
276 .latest_search_query
277 .as_ref()
278 .and_then(|query| query.row)
279 .map(|row| row.saturating_sub(1));
280 let col = self
281 .latest_search_query
282 .as_ref()
283 .and_then(|query| query.column)
284 .unwrap_or(0)
285 .saturating_sub(1);
286 cx.spawn(|_, mut cx| async move {
287 let item = open_task.await.log_err()?;
288 if let Some(row) = row {
289 if let Some(active_editor) = item.downcast::<Editor>() {
290 active_editor
291 .downgrade()
292 .update(&mut cx, |editor, cx| {
293 let snapshot = editor.snapshot(cx).display_snapshot;
294 let point = snapshot
295 .buffer_snapshot
296 .clip_point(Point::new(row, col), Bias::Left);
297 editor.change_selections(Some(Autoscroll::center()), cx, |s| {
298 s.select_ranges([point..point])
299 });
300 })
301 .log_err();
302 }
303 }
304
305 workspace
306 .update(&mut cx, |workspace, cx| workspace.dismiss_modal(cx))
307 .log_err();
308
309 Some(())
310 })
311 .detach();
312 }
313 }
314 }
315
316 fn dismissed(&mut self, _: &mut ViewContext<FileFinder>) {}
317
318 fn render_match(
319 &self,
320 ix: usize,
321 mouse_state: &mut MouseState,
322 selected: bool,
323 cx: &AppContext,
324 ) -> AnyElement<Picker<Self>> {
325 let path_match = &self.matches[ix];
326 let theme = theme::current(cx);
327 let style = theme.picker.item.style_for(mouse_state, selected);
328 let (file_name, file_name_positions, full_path, full_path_positions) =
329 self.labels_for_match(path_match);
330 Flex::column()
331 .with_child(
332 Label::new(file_name, style.label.clone()).with_highlights(file_name_positions),
333 )
334 .with_child(
335 Label::new(full_path, style.label.clone()).with_highlights(full_path_positions),
336 )
337 .flex(1., false)
338 .contained()
339 .with_style(style.container)
340 .into_any_named("match")
341 }
342}
343
344#[cfg(test)]
345mod tests {
346 use std::time::Duration;
347
348 use super::*;
349 use editor::Editor;
350 use gpui::TestAppContext;
351 use menu::{Confirm, SelectNext};
352 use serde_json::json;
353 use workspace::{AppState, Workspace};
354
355 #[ctor::ctor]
356 fn init_logger() {
357 if std::env::var("RUST_LOG").is_ok() {
358 env_logger::init();
359 }
360 }
361
362 #[gpui::test]
363 async fn test_matching_paths(cx: &mut TestAppContext) {
364 let app_state = init_test(cx);
365 app_state
366 .fs
367 .as_fake()
368 .insert_tree(
369 "/root",
370 json!({
371 "a": {
372 "banana": "",
373 "bandana": "",
374 }
375 }),
376 )
377 .await;
378
379 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
380 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
381 cx.dispatch_action(window_id, Toggle);
382
383 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
384 finder
385 .update(cx, |finder, cx| {
386 finder.delegate_mut().update_matches("bna".to_string(), cx)
387 })
388 .await;
389 finder.read_with(cx, |finder, _| {
390 assert_eq!(finder.delegate().matches.len(), 2);
391 });
392
393 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
394 cx.dispatch_action(window_id, SelectNext);
395 cx.dispatch_action(window_id, Confirm);
396 active_pane
397 .condition(cx, |pane, _| pane.active_item().is_some())
398 .await;
399 cx.read(|cx| {
400 let active_item = active_pane.read(cx).active_item().unwrap();
401 assert_eq!(
402 active_item
403 .as_any()
404 .downcast_ref::<Editor>()
405 .unwrap()
406 .read(cx)
407 .title(cx),
408 "bandana"
409 );
410 });
411 }
412
413 #[gpui::test]
414 async fn test_row_column_numbers_query_inside_file(cx: &mut TestAppContext) {
415 let app_state = init_test(cx);
416
417 let first_file_name = "first.rs";
418 let first_file_contents = "// First Rust file";
419 app_state
420 .fs
421 .as_fake()
422 .insert_tree(
423 "/src",
424 json!({
425 "test": {
426 first_file_name: first_file_contents,
427 "second.rs": "// Second Rust file",
428 }
429 }),
430 )
431 .await;
432
433 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
434 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
435 cx.dispatch_action(window_id, Toggle);
436 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
437
438 let file_query = &first_file_name[..3];
439 let file_row = 1;
440 let file_column = 3;
441 assert!(file_column <= first_file_contents.len());
442 let query_inside_file = format!("{file_query}:{file_row}:{file_column}");
443 finder
444 .update(cx, |finder, cx| {
445 finder
446 .delegate_mut()
447 .update_matches(query_inside_file.to_string(), cx)
448 })
449 .await;
450 finder.read_with(cx, |finder, _| {
451 let finder = finder.delegate();
452 assert_eq!(finder.matches.len(), 1);
453 let latest_search_query = finder
454 .latest_search_query
455 .as_ref()
456 .expect("Finder should have a query after the update_matches call");
457 assert_eq!(latest_search_query.path_like.raw_query, query_inside_file);
458 assert_eq!(
459 latest_search_query.path_like.file_query_end,
460 Some(file_query.len())
461 );
462 assert_eq!(latest_search_query.row, Some(file_row));
463 assert_eq!(latest_search_query.column, Some(file_column as u32));
464 });
465
466 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
467 cx.dispatch_action(window_id, SelectNext);
468 cx.dispatch_action(window_id, Confirm);
469 active_pane
470 .condition(cx, |pane, _| pane.active_item().is_some())
471 .await;
472 let editor = cx.update(|cx| {
473 let active_item = active_pane.read(cx).active_item().unwrap();
474 active_item.downcast::<Editor>().unwrap()
475 });
476 cx.foreground().advance_clock(Duration::from_secs(2));
477 cx.foreground().start_waiting();
478 cx.foreground().finish_waiting();
479 editor.update(cx, |editor, cx| {
480 let all_selections = editor.selections.all_adjusted(cx);
481 assert_eq!(
482 all_selections.len(),
483 1,
484 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
485 );
486 let caret_selection = all_selections.into_iter().next().unwrap();
487 assert_eq!(caret_selection.start, caret_selection.end,
488 "Caret selection should have its start and end at the same position");
489 assert_eq!(file_row, caret_selection.start.row + 1,
490 "Query inside file should get caret with the same focus row");
491 assert_eq!(file_column, caret_selection.start.column as usize + 1,
492 "Query inside file should get caret with the same focus column");
493 });
494 }
495
496 #[gpui::test]
497 async fn test_row_column_numbers_query_outside_file(cx: &mut TestAppContext) {
498 let app_state = init_test(cx);
499
500 let first_file_name = "first.rs";
501 let first_file_contents = "// First Rust file";
502 app_state
503 .fs
504 .as_fake()
505 .insert_tree(
506 "/src",
507 json!({
508 "test": {
509 first_file_name: first_file_contents,
510 "second.rs": "// Second Rust file",
511 }
512 }),
513 )
514 .await;
515
516 let project = Project::test(app_state.fs.clone(), ["/src".as_ref()], cx).await;
517 let (window_id, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
518 cx.dispatch_action(window_id, Toggle);
519 let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
520
521 let file_query = &first_file_name[..3];
522 let file_row = 200;
523 let file_column = 300;
524 assert!(file_column > first_file_contents.len());
525 let query_outside_file = format!("{file_query}:{file_row}:{file_column}");
526 finder
527 .update(cx, |finder, cx| {
528 finder
529 .delegate_mut()
530 .update_matches(query_outside_file.to_string(), cx)
531 })
532 .await;
533 finder.read_with(cx, |finder, _| {
534 let finder = finder.delegate();
535 assert_eq!(finder.matches.len(), 1);
536 let latest_search_query = finder
537 .latest_search_query
538 .as_ref()
539 .expect("Finder should have a query after the update_matches call");
540 assert_eq!(latest_search_query.path_like.raw_query, query_outside_file);
541 assert_eq!(
542 latest_search_query.path_like.file_query_end,
543 Some(file_query.len())
544 );
545 assert_eq!(latest_search_query.row, Some(file_row));
546 assert_eq!(latest_search_query.column, Some(file_column as u32));
547 });
548
549 let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
550 cx.dispatch_action(window_id, SelectNext);
551 cx.dispatch_action(window_id, Confirm);
552 active_pane
553 .condition(cx, |pane, _| pane.active_item().is_some())
554 .await;
555 let editor = cx.update(|cx| {
556 let active_item = active_pane.read(cx).active_item().unwrap();
557 active_item.downcast::<Editor>().unwrap()
558 });
559 cx.foreground().advance_clock(Duration::from_secs(2));
560 cx.foreground().start_waiting();
561 cx.foreground().finish_waiting();
562 editor.update(cx, |editor, cx| {
563 let all_selections = editor.selections.all_adjusted(cx);
564 assert_eq!(
565 all_selections.len(),
566 1,
567 "Expected to have 1 selection (caret) after file finder confirm, but got: {all_selections:?}"
568 );
569 let caret_selection = all_selections.into_iter().next().unwrap();
570 assert_eq!(caret_selection.start, caret_selection.end,
571 "Caret selection should have its start and end at the same position");
572 assert_eq!(0, caret_selection.start.row,
573 "Excessive rows (as in query outside file borders) should get trimmed to last file row");
574 assert_eq!(first_file_contents.len(), caret_selection.start.column as usize,
575 "Excessive columns (as in query outside file borders) should get trimmed to selected row's last column");
576 });
577 }
578
579 #[gpui::test]
580 async fn test_matching_cancellation(cx: &mut TestAppContext) {
581 let app_state = init_test(cx);
582 app_state
583 .fs
584 .as_fake()
585 .insert_tree(
586 "/dir",
587 json!({
588 "hello": "",
589 "goodbye": "",
590 "halogen-light": "",
591 "happiness": "",
592 "height": "",
593 "hi": "",
594 "hiccup": "",
595 }),
596 )
597 .await;
598
599 let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
600 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
601 let (_, finder) = cx.add_window(|cx| {
602 Picker::new(
603 FileFinderDelegate::new(
604 workspace.downgrade(),
605 workspace.read(cx).project().clone(),
606 None,
607 cx,
608 ),
609 cx,
610 )
611 });
612
613 let query = test_path_like("hi");
614 finder
615 .update(cx, |f, cx| f.delegate_mut().spawn_search(query.clone(), cx))
616 .await;
617 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 5));
618
619 finder.update(cx, |finder, cx| {
620 let delegate = finder.delegate_mut();
621 let matches = delegate.matches.clone();
622
623 // Simulate a search being cancelled after the time limit,
624 // returning only a subset of the matches that would have been found.
625 drop(delegate.spawn_search(query.clone(), cx));
626 delegate.set_matches(
627 delegate.latest_search_id,
628 true, // did-cancel
629 query.clone(),
630 vec![matches[1].clone(), matches[3].clone()],
631 cx,
632 );
633
634 // Simulate another cancellation.
635 drop(delegate.spawn_search(query.clone(), cx));
636 delegate.set_matches(
637 delegate.latest_search_id,
638 true, // did-cancel
639 query.clone(),
640 vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
641 cx,
642 );
643
644 assert_eq!(delegate.matches, matches[0..4])
645 });
646 }
647
648 #[gpui::test]
649 async fn test_ignored_files(cx: &mut TestAppContext) {
650 let app_state = init_test(cx);
651 app_state
652 .fs
653 .as_fake()
654 .insert_tree(
655 "/ancestor",
656 json!({
657 ".gitignore": "ignored-root",
658 "ignored-root": {
659 "happiness": "",
660 "height": "",
661 "hi": "",
662 "hiccup": "",
663 },
664 "tracked-root": {
665 ".gitignore": "height",
666 "happiness": "",
667 "height": "",
668 "hi": "",
669 "hiccup": "",
670 },
671 }),
672 )
673 .await;
674
675 let project = Project::test(
676 app_state.fs.clone(),
677 [
678 "/ancestor/tracked-root".as_ref(),
679 "/ancestor/ignored-root".as_ref(),
680 ],
681 cx,
682 )
683 .await;
684 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
685 let (_, finder) = cx.add_window(|cx| {
686 Picker::new(
687 FileFinderDelegate::new(
688 workspace.downgrade(),
689 workspace.read(cx).project().clone(),
690 None,
691 cx,
692 ),
693 cx,
694 )
695 });
696 finder
697 .update(cx, |f, cx| {
698 f.delegate_mut().spawn_search(test_path_like("hi"), cx)
699 })
700 .await;
701 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 7));
702 }
703
704 #[gpui::test]
705 async fn test_single_file_worktrees(cx: &mut TestAppContext) {
706 let app_state = init_test(cx);
707 app_state
708 .fs
709 .as_fake()
710 .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
711 .await;
712
713 let project = Project::test(
714 app_state.fs.clone(),
715 ["/root/the-parent-dir/the-file".as_ref()],
716 cx,
717 )
718 .await;
719 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
720 let (_, finder) = cx.add_window(|cx| {
721 Picker::new(
722 FileFinderDelegate::new(
723 workspace.downgrade(),
724 workspace.read(cx).project().clone(),
725 None,
726 cx,
727 ),
728 cx,
729 )
730 });
731
732 // Even though there is only one worktree, that worktree's filename
733 // is included in the matching, because the worktree is a single file.
734 finder
735 .update(cx, |f, cx| {
736 f.delegate_mut().spawn_search(test_path_like("thf"), cx)
737 })
738 .await;
739 cx.read(|cx| {
740 let finder = finder.read(cx);
741 let delegate = finder.delegate();
742 assert_eq!(delegate.matches.len(), 1);
743
744 let (file_name, file_name_positions, full_path, full_path_positions) =
745 delegate.labels_for_match(&delegate.matches[0]);
746 assert_eq!(file_name, "the-file");
747 assert_eq!(file_name_positions, &[0, 1, 4]);
748 assert_eq!(full_path, "the-file");
749 assert_eq!(full_path_positions, &[0, 1, 4]);
750 });
751
752 // Since the worktree root is a file, searching for its name followed by a slash does
753 // not match anything.
754 finder
755 .update(cx, |f, cx| {
756 f.delegate_mut().spawn_search(test_path_like("thf/"), cx)
757 })
758 .await;
759 finder.read_with(cx, |f, _| assert_eq!(f.delegate().matches.len(), 0));
760 }
761
762 #[gpui::test]
763 async fn test_multiple_matches_with_same_relative_path(cx: &mut TestAppContext) {
764 let app_state = init_test(cx);
765 app_state
766 .fs
767 .as_fake()
768 .insert_tree(
769 "/root",
770 json!({
771 "dir1": { "a.txt": "" },
772 "dir2": { "a.txt": "" }
773 }),
774 )
775 .await;
776
777 let project = Project::test(
778 app_state.fs.clone(),
779 ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
780 cx,
781 )
782 .await;
783 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
784
785 let (_, finder) = cx.add_window(|cx| {
786 Picker::new(
787 FileFinderDelegate::new(
788 workspace.downgrade(),
789 workspace.read(cx).project().clone(),
790 None,
791 cx,
792 ),
793 cx,
794 )
795 });
796
797 // Run a search that matches two files with the same relative path.
798 finder
799 .update(cx, |f, cx| {
800 f.delegate_mut().spawn_search(test_path_like("a.t"), cx)
801 })
802 .await;
803
804 // Can switch between different matches with the same relative path.
805 finder.update(cx, |finder, cx| {
806 let delegate = finder.delegate_mut();
807 assert_eq!(delegate.matches.len(), 2);
808 assert_eq!(delegate.selected_index(), 0);
809 delegate.set_selected_index(1, cx);
810 assert_eq!(delegate.selected_index(), 1);
811 delegate.set_selected_index(0, cx);
812 assert_eq!(delegate.selected_index(), 0);
813 });
814 }
815
816 #[gpui::test]
817 async fn test_path_distance_ordering(cx: &mut TestAppContext) {
818 let app_state = init_test(cx);
819 app_state
820 .fs
821 .as_fake()
822 .insert_tree(
823 "/root",
824 json!({
825 "dir1": { "a.txt": "" },
826 "dir2": {
827 "a.txt": "",
828 "b.txt": ""
829 }
830 }),
831 )
832 .await;
833
834 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
835 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
836
837 // When workspace has an active item, sort items which are closer to that item
838 // first when they have the same name. In this case, b.txt is closer to dir2's a.txt
839 // so that one should be sorted earlier
840 let b_path = Some(Arc::from(Path::new("/root/dir2/b.txt")));
841 let (_, finder) = cx.add_window(|cx| {
842 Picker::new(
843 FileFinderDelegate::new(
844 workspace.downgrade(),
845 workspace.read(cx).project().clone(),
846 b_path,
847 cx,
848 ),
849 cx,
850 )
851 });
852
853 finder
854 .update(cx, |f, cx| {
855 f.delegate_mut().spawn_search(test_path_like("a.txt"), cx)
856 })
857 .await;
858
859 finder.read_with(cx, |f, _| {
860 let delegate = f.delegate();
861 assert_eq!(delegate.matches[0].path.as_ref(), Path::new("dir2/a.txt"));
862 assert_eq!(delegate.matches[1].path.as_ref(), Path::new("dir1/a.txt"));
863 });
864 }
865
866 #[gpui::test]
867 async fn test_search_worktree_without_files(cx: &mut TestAppContext) {
868 let app_state = init_test(cx);
869 app_state
870 .fs
871 .as_fake()
872 .insert_tree(
873 "/root",
874 json!({
875 "dir1": {},
876 "dir2": {
877 "dir3": {}
878 }
879 }),
880 )
881 .await;
882
883 let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
884 let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
885 let (_, finder) = cx.add_window(|cx| {
886 Picker::new(
887 FileFinderDelegate::new(
888 workspace.downgrade(),
889 workspace.read(cx).project().clone(),
890 None,
891 cx,
892 ),
893 cx,
894 )
895 });
896 finder
897 .update(cx, |f, cx| {
898 f.delegate_mut().spawn_search(test_path_like("dir"), cx)
899 })
900 .await;
901 cx.read(|cx| {
902 let finder = finder.read(cx);
903 assert_eq!(finder.delegate().matches.len(), 0);
904 });
905 }
906
907 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
908 cx.foreground().forbid_parking();
909 cx.update(|cx| {
910 let state = AppState::test(cx);
911 theme::init((), cx);
912 language::init(cx);
913 super::init(cx);
914 editor::init(cx);
915 workspace::init_settings(cx);
916 state
917 })
918 }
919
920 fn test_path_like(test_str: &str) -> PathLikeWithPosition<FileSearchQuery> {
921 PathLikeWithPosition::parse_str(test_str, |path_like_str| {
922 Ok::<_, std::convert::Infallible>(FileSearchQuery {
923 raw_query: test_str.to_owned(),
924 file_query_end: if path_like_str == test_str {
925 None
926 } else {
927 Some(path_like_str.len())
928 },
929 })
930 })
931 .unwrap()
932 }
933}