file_finder.rs

  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}