file_finder.rs

  1use crate::{
  2    editor::{self, Editor},
  3    settings::Settings,
  4    util,
  5    workspace::Workspace,
  6    worktree::{match_paths, PathMatch, Worktree},
  7};
  8use gpui::{
  9    color::{ColorF, ColorU},
 10    elements::*,
 11    fonts::{Properties, Weight},
 12    geometry::vector::vec2f,
 13    keymap::{self, Binding},
 14    AppContext, Axis, Border, Entity, MutableAppContext, Task, View, ViewContext, ViewHandle,
 15    WeakViewHandle,
 16};
 17use postage::watch;
 18use std::{
 19    cmp,
 20    path::Path,
 21    sync::{
 22        atomic::{self, AtomicBool},
 23        Arc,
 24    },
 25};
 26
 27pub struct FileFinder {
 28    handle: WeakViewHandle<Self>,
 29    settings: watch::Receiver<Settings>,
 30    workspace: WeakViewHandle<Workspace>,
 31    query_buffer: ViewHandle<Editor>,
 32    search_count: usize,
 33    latest_search_id: usize,
 34    latest_search_did_cancel: bool,
 35    latest_search_query: String,
 36    matches: Vec<PathMatch>,
 37    selected: Option<(usize, Arc<Path>)>,
 38    cancel_flag: Arc<AtomicBool>,
 39    list_state: UniformListState,
 40}
 41
 42pub fn init(cx: &mut MutableAppContext) {
 43    cx.add_action("file_finder:toggle", FileFinder::toggle);
 44    cx.add_action("file_finder:confirm", FileFinder::confirm);
 45    cx.add_action("file_finder:select", FileFinder::select);
 46    cx.add_action("menu:select_prev", FileFinder::select_prev);
 47    cx.add_action("menu:select_next", FileFinder::select_next);
 48    cx.add_action("uniform_list:scroll", FileFinder::scroll);
 49
 50    cx.add_bindings(vec![
 51        Binding::new("cmd-p", "file_finder:toggle", None),
 52        Binding::new("escape", "file_finder:toggle", Some("FileFinder")),
 53        Binding::new("enter", "file_finder:confirm", Some("FileFinder")),
 54    ]);
 55}
 56
 57pub enum Event {
 58    Selected(usize, Arc<Path>),
 59    Dismissed,
 60}
 61
 62impl Entity for FileFinder {
 63    type Event = Event;
 64}
 65
 66impl View for FileFinder {
 67    fn ui_name() -> &'static str {
 68        "FileFinder"
 69    }
 70
 71    fn render(&self, _: &AppContext) -> ElementBox {
 72        Align::new(
 73            ConstrainedBox::new(
 74                Container::new(
 75                    Flex::new(Axis::Vertical)
 76                        .with_child(ChildView::new(self.query_buffer.id()).boxed())
 77                        .with_child(Expanded::new(1.0, self.render_matches()).boxed())
 78                        .boxed(),
 79                )
 80                .with_margin_top(12.0)
 81                .with_uniform_padding(6.0)
 82                .with_corner_radius(6.0)
 83                .with_background_color(ColorU::from_u32(0xf2f2f2ff))
 84                .with_shadow(vec2f(0., 4.), 12., ColorF::new(0.0, 0.0, 0.0, 0.25).to_u8())
 85                .boxed(),
 86            )
 87            .with_max_width(600.0)
 88            .with_max_height(400.0)
 89            .boxed(),
 90        )
 91        .top()
 92        .named("file finder")
 93    }
 94
 95    fn on_focus(&mut self, cx: &mut ViewContext<Self>) {
 96        cx.focus(&self.query_buffer);
 97    }
 98
 99    fn keymap_context(&self, _: &AppContext) -> keymap::Context {
100        let mut cx = Self::default_keymap_context();
101        cx.set.insert("menu".into());
102        cx
103    }
104}
105
106impl FileFinder {
107    fn render_matches(&self) -> ElementBox {
108        if self.matches.is_empty() {
109            let settings = self.settings.borrow();
110            return Container::new(
111                Label::new(
112                    "No matches".into(),
113                    settings.ui_font_family,
114                    settings.ui_font_size,
115                )
116                .boxed(),
117            )
118            .with_margin_top(6.0)
119            .named("empty matches");
120        }
121
122        let handle = self.handle.clone();
123        let list = UniformList::new(
124            self.list_state.clone(),
125            self.matches.len(),
126            move |mut range, items, cx| {
127                let finder = handle.upgrade(cx).unwrap();
128                let finder = finder.read(cx);
129                let start = range.start;
130                range.end = cmp::min(range.end, finder.matches.len());
131                items.extend(finder.matches[range].iter().enumerate().filter_map(
132                    move |(i, path_match)| finder.render_match(path_match, start + i, cx),
133                ));
134            },
135        );
136
137        Container::new(list.boxed())
138            .with_background_color(ColorU::from_u32(0xf7f7f7ff))
139            .with_border(Border::all(1.0, ColorU::from_u32(0xdbdbdcff)))
140            .with_margin_top(6.0)
141            .named("matches")
142    }
143
144    fn render_match(
145        &self,
146        path_match: &PathMatch,
147        index: usize,
148        cx: &AppContext,
149    ) -> Option<ElementBox> {
150        self.labels_for_match(path_match, cx).map(
151            |(file_name, file_name_positions, full_path, full_path_positions)| {
152                let settings = self.settings.borrow();
153                let highlight_color = ColorU::from_u32(0x304ee2ff);
154                let bold = *Properties::new().weight(Weight::BOLD);
155                let mut container = Container::new(
156                    Flex::row()
157                        .with_child(
158                            Container::new(
159                                LineBox::new(
160                                    settings.ui_font_family,
161                                    settings.ui_font_size,
162                                    Svg::new("icons/file-16.svg").boxed(),
163                                )
164                                .boxed(),
165                            )
166                            .with_padding_right(6.0)
167                            .boxed(),
168                        )
169                        .with_child(
170                            Expanded::new(
171                                1.0,
172                                Flex::column()
173                                    .with_child(
174                                        Label::new(
175                                            file_name.to_string(),
176                                            settings.ui_font_family,
177                                            settings.ui_font_size,
178                                        )
179                                        .with_highlights(highlight_color, bold, file_name_positions)
180                                        .boxed(),
181                                    )
182                                    .with_child(
183                                        Label::new(
184                                            full_path,
185                                            settings.ui_font_family,
186                                            settings.ui_font_size,
187                                        )
188                                        .with_highlights(highlight_color, bold, full_path_positions)
189                                        .boxed(),
190                                    )
191                                    .boxed(),
192                            )
193                            .boxed(),
194                        )
195                        .boxed(),
196                )
197                .with_uniform_padding(6.0);
198
199                let selected_index = self.selected_index();
200                if index == selected_index || index < self.matches.len() - 1 {
201                    container =
202                        container.with_border(Border::bottom(1.0, ColorU::from_u32(0xdbdbdcff)));
203                }
204
205                if index == selected_index {
206                    container = container.with_background_color(ColorU::from_u32(0xdbdbdcff));
207                }
208
209                let entry = (path_match.tree_id, path_match.path.clone());
210                EventHandler::new(container.boxed())
211                    .on_mouse_down(move |cx| {
212                        cx.dispatch_action("file_finder:select", entry.clone());
213                        true
214                    })
215                    .named("match")
216            },
217        )
218    }
219
220    fn labels_for_match(
221        &self,
222        path_match: &PathMatch,
223        cx: &AppContext,
224    ) -> Option<(String, Vec<usize>, String, Vec<usize>)> {
225        self.worktree(path_match.tree_id, cx).map(|tree| {
226            let prefix = if path_match.include_root_name {
227                tree.root_name()
228            } else {
229                ""
230            };
231
232            let path_string = path_match.path.to_string_lossy();
233            let full_path = [prefix, path_string.as_ref()].join("");
234            let path_positions = path_match.positions.clone();
235
236            let file_name = path_match.path.file_name().map_or_else(
237                || prefix.to_string(),
238                |file_name| file_name.to_string_lossy().to_string(),
239            );
240            let file_name_start =
241                prefix.chars().count() + path_string.chars().count() - file_name.chars().count();
242            let file_name_positions = path_positions
243                .iter()
244                .filter_map(|pos| {
245                    if pos >= &file_name_start {
246                        Some(pos - file_name_start)
247                    } else {
248                        None
249                    }
250                })
251                .collect();
252
253            (file_name, file_name_positions, full_path, path_positions)
254        })
255    }
256
257    fn toggle(workspace_view: &mut Workspace, _: &(), cx: &mut ViewContext<Workspace>) {
258        workspace_view.toggle_modal(cx, |cx, workspace_view| {
259            let workspace = cx.handle();
260            let finder =
261                cx.add_view(|cx| Self::new(workspace_view.settings.clone(), workspace, cx));
262            cx.subscribe_to_view(&finder, Self::on_event);
263            finder
264        });
265    }
266
267    fn on_event(
268        workspace_view: &mut Workspace,
269        _: ViewHandle<FileFinder>,
270        event: &Event,
271        cx: &mut ViewContext<Workspace>,
272    ) {
273        match event {
274            Event::Selected(tree_id, path) => {
275                workspace_view
276                    .open_entry((*tree_id, path.clone()), cx)
277                    .map(|d| d.detach());
278                workspace_view.dismiss_modal(cx);
279            }
280            Event::Dismissed => {
281                workspace_view.dismiss_modal(cx);
282            }
283        }
284    }
285
286    pub fn new(
287        settings: watch::Receiver<Settings>,
288        workspace: ViewHandle<Workspace>,
289        cx: &mut ViewContext<Self>,
290    ) -> Self {
291        cx.observe_view(&workspace, Self::workspace_updated);
292
293        let query_buffer = cx.add_view(|cx| Editor::single_line(settings.clone(), cx));
294        cx.subscribe_to_view(&query_buffer, Self::on_query_editor_event);
295
296        Self {
297            handle: cx.handle().downgrade(),
298            settings,
299            workspace: workspace.downgrade(),
300            query_buffer,
301            search_count: 0,
302            latest_search_id: 0,
303            latest_search_did_cancel: false,
304            latest_search_query: String::new(),
305            matches: Vec::new(),
306            selected: None,
307            cancel_flag: Arc::new(AtomicBool::new(false)),
308            list_state: UniformListState::new(),
309        }
310    }
311
312    fn workspace_updated(&mut self, _: ViewHandle<Workspace>, cx: &mut ViewContext<Self>) {
313        if let Some(task) = self.spawn_search(self.query_buffer.read(cx).text(cx.as_ref()), cx) {
314            task.detach();
315        }
316    }
317
318    fn on_query_editor_event(
319        &mut self,
320        _: ViewHandle<Editor>,
321        event: &editor::Event,
322        cx: &mut ViewContext<Self>,
323    ) {
324        match event {
325            editor::Event::Edited => {
326                let query = self.query_buffer.read(cx).text(cx.as_ref());
327                if query.is_empty() {
328                    self.latest_search_id = util::post_inc(&mut self.search_count);
329                    self.matches.clear();
330                    cx.notify();
331                } else {
332                    if let Some(task) = self.spawn_search(query, cx) {
333                        task.detach();
334                    }
335                }
336            }
337            editor::Event::Blurred => cx.emit(Event::Dismissed),
338            _ => {}
339        }
340    }
341
342    fn selected_index(&self) -> usize {
343        if let Some(selected) = self.selected.as_ref() {
344            for (ix, path_match) in self.matches.iter().enumerate() {
345                if (path_match.tree_id, path_match.path.as_ref())
346                    == (selected.0, selected.1.as_ref())
347                {
348                    return ix;
349                }
350            }
351        }
352        0
353    }
354
355    fn select_prev(&mut self, _: &(), cx: &mut ViewContext<Self>) {
356        let mut selected_index = self.selected_index();
357        if selected_index > 0 {
358            selected_index -= 1;
359            let mat = &self.matches[selected_index];
360            self.selected = Some((mat.tree_id, mat.path.clone()));
361        }
362        self.list_state.scroll_to(selected_index);
363        cx.notify();
364    }
365
366    fn select_next(&mut self, _: &(), cx: &mut ViewContext<Self>) {
367        let mut selected_index = self.selected_index();
368        if selected_index + 1 < self.matches.len() {
369            selected_index += 1;
370            let mat = &self.matches[selected_index];
371            self.selected = Some((mat.tree_id, mat.path.clone()));
372        }
373        self.list_state.scroll_to(selected_index);
374        cx.notify();
375    }
376
377    fn scroll(&mut self, _: &f32, cx: &mut ViewContext<Self>) {
378        cx.notify();
379    }
380
381    fn confirm(&mut self, _: &(), cx: &mut ViewContext<Self>) {
382        if let Some(m) = self.matches.get(self.selected_index()) {
383            cx.emit(Event::Selected(m.tree_id, m.path.clone()));
384        }
385    }
386
387    fn select(&mut self, (tree_id, path): &(usize, Arc<Path>), cx: &mut ViewContext<Self>) {
388        cx.emit(Event::Selected(*tree_id, path.clone()));
389    }
390
391    #[must_use]
392    fn spawn_search(&mut self, query: String, cx: &mut ViewContext<Self>) -> Option<Task<()>> {
393        let snapshots = self
394            .workspace
395            .upgrade(&cx)?
396            .read(cx)
397            .worktrees()
398            .iter()
399            .map(|tree| tree.read(cx).snapshot())
400            .collect::<Vec<_>>();
401        let search_id = util::post_inc(&mut self.search_count);
402        let pool = cx.as_ref().thread_pool().clone();
403        self.cancel_flag.store(true, atomic::Ordering::Relaxed);
404        self.cancel_flag = Arc::new(AtomicBool::new(false));
405        let cancel_flag = self.cancel_flag.clone();
406        let background_task = cx.background_executor().spawn(async move {
407            let include_root_name = snapshots.len() > 1;
408            let matches = match_paths(
409                snapshots.iter(),
410                &query,
411                include_root_name,
412                false,
413                false,
414                100,
415                cancel_flag.clone(),
416                pool,
417            );
418            let did_cancel = cancel_flag.load(atomic::Ordering::Relaxed);
419            (search_id, did_cancel, query, matches)
420        });
421
422        Some(cx.spawn(|this, mut cx| async move {
423            let matches = background_task.await;
424            this.update(&mut cx, |this, cx| this.update_matches(matches, cx));
425        }))
426    }
427
428    fn update_matches(
429        &mut self,
430        (search_id, did_cancel, query, matches): (usize, bool, String, Vec<PathMatch>),
431        cx: &mut ViewContext<Self>,
432    ) {
433        if search_id >= self.latest_search_id {
434            self.latest_search_id = search_id;
435            if self.latest_search_did_cancel && query == self.latest_search_query {
436                util::extend_sorted(&mut self.matches, matches.into_iter(), 100, |a, b| b.cmp(a));
437            } else {
438                self.matches = matches;
439            }
440            self.latest_search_query = query;
441            self.latest_search_did_cancel = did_cancel;
442            self.list_state.scroll_to(self.selected_index());
443            cx.notify();
444        }
445    }
446
447    fn worktree<'a>(&'a self, tree_id: usize, cx: &'a AppContext) -> Option<&'a Worktree> {
448        self.workspace
449            .upgrade(cx)?
450            .read(cx)
451            .worktrees()
452            .get(&tree_id)
453            .map(|worktree| worktree.read(cx))
454    }
455}
456
457#[cfg(test)]
458mod tests {
459    use super::*;
460    use crate::{
461        editor,
462        test::{build_app_state, temp_tree},
463        workspace::Workspace,
464    };
465    use serde_json::json;
466    use std::fs;
467    use tempdir::TempDir;
468
469    #[gpui::test]
470    async fn test_matching_paths(mut cx: gpui::TestAppContext) {
471        let tmp_dir = TempDir::new("example").unwrap();
472        fs::create_dir(tmp_dir.path().join("a")).unwrap();
473        fs::write(tmp_dir.path().join("a/banana"), "banana").unwrap();
474        fs::write(tmp_dir.path().join("a/bandana"), "bandana").unwrap();
475        cx.update(|cx| {
476            super::init(cx);
477            editor::init(cx);
478        });
479
480        let app_state = cx.read(build_app_state);
481        let (window_id, workspace) = cx.add_window(|cx| {
482            let mut workspace = Workspace::new(
483                app_state.settings,
484                app_state.language_registry,
485                app_state.rpc,
486                cx,
487            );
488            workspace.add_worktree(tmp_dir.path(), cx);
489            workspace
490        });
491        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
492            .await;
493        cx.dispatch_action(
494            window_id,
495            vec![workspace.id()],
496            "file_finder:toggle".into(),
497            (),
498        );
499
500        let finder = cx.read(|cx| {
501            workspace
502                .read(cx)
503                .modal()
504                .cloned()
505                .unwrap()
506                .downcast::<FileFinder>()
507                .unwrap()
508        });
509        let query_buffer = cx.read(|cx| finder.read(cx).query_buffer.clone());
510
511        let chain = vec![finder.id(), query_buffer.id()];
512        cx.dispatch_action(window_id, chain.clone(), "buffer:insert", "b".to_string());
513        cx.dispatch_action(window_id, chain.clone(), "buffer:insert", "n".to_string());
514        cx.dispatch_action(window_id, chain.clone(), "buffer:insert", "a".to_string());
515        finder
516            .condition(&cx, |finder, _| finder.matches.len() == 2)
517            .await;
518
519        let active_pane = cx.read(|cx| workspace.read(cx).active_pane().clone());
520        cx.dispatch_action(
521            window_id,
522            vec![workspace.id(), finder.id()],
523            "menu:select_next",
524            (),
525        );
526        cx.dispatch_action(
527            window_id,
528            vec![workspace.id(), finder.id()],
529            "file_finder:confirm",
530            (),
531        );
532        active_pane
533            .condition(&cx, |pane, _| pane.active_item().is_some())
534            .await;
535        cx.read(|cx| {
536            let active_item = active_pane.read(cx).active_item().unwrap();
537            assert_eq!(active_item.title(cx), "bandana");
538        });
539    }
540
541    #[gpui::test]
542    async fn test_matching_cancellation(mut cx: gpui::TestAppContext) {
543        let tmp_dir = temp_tree(json!({
544            "hello": "",
545            "goodbye": "",
546            "halogen-light": "",
547            "happiness": "",
548            "height": "",
549            "hi": "",
550            "hiccup": "",
551        }));
552        let app_state = cx.read(build_app_state);
553        let (_, workspace) = cx.add_window(|cx| {
554            let mut workspace = Workspace::new(
555                app_state.settings.clone(),
556                app_state.language_registry.clone(),
557                app_state.rpc.clone(),
558                cx,
559            );
560            workspace.add_worktree(tmp_dir.path(), cx);
561            workspace
562        });
563        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
564            .await;
565        let (_, finder) =
566            cx.add_window(|cx| FileFinder::new(app_state.settings, workspace.clone(), cx));
567
568        let query = "hi".to_string();
569        finder
570            .update(&mut cx, |f, cx| f.spawn_search(query.clone(), cx))
571            .unwrap()
572            .await;
573        finder.read_with(&cx, |f, _| assert_eq!(f.matches.len(), 5));
574
575        finder.update(&mut cx, |finder, cx| {
576            let matches = finder.matches.clone();
577
578            // Simulate a search being cancelled after the time limit,
579            // returning only a subset of the matches that would have been found.
580            finder.spawn_search(query.clone(), cx).unwrap().detach();
581            finder.update_matches(
582                (
583                    finder.latest_search_id,
584                    true, // did-cancel
585                    query.clone(),
586                    vec![matches[1].clone(), matches[3].clone()],
587                ),
588                cx,
589            );
590
591            // Simulate another cancellation.
592            finder.spawn_search(query.clone(), cx).unwrap().detach();
593            finder.update_matches(
594                (
595                    finder.latest_search_id,
596                    true, // did-cancel
597                    query.clone(),
598                    vec![matches[0].clone(), matches[2].clone(), matches[3].clone()],
599                ),
600                cx,
601            );
602
603            assert_eq!(finder.matches, matches[0..4])
604        });
605    }
606
607    #[gpui::test]
608    async fn test_single_file_worktrees(mut cx: gpui::TestAppContext) {
609        let temp_dir = TempDir::new("test-single-file-worktrees").unwrap();
610        let dir_path = temp_dir.path().join("the-parent-dir");
611        let file_path = dir_path.join("the-file");
612        fs::create_dir(&dir_path).unwrap();
613        fs::write(&file_path, "").unwrap();
614
615        let app_state = cx.read(build_app_state);
616        let (_, workspace) = cx.add_window(|cx| {
617            let mut workspace = Workspace::new(
618                app_state.settings.clone(),
619                app_state.language_registry.clone(),
620                app_state.rpc.clone(),
621                cx,
622            );
623            workspace.add_worktree(&file_path, cx);
624            workspace
625        });
626        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
627            .await;
628        let (_, finder) =
629            cx.add_window(|cx| FileFinder::new(app_state.settings, workspace.clone(), cx));
630
631        // Even though there is only one worktree, that worktree's filename
632        // is included in the matching, because the worktree is a single file.
633        finder
634            .update(&mut cx, |f, cx| f.spawn_search("thf".into(), cx))
635            .unwrap()
636            .await;
637        cx.read(|cx| {
638            let finder = finder.read(cx);
639            assert_eq!(finder.matches.len(), 1);
640
641            let (file_name, file_name_positions, full_path, full_path_positions) =
642                finder.labels_for_match(&finder.matches[0], cx).unwrap();
643            assert_eq!(file_name, "the-file");
644            assert_eq!(file_name_positions, &[0, 1, 4]);
645            assert_eq!(full_path, "the-file");
646            assert_eq!(full_path_positions, &[0, 1, 4]);
647        });
648
649        // Since the worktree root is a file, searching for its name followed by a slash does
650        // not match anything.
651        finder
652            .update(&mut cx, |f, cx| f.spawn_search("thf/".into(), cx))
653            .unwrap()
654            .await;
655        finder.read_with(&cx, |f, _| assert_eq!(f.matches.len(), 0));
656    }
657
658    #[gpui::test(retries = 5)]
659    async fn test_multiple_matches_with_same_relative_path(mut cx: gpui::TestAppContext) {
660        let tmp_dir = temp_tree(json!({
661            "dir1": { "a.txt": "" },
662            "dir2": { "a.txt": "" }
663        }));
664
665        let app_state = cx.read(build_app_state);
666
667        let (_, workspace) = cx.add_window(|cx| {
668            Workspace::new(
669                app_state.settings.clone(),
670                app_state.language_registry.clone(),
671                app_state.rpc.clone(),
672                cx,
673            )
674        });
675
676        workspace
677            .update(&mut cx, |workspace, cx| {
678                workspace.open_paths(
679                    &[tmp_dir.path().join("dir1"), tmp_dir.path().join("dir2")],
680                    cx,
681                )
682            })
683            .await;
684        cx.read(|cx| workspace.read(cx).worktree_scans_complete(cx))
685            .await;
686
687        let (_, finder) =
688            cx.add_window(|cx| FileFinder::new(app_state.settings, workspace.clone(), cx));
689
690        // Run a search that matches two files with the same relative path.
691        finder
692            .update(&mut cx, |f, cx| f.spawn_search("a.t".into(), cx))
693            .unwrap()
694            .await;
695
696        // Can switch between different matches with the same relative path.
697        finder.update(&mut cx, |f, cx| {
698            assert_eq!(f.matches.len(), 2);
699            assert_eq!(f.selected_index(), 0);
700            f.select_next(&(), cx);
701            assert_eq!(f.selected_index(), 1);
702            f.select_prev(&(), cx);
703            assert_eq!(f.selected_index(), 0);
704        });
705    }
706}