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