file_finder.rs

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