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