outline.rs

  1use editor::{
  2    actions::ToggleOutline, scroll::Autoscroll, Anchor, AnchorRangeExt, Editor, EditorMode,
  3};
  4use fuzzy::StringMatch;
  5use gpui::{
  6    div, rems, AppContext, DismissEvent, EventEmitter, FocusHandle, FocusableView, ParentElement,
  7    Point, Render, Styled, Task, View, ViewContext, VisualContext, WeakView, WindowContext,
  8};
  9use language::Outline;
 10use ordered_float::OrderedFloat;
 11use picker::{Picker, PickerDelegate};
 12use std::{
 13    cmp::{self, Reverse},
 14    sync::Arc,
 15};
 16
 17use theme::ActiveTheme;
 18use ui::{prelude::*, ListItem, ListItemSpacing};
 19use util::ResultExt;
 20use workspace::{DismissDecision, ModalView};
 21
 22pub fn init(cx: &mut AppContext) {
 23    cx.observe_new_views(OutlineView::register).detach();
 24}
 25
 26pub fn toggle(editor: View<Editor>, _: &ToggleOutline, cx: &mut WindowContext) {
 27    let outline = editor
 28        .read(cx)
 29        .buffer()
 30        .read(cx)
 31        .snapshot(cx)
 32        .outline(Some(cx.theme().syntax()));
 33
 34    if let Some((workspace, outline)) = editor.read(cx).workspace().zip(outline) {
 35        workspace.update(cx, |workspace, cx| {
 36            workspace.toggle_modal(cx, |cx| OutlineView::new(outline, editor, cx));
 37        })
 38    }
 39}
 40
 41pub struct OutlineView {
 42    picker: View<Picker<OutlineViewDelegate>>,
 43}
 44
 45impl FocusableView for OutlineView {
 46    fn focus_handle(&self, cx: &AppContext) -> FocusHandle {
 47        self.picker.focus_handle(cx)
 48    }
 49}
 50
 51impl EventEmitter<DismissEvent> for OutlineView {}
 52impl ModalView for OutlineView {
 53    fn on_before_dismiss(&mut self, cx: &mut ViewContext<Self>) -> DismissDecision {
 54        self.picker
 55            .update(cx, |picker, cx| picker.delegate.restore_active_editor(cx));
 56        DismissDecision::Dismiss(true)
 57    }
 58}
 59
 60impl Render for OutlineView {
 61    fn render(&mut self, _cx: &mut ViewContext<Self>) -> impl IntoElement {
 62        v_flex().w(rems(34.)).child(self.picker.clone())
 63    }
 64}
 65
 66impl OutlineView {
 67    fn register(editor: &mut Editor, cx: &mut ViewContext<Editor>) {
 68        if editor.mode() == EditorMode::Full {
 69            let handle = cx.view().downgrade();
 70            editor
 71                .register_action(move |action, cx| {
 72                    if let Some(editor) = handle.upgrade() {
 73                        toggle(editor, action, cx);
 74                    }
 75                })
 76                .detach();
 77        }
 78    }
 79
 80    fn new(
 81        outline: Outline<Anchor>,
 82        editor: View<Editor>,
 83        cx: &mut ViewContext<Self>,
 84    ) -> OutlineView {
 85        let delegate = OutlineViewDelegate::new(cx.view().downgrade(), outline, editor, cx);
 86        let picker =
 87            cx.new_view(|cx| Picker::uniform_list(delegate, cx).max_height(Some(vh(0.75, cx))));
 88        OutlineView { picker }
 89    }
 90}
 91
 92struct OutlineViewDelegate {
 93    outline_view: WeakView<OutlineView>,
 94    active_editor: View<Editor>,
 95    outline: Outline<Anchor>,
 96    selected_match_index: usize,
 97    prev_scroll_position: Option<Point<f32>>,
 98    matches: Vec<StringMatch>,
 99    last_query: String,
100}
101
102enum OutlineRowHighlights {}
103
104impl OutlineViewDelegate {
105    fn new(
106        outline_view: WeakView<OutlineView>,
107        outline: Outline<Anchor>,
108        editor: View<Editor>,
109        cx: &mut ViewContext<OutlineView>,
110    ) -> Self {
111        Self {
112            outline_view,
113            last_query: Default::default(),
114            matches: Default::default(),
115            selected_match_index: 0,
116            prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))),
117            active_editor: editor,
118            outline,
119        }
120    }
121
122    fn restore_active_editor(&mut self, cx: &mut WindowContext) {
123        self.active_editor.update(cx, |editor, cx| {
124            editor.clear_row_highlights::<OutlineRowHighlights>();
125            if let Some(scroll_position) = self.prev_scroll_position {
126                editor.set_scroll_position(scroll_position, cx);
127            }
128        })
129    }
130
131    fn set_selected_index(
132        &mut self,
133        ix: usize,
134        navigate: bool,
135        cx: &mut ViewContext<Picker<OutlineViewDelegate>>,
136    ) {
137        self.selected_match_index = ix;
138
139        if navigate && !self.matches.is_empty() {
140            let selected_match = &self.matches[self.selected_match_index];
141            let outline_item = &self.outline.items[selected_match.candidate_id];
142
143            self.active_editor.update(cx, |active_editor, cx| {
144                active_editor.clear_row_highlights::<OutlineRowHighlights>();
145                active_editor.highlight_rows::<OutlineRowHighlights>(
146                    outline_item.range.start..outline_item.range.end,
147                    cx.theme().colors().editor_highlighted_line_background,
148                    true,
149                    cx,
150                );
151                active_editor.request_autoscroll(Autoscroll::center(), cx);
152            });
153        }
154    }
155}
156
157impl PickerDelegate for OutlineViewDelegate {
158    type ListItem = ListItem;
159
160    fn placeholder_text(&self, _cx: &mut WindowContext) -> Arc<str> {
161        "Search buffer symbols...".into()
162    }
163
164    fn match_count(&self) -> usize {
165        self.matches.len()
166    }
167
168    fn selected_index(&self) -> usize {
169        self.selected_match_index
170    }
171
172    fn set_selected_index(&mut self, ix: usize, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
173        self.set_selected_index(ix, true, cx);
174    }
175
176    fn update_matches(
177        &mut self,
178        query: String,
179        cx: &mut ViewContext<Picker<OutlineViewDelegate>>,
180    ) -> Task<()> {
181        let selected_index;
182        if query.is_empty() {
183            self.restore_active_editor(cx);
184            self.matches = self
185                .outline
186                .items
187                .iter()
188                .enumerate()
189                .map(|(index, _)| StringMatch {
190                    candidate_id: index,
191                    score: Default::default(),
192                    positions: Default::default(),
193                    string: Default::default(),
194                })
195                .collect();
196
197            let (buffer, cursor_offset) = self.active_editor.update(cx, |editor, cx| {
198                let buffer = editor.buffer().read(cx).snapshot(cx);
199                let cursor_offset = editor.selections.newest::<usize>(cx).head();
200                (buffer, cursor_offset)
201            });
202            selected_index = self
203                .outline
204                .items
205                .iter()
206                .enumerate()
207                .map(|(ix, item)| {
208                    let range = item.range.to_offset(&buffer);
209                    let distance_to_closest_endpoint = cmp::min(
210                        (range.start as isize - cursor_offset as isize).abs(),
211                        (range.end as isize - cursor_offset as isize).abs(),
212                    );
213                    let depth = if range.contains(&cursor_offset) {
214                        Some(item.depth)
215                    } else {
216                        None
217                    };
218                    (ix, depth, distance_to_closest_endpoint)
219                })
220                .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
221                .map(|(ix, _, _)| ix)
222                .unwrap_or(0);
223        } else {
224            self.matches = smol::block_on(
225                self.outline
226                    .search(&query, cx.background_executor().clone()),
227            );
228            selected_index = self
229                .matches
230                .iter()
231                .enumerate()
232                .max_by_key(|(_, m)| OrderedFloat(m.score))
233                .map(|(ix, _)| ix)
234                .unwrap_or(0);
235        }
236        self.last_query = query;
237        self.set_selected_index(selected_index, !self.last_query.is_empty(), cx);
238        Task::ready(())
239    }
240
241    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
242        self.prev_scroll_position.take();
243
244        self.active_editor.update(cx, |active_editor, cx| {
245            let highlight = active_editor
246                .highlighted_rows::<OutlineRowHighlights>()
247                .next();
248            if let Some((rows, _)) = highlight {
249                active_editor.change_selections(Some(Autoscroll::center()), cx, |s| {
250                    s.select_ranges([rows.start..rows.start])
251                });
252                active_editor.clear_row_highlights::<OutlineRowHighlights>();
253                active_editor.focus(cx);
254            }
255        });
256
257        self.dismissed(cx);
258    }
259
260    fn dismissed(&mut self, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
261        self.outline_view
262            .update(cx, |_, cx| cx.emit(DismissEvent))
263            .log_err();
264        self.restore_active_editor(cx);
265    }
266
267    fn render_match(
268        &self,
269        ix: usize,
270        selected: bool,
271        cx: &mut ViewContext<Picker<Self>>,
272    ) -> Option<Self::ListItem> {
273        let mat = self.matches.get(ix)?;
274        let outline_item = self.outline.items.get(mat.candidate_id)?;
275
276        Some(
277            ListItem::new(ix)
278                .inset(true)
279                .spacing(ListItemSpacing::Sparse)
280                .selected(selected)
281                .child(
282                    div()
283                        .text_ui(cx)
284                        .pl(rems(outline_item.depth as f32))
285                        .child(language::render_item(outline_item, mat.ranges(), cx)),
286                ),
287        )
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use gpui::{TestAppContext, VisualTestContext};
295    use indoc::indoc;
296    use language::{Language, LanguageConfig, LanguageMatcher};
297    use project::{FakeFs, Project};
298    use serde_json::json;
299    use workspace::{AppState, Workspace};
300
301    #[gpui::test]
302    async fn test_outline_view_row_highlights(cx: &mut TestAppContext) {
303        init_test(cx);
304        let fs = FakeFs::new(cx.executor());
305        fs.insert_tree(
306            "/dir",
307            json!({
308                "a.rs": indoc!{"
309                    struct SingleLine; // display line 0
310                                       // display line 1
311                    struct MultiLine { // display line 2
312                        field_1: i32,  // display line 3
313                        field_2: i32,  // display line 4
314                    }                  // display line 5
315                "}
316            }),
317        )
318        .await;
319
320        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
321        project.read_with(cx, |project, _| project.languages().add(rust_lang()));
322
323        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
324        let worktree_id = workspace.update(cx, |workspace, cx| {
325            workspace.project().update(cx, |project, cx| {
326                project.worktrees(cx).next().unwrap().read(cx).id()
327            })
328        });
329        let _buffer = project
330            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
331            .await
332            .unwrap();
333        let editor = workspace
334            .update(cx, |workspace, cx| {
335                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
336            })
337            .await
338            .unwrap()
339            .downcast::<Editor>()
340            .unwrap();
341        let ensure_outline_view_contents =
342            |outline_view: &View<Picker<OutlineViewDelegate>>, cx: &mut VisualTestContext| {
343                assert_eq!(query(outline_view, cx), "");
344                assert_eq!(
345                    outline_names(outline_view, cx),
346                    vec![
347                        "struct SingleLine",
348                        "struct MultiLine",
349                        "field_1",
350                        "field_2"
351                    ],
352                );
353            };
354
355        let outline_view = open_outline_view(&workspace, cx);
356        ensure_outline_view_contents(&outline_view, cx);
357        assert_eq!(
358            highlighted_display_rows(&editor, cx),
359            Vec::<u32>::new(),
360            "Initially opened outline view should have no highlights"
361        );
362        assert_single_caret_at_row(&editor, 0, cx);
363
364        cx.dispatch_action(menu::SelectNext);
365        ensure_outline_view_contents(&outline_view, cx);
366        assert_eq!(
367            highlighted_display_rows(&editor, cx),
368            vec![2, 3, 4, 5],
369            "Second struct's rows should be highlighted"
370        );
371        assert_single_caret_at_row(&editor, 0, cx);
372
373        cx.dispatch_action(menu::SelectPrev);
374        ensure_outline_view_contents(&outline_view, cx);
375        assert_eq!(
376            highlighted_display_rows(&editor, cx),
377            vec![0],
378            "First struct's row should be highlighted"
379        );
380        assert_single_caret_at_row(&editor, 0, cx);
381
382        cx.dispatch_action(menu::Cancel);
383        ensure_outline_view_contents(&outline_view, cx);
384        assert_eq!(
385            highlighted_display_rows(&editor, cx),
386            Vec::<u32>::new(),
387            "No rows should be highlighted after outline view is cancelled and closed"
388        );
389        assert_single_caret_at_row(&editor, 0, cx);
390
391        let outline_view = open_outline_view(&workspace, cx);
392        ensure_outline_view_contents(&outline_view, cx);
393        assert_eq!(
394            highlighted_display_rows(&editor, cx),
395            Vec::<u32>::new(),
396            "Reopened outline view should have no highlights"
397        );
398        assert_single_caret_at_row(&editor, 0, cx);
399
400        let expected_first_highlighted_row = 2;
401        cx.dispatch_action(menu::SelectNext);
402        ensure_outline_view_contents(&outline_view, cx);
403        assert_eq!(
404            highlighted_display_rows(&editor, cx),
405            vec![expected_first_highlighted_row, 3, 4, 5]
406        );
407        assert_single_caret_at_row(&editor, 0, cx);
408        cx.dispatch_action(menu::Confirm);
409        ensure_outline_view_contents(&outline_view, cx);
410        assert_eq!(
411            highlighted_display_rows(&editor, cx),
412            Vec::<u32>::new(),
413            "No rows should be highlighted after outline view is confirmed and closed"
414        );
415        // On confirm, should place the caret on the first row of the highlighted rows range.
416        assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx);
417    }
418
419    fn open_outline_view(
420        workspace: &View<Workspace>,
421        cx: &mut VisualTestContext,
422    ) -> View<Picker<OutlineViewDelegate>> {
423        cx.dispatch_action(ToggleOutline);
424        workspace.update(cx, |workspace, cx| {
425            workspace
426                .active_modal::<OutlineView>(cx)
427                .unwrap()
428                .read(cx)
429                .picker
430                .clone()
431        })
432    }
433
434    fn query(
435        outline_view: &View<Picker<OutlineViewDelegate>>,
436        cx: &mut VisualTestContext,
437    ) -> String {
438        outline_view.update(cx, |outline_view, cx| outline_view.query(cx))
439    }
440
441    fn outline_names(
442        outline_view: &View<Picker<OutlineViewDelegate>>,
443        cx: &mut VisualTestContext,
444    ) -> Vec<String> {
445        outline_view.update(cx, |outline_view, _| {
446            let items = &outline_view.delegate.outline.items;
447            outline_view
448                .delegate
449                .matches
450                .iter()
451                .map(|hit| items[hit.candidate_id].text.clone())
452                .collect::<Vec<_>>()
453        })
454    }
455
456    fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
457        editor.update(cx, |editor, cx| {
458            editor
459                .highlighted_display_rows(cx)
460                .into_keys()
461                .map(|r| r.0)
462                .collect()
463        })
464    }
465
466    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
467        cx.update(|cx| {
468            let state = AppState::test(cx);
469            language::init(cx);
470            crate::init(cx);
471            editor::init(cx);
472            workspace::init_settings(cx);
473            Project::init_settings(cx);
474            state
475        })
476    }
477
478    fn rust_lang() -> Arc<Language> {
479        Arc::new(
480            Language::new(
481                LanguageConfig {
482                    name: "Rust".into(),
483                    matcher: LanguageMatcher {
484                        path_suffixes: vec!["rs".to_string()],
485                        ..Default::default()
486                    },
487                    ..Default::default()
488                },
489                Some(tree_sitter_rust::LANGUAGE.into()),
490            )
491            .with_outline_query(
492                r#"(struct_item
493            (visibility_modifier)? @context
494            "struct" @context
495            name: (_) @name) @item
496
497        (enum_item
498            (visibility_modifier)? @context
499            "enum" @context
500            name: (_) @name) @item
501
502        (enum_variant
503            (visibility_modifier)? @context
504            name: (_) @name) @item
505
506        (impl_item
507            "impl" @context
508            trait: (_)? @name
509            "for"? @context
510            type: (_) @name) @item
511
512        (trait_item
513            (visibility_modifier)? @context
514            "trait" @context
515            name: (_) @name) @item
516
517        (function_item
518            (visibility_modifier)? @context
519            (function_modifiers)? @context
520            "fn" @context
521            name: (_) @name) @item
522
523        (function_signature_item
524            (visibility_modifier)? @context
525            (function_modifiers)? @context
526            "fn" @context
527            name: (_) @name) @item
528
529        (macro_definition
530            . "macro_rules!" @context
531            name: (_) @name) @item
532
533        (mod_item
534            (visibility_modifier)? @context
535            "mod" @context
536            name: (_) @name) @item
537
538        (type_item
539            (visibility_modifier)? @context
540            "type" @context
541            name: (_) @name) @item
542
543        (associated_type
544            "type" @context
545            name: (_) @name) @item
546
547        (const_item
548            (visibility_modifier)? @context
549            "const" @context
550            name: (_) @name) @item
551
552        (field_declaration
553            (visibility_modifier)? @context
554            name: (_) @name) @item
555"#,
556            )
557            .unwrap(),
558        )
559    }
560
561    #[track_caller]
562    fn assert_single_caret_at_row(
563        editor: &View<Editor>,
564        buffer_row: u32,
565        cx: &mut VisualTestContext,
566    ) {
567        let selections = editor.update(cx, |editor, cx| {
568            editor
569                .selections
570                .all::<rope::Point>(cx)
571                .into_iter()
572                .map(|s| s.start..s.end)
573                .collect::<Vec<_>>()
574        });
575        assert!(
576            selections.len() == 1,
577            "Expected one caret selection but got: {selections:?}"
578        );
579        let selection = &selections[0];
580        assert!(
581            selection.start == selection.end,
582            "Expected a single caret selection, but got: {selection:?}"
583        );
584        assert_eq!(selection.start.row, buffer_row);
585    }
586}