file_finder.rs

  1use fuzzy::PathMatch;
  2use gpui::{
  3    actions, elements::*, AnyViewHandle, AppContext, Entity, ModelHandle, MouseState,
  4    MutableAppContext, RenderContext, Task, View, ViewContext, ViewHandle,
  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 util::post_inc;
 17use workspace::Workspace;
 18
 19pub struct FileFinder {
 20    project: ModelHandle<Project>,
 21    picker: ViewHandle<Picker<Self>>,
 22    search_count: usize,
 23    latest_search_id: usize,
 24    latest_search_did_cancel: bool,
 25    latest_search_query: String,
 26    matches: Vec<PathMatch>,
 27    selected: Option<(usize, Arc<Path>)>,
 28    cancel_flag: Arc<AtomicBool>,
 29}
 30
 31actions!(file_finder, [Toggle]);
 32
 33pub fn init(cx: &mut MutableAppContext) {
 34    cx.add_action(FileFinder::toggle);
 35    Picker::<FileFinder>::init(cx);
 36}
 37
 38pub enum Event {
 39    Selected(ProjectPath),
 40    Dismissed,
 41}
 42
 43impl Entity for FileFinder {
 44    type Event = Event;
 45}
 46
 47impl View for FileFinder {
 48    fn ui_name() -> &'static str {
 49        "FileFinder"
 50    }
 51
 52    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
 53        ChildView::new(self.picker.clone(), cx).boxed()
 54    }
 55
 56    fn focus_in(&mut self, _: AnyViewHandle, cx: &mut ViewContext<Self>) {
 57        if cx.is_self_focused() {
 58            cx.focus(&self.picker);
 59        }
 60    }
 61}
 62
 63impl FileFinder {
 64    fn labels_for_match(&self, path_match: &PathMatch) -> (String, Vec<usize>, String, Vec<usize>) {
 65        let path = &path_match.path;
 66        let path_string = path.to_string_lossy();
 67        let full_path = [path_match.path_prefix.as_ref(), path_string.as_ref()].join("");
 68        let path_positions = path_match.positions.clone();
 69
 70        let file_name = path.file_name().map_or_else(
 71            || path_match.path_prefix.to_string(),
 72            |file_name| file_name.to_string_lossy().to_string(),
 73        );
 74        let file_name_start = path_match.path_prefix.chars().count() + path_string.chars().count()
 75            - file_name.chars().count();
 76        let file_name_positions = path_positions
 77            .iter()
 78            .filter_map(|pos| {
 79                if pos >= &file_name_start {
 80                    Some(pos - file_name_start)
 81                } else {
 82                    None
 83                }
 84            })
 85            .collect();
 86
 87        (file_name, file_name_positions, full_path, path_positions)
 88    }
 89
 90    fn toggle(workspace: &mut Workspace, _: &Toggle, cx: &mut ViewContext<Workspace>) {
 91        workspace.toggle_modal(cx, |workspace, cx| {
 92            let project = workspace.project().clone();
 93            let finder = cx.add_view(|cx| Self::new(project, cx));
 94            cx.subscribe(&finder, Self::on_event).detach();
 95            finder
 96        });
 97    }
 98
 99    fn on_event(
100        workspace: &mut Workspace,
101        _: ViewHandle<FileFinder>,
102        event: &Event,
103        cx: &mut ViewContext<Workspace>,
104    ) {
105        match event {
106            Event::Selected(project_path) => {
107                workspace
108                    .open_path(project_path.clone(), None, true, cx)
109                    .detach_and_log_err(cx);
110                workspace.dismiss_modal(cx);
111            }
112            Event::Dismissed => {
113                workspace.dismiss_modal(cx);
114            }
115        }
116    }
117
118    pub fn new(project: ModelHandle<Project>, cx: &mut ViewContext<Self>) -> Self {
119        let handle = cx.weak_handle();
120        cx.observe(&project, Self::project_updated).detach();
121        Self {
122            project,
123            picker: cx.add_view(|cx| Picker::new("Search project files...", handle, cx)),
124            search_count: 0,
125            latest_search_id: 0,
126            latest_search_did_cancel: false,
127            latest_search_query: String::new(),
128            matches: Vec::new(),
129            selected: None,
130            cancel_flag: Arc::new(AtomicBool::new(false)),
131        }
132    }
133
134    fn project_updated(&mut self, _: ModelHandle<Project>, cx: &mut ViewContext<Self>) {
135        self.spawn_search(self.picker.read(cx).query(cx), cx)
136            .detach();
137    }
138
139    fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
140        let worktrees = self
141            .project
142            .read(cx)
143            .visible_worktrees(cx)
144            .collect::<Vec<_>>();
145        let include_root_name = worktrees.len() > 1;
146        let candidate_sets = worktrees
147            .into_iter()
148            .map(|worktree| {
149                let worktree = worktree.read(cx);
150                PathMatchCandidateSet {
151                    snapshot: worktree.snapshot(),
152                    include_ignored: worktree
153                        .root_entry()
154                        .map_or(false, |entry| entry.is_ignored),
155                    include_root_name,
156                }
157            })
158            .collect::<Vec<_>>();
159
160        let search_id = util::post_inc(&mut self.search_count);
161        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
162        self.cancel_flag = Arc::new(AtomicBool::new(false));
163        let cancel_flag = self.cancel_flag.clone();
164        cx.spawn(|this, mut cx| async move {
165            let matches = fuzzy::match_path_sets(
166                candidate_sets.as_slice(),
167                &query,
168                false,
169                100,
170                &cancel_flag,
171                cx.background(),
172            )
173            .await;
174            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
175            this.update(&mut cx, |this, cx| {
176                this.set_matches(search_id, did_cancel, query, matches, cx)
177            });
178        })
179    }
180
181    fn set_matches(
182        &mut self,
183        search_id: usize,
184        did_cancel: bool,
185        query: String,
186        matches: Vec<PathMatch>,
187        cx: &mut ViewContext<Self>,
188    ) {
189        if search_id >= self.latest_search_id {
190            self.latest_search_id = search_id;
191            if self.latest_search_did_cancel && query == self.latest_search_query {
192                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
193            } else {
194                self.matches = matches;
195            }
196            self.latest_search_query = query;
197            self.latest_search_did_cancel = did_cancel;
198            cx.notify();
199            self.picker.update(cx, |_, cx| cx.notify());
200        }
201    }
202}
203
204impl PickerDelegate for FileFinder {
205    fn match_count(&self) -> usize {
206        self.matches.len()
207    }
208
209    fn selected_index(&self) -> usize {
210        if let Some(selected) = self.selected.as_ref() {
211            for (ix, path_match) in self.matches.iter().enumerate() {
212                if (path_match.worktree_id, path_match.path.as_ref())
213                    == (selected.0, selected.1.as_ref())
214                {
215                    return ix;
216                }
217            }
218        }
219        0
220    }
221
222    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Self>) {
223        let mat = &self.matches[ix];
224        self.selected = Some((mat.worktree_id, mat.path.clone()));
225        cx.notify();
226    }
227
228    fn update_matches(&mut self, query: String, cx: &mut ViewContext<Self>) -> Task<()> {
229        if query.is_empty() {
230            self.latest_search_id = post_inc(&mut self.search_count);
231            self.matches.clear();
232            cx.notify();
233            Task::ready(())
234        } else {
235            self.spawn_search(query, cx)
236        }
237    }
238
239    fn confirm(&mut self, cx: &mut ViewContext<Self>) {
240        if let Some(m) = self.matches.get(self.selected_index()) {
241            cx.emit(Event::Selected(ProjectPath {
242                worktree_id: WorktreeId::from_usize(m.worktree_id),
243                path: m.path.clone(),
244            }));
245        }
246    }
247
248    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
249        cx.emit(Event::Dismissed);
250    }
251
252    fn render_match(
253        &self,
254        ix: usize,
255        mouse_state: &mut MouseState,
256        selected: bool,
257        cx: &AppContext,
258    ) -> ElementBox {
259        let path_match = &self.matches[ix];
260        let settings = cx.global::<Settings>();
261        let style = settings.theme.picker.item.style_for(mouse_state, selected);
262        let (file_name, file_name_positions, full_path, full_path_positions) =
263            self.labels_for_match(path_match);
264        Flex::column()
265            .with_child(
266                Label::new(file_name, style.label.clone())
267                    .with_highlights(file_name_positions)
268                    .boxed(),
269            )
270            .with_child(
271                Label::new(full_path, style.label.clone())
272                    .with_highlights(full_path_positions)
273                    .boxed(),
274            )
275            .flex(1., false)
276            .contained()
277            .with_style(style.container)
278            .named("match")
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use editor::Editor;
286    use menu::{Confirm, SelectNext};
287    use serde_json::json;
288    use workspace::{AppState, Workspace};
289
290    #[ctor::ctor]
291    fn init_logger() {
292        if std::env::var("RUST_LOG").is_ok() {
293            env_logger::init();
294        }
295    }
296
297    #[gpui::test]
298    async fn test_matching_paths(cx: &mut gpui::TestAppContext) {
299        let app_state = cx.update(|cx| {
300            super::init(cx);
301            editor::init(cx);
302            AppState::test(cx)
303        });
304
305        app_state
306            .fs
307            .as_fake()
308            .insert_tree(
309                "/root",
310                json!({
311                    "a": {
312                        "banana": "",
313                        "bandana": "",
314                    }
315                }),
316            )
317            .await;
318
319        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
320        let (window_id, workspace) = cx.add_window(|cx| {
321            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
322        });
323        cx.dispatch_action(window_id, Toggle);
324
325        let finder = cx.read(|cx| workspace.read(cx).modal::<FileFinder>().unwrap());
326        finder
327            .update(cx, |finder, cx| {
328                finder.update_matches("bna".to_string(), cx)
329            })
330            .await;
331        finder.read_with(cx, |finder, _| {
332            assert_eq!(finder.matches.len(), 2);
333        });
334
335        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
336        cx.dispatch_action(window_id, SelectNext);
337        cx.dispatch_action(window_id, Confirm);
338        active_pane
339            .condition(cx, |pane, _| pane.active_item().is_some())
340            .await;
341        cx.read(|cx| {
342            let active_item = active_pane.read(cx).active_item().unwrap();
343            assert_eq!(
344                active_item
345                    .to_any()
346                    .downcast::<Editor>()
347                    .unwrap()
348                    .read(cx)
349                    .title(cx),
350                "bandana"
351            );
352        });
353    }
354
355    #[gpui::test]
356    async fn test_matching_cancellation(cx: &mut gpui::TestAppContext) {
357        let app_state = cx.update(AppState::test);
358        app_state
359            .fs
360            .as_fake()
361            .insert_tree(
362                "/dir",
363                json!({
364                    "hello": "",
365                    "goodbye": "",
366                    "halogen-light": "",
367                    "happiness": "",
368                    "height": "",
369                    "hi": "",
370                    "hiccup": "",
371                }),
372            )
373            .await;
374
375        let project = Project::test(app_state.fs.clone(), ["/dir".as_ref()], cx).await;
376        let (_, workspace) = cx.add_window(|cx| {
377            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
378        });
379        let (_, finder) =
380            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
381
382        let query = "hi".to_string();
383        finder
384            .update(cx, |f, cx| f.spawn_search(query.clone(), cx))
385            .await;
386        finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 5));
387
388        finder.update(cx, |finder, cx| {
389            let matches = finder.matches.clone();
390
391            // Simulate a search being cancelled after the time limit,
392            // returning only a subset of the matches that would have been found.
393            drop(finder.spawn_search(query.clone(), cx));
394            finder.set_matches(
395                finder.latest_search_id,
396                true, // did-cancel
397                query.clone(),
398                vec![matches[1].clone(), matches[3].clone()],
399                cx,
400            );
401
402            // Simulate another cancellation.
403            drop(finder.spawn_search(query.clone(), cx));
404            finder.set_matches(
405                finder.latest_search_id,
406                true, // did-cancel
407                query.clone(),
408                vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
409                cx,
410            );
411
412            assert_eq!(finder.matches, matches[0..4])
413        });
414    }
415
416    #[gpui::test]
417    async fn test_ignored_files(cx: &mut gpui::TestAppContext) {
418        let app_state = cx.update(AppState::test);
419        app_state
420            .fs
421            .as_fake()
422            .insert_tree(
423                "/ancestor",
424                json!({
425                    ".gitignore": "ignored-root",
426                    "ignored-root": {
427                        "happiness": "",
428                        "height": "",
429                        "hi": "",
430                        "hiccup": "",
431                    },
432                    "tracked-root": {
433                        ".gitignore": "height",
434                        "happiness": "",
435                        "height": "",
436                        "hi": "",
437                        "hiccup": "",
438                    },
439                }),
440            )
441            .await;
442
443        let project = Project::test(
444            app_state.fs.clone(),
445            [
446                "/ancestor/tracked-root".as_ref(),
447                "/ancestor/ignored-root".as_ref(),
448            ],
449            cx,
450        )
451        .await;
452        let (_, workspace) = cx.add_window(|cx| {
453            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
454        });
455        let (_, finder) =
456            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
457        finder
458            .update(cx, |f, cx| f.spawn_search("hi".into(), cx))
459            .await;
460        finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 7));
461    }
462
463    #[gpui::test]
464    async fn test_single_file_worktrees(cx: &mut gpui::TestAppContext) {
465        let app_state = cx.update(AppState::test);
466        app_state
467            .fs
468            .as_fake()
469            .insert_tree("/root", json!({ "the-parent-dir": { "the-file": "" } }))
470            .await;
471
472        let project = Project::test(
473            app_state.fs.clone(),
474            ["/root/the-parent-dir/the-file".as_ref()],
475            cx,
476        )
477        .await;
478        let (_, workspace) = cx.add_window(|cx| {
479            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
480        });
481        let (_, finder) =
482            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
483
484        // Even though there is only one worktree, that worktree's filename
485        // is included in the matching, because the worktree is a single file.
486        finder
487            .update(cx, |f, cx| f.spawn_search("thf".into(), cx))
488            .await;
489        cx.read(|cx| {
490            let finder = finder.read(cx);
491            assert_eq!(finder.matches.len(), 1);
492
493            let (file_name, file_name_positions, full_path, full_path_positions) =
494                finder.labels_for_match(&finder.matches[0]);
495            assert_eq!(file_name, "the-file");
496            assert_eq!(file_name_positions, &[0, 1, 4]);
497            assert_eq!(full_path, "the-file");
498            assert_eq!(full_path_positions, &[0, 1, 4]);
499        });
500
501        // Since the worktree root is a file, searching for its name followed by a slash does
502        // not match anything.
503        finder
504            .update(cx, |f, cx| f.spawn_search("thf/".into(), cx))
505            .await;
506        finder.read_with(cx, |f, _| assert_eq!(f.matches.len(), 0));
507    }
508
509    #[gpui::test]
510    async fn test_multiple_matches_with_same_relative_path(cx: &mut gpui::TestAppContext) {
511        cx.foreground().forbid_parking();
512
513        let app_state = cx.update(AppState::test);
514        app_state
515            .fs
516            .as_fake()
517            .insert_tree(
518                "/root",
519                json!({
520                    "dir1": { "a.txt": "" },
521                    "dir2": { "a.txt": "" }
522                }),
523            )
524            .await;
525
526        let project = Project::test(
527            app_state.fs.clone(),
528            ["/root/dir1".as_ref(), "/root/dir2".as_ref()],
529            cx,
530        )
531        .await;
532        let (_, workspace) = cx.add_window(|cx| {
533            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
534        });
535        let (_, finder) =
536            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
537
538        // Run a search that matches two files with the same relative path.
539        finder
540            .update(cx, |f, cx| f.spawn_search("a.t".into(), cx))
541            .await;
542
543        // Can switch between different matches with the same relative path.
544        finder.update(cx, |f, cx| {
545            assert_eq!(f.matches.len(), 2);
546            assert_eq!(f.selected_index(), 0);
547            f.set_selected_index(1, cx);
548            assert_eq!(f.selected_index(), 1);
549            f.set_selected_index(0, cx);
550            assert_eq!(f.selected_index(), 0);
551        });
552    }
553
554    #[gpui::test]
555    async fn test_search_worktree_without_files(cx: &mut gpui::TestAppContext) {
556        let app_state = cx.update(AppState::test);
557        app_state
558            .fs
559            .as_fake()
560            .insert_tree(
561                "/root",
562                json!({
563                    "dir1": {},
564                    "dir2": {
565                        "dir3": {}
566                    }
567                }),
568            )
569            .await;
570
571        let project = Project::test(app_state.fs.clone(), ["/root".as_ref()], cx).await;
572        let (_, workspace) = cx.add_window(|cx| {
573            Workspace::new(Default::default(), 0, project, |_, _| unimplemented!(), cx)
574        });
575        let (_, finder) =
576            cx.add_window(|cx| FileFinder::new(workspace.read(cx).project().clone(), cx));
577        finder
578            .update(cx, |f, cx| f.spawn_search("dir".into(), cx))
579            .await;
580        cx.read(|cx| {
581            let finder = finder.read(cx);
582            assert_eq!(finder.matches.len(), 0);
583        });
584    }
585}