file_finder.rs

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