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 editor = self.active_editor.read(cx);
198            let cursor_offset = editor.selections.newest::<usize>(cx).head();
199            let buffer = editor.buffer().read(cx).snapshot(cx);
200            selected_index = self
201                .outline
202                .items
203                .iter()
204                .enumerate()
205                .map(|(ix, item)| {
206                    let range = item.range.to_offset(&buffer);
207                    let distance_to_closest_endpoint = cmp::min(
208                        (range.start as isize - cursor_offset as isize).abs(),
209                        (range.end as isize - cursor_offset as isize).abs(),
210                    );
211                    let depth = if range.contains(&cursor_offset) {
212                        Some(item.depth)
213                    } else {
214                        None
215                    };
216                    (ix, depth, distance_to_closest_endpoint)
217                })
218                .max_by_key(|(_, depth, distance)| (*depth, Reverse(*distance)))
219                .map(|(ix, _, _)| ix)
220                .unwrap_or(0);
221        } else {
222            self.matches = smol::block_on(
223                self.outline
224                    .search(&query, cx.background_executor().clone()),
225            );
226            selected_index = self
227                .matches
228                .iter()
229                .enumerate()
230                .max_by_key(|(_, m)| OrderedFloat(m.score))
231                .map(|(ix, _)| ix)
232                .unwrap_or(0);
233        }
234        self.last_query = query;
235        self.set_selected_index(selected_index, !self.last_query.is_empty(), cx);
236        Task::ready(())
237    }
238
239    fn confirm(&mut self, _: bool, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
240        self.prev_scroll_position.take();
241
242        self.active_editor.update(cx, |active_editor, cx| {
243            let highlight = active_editor
244                .highlighted_rows::<OutlineRowHighlights>()
245                .next();
246            if let Some((rows, _)) = highlight {
247                active_editor.change_selections(Some(Autoscroll::center()), cx, |s| {
248                    s.select_ranges([rows.start..rows.start])
249                });
250                active_editor.clear_row_highlights::<OutlineRowHighlights>();
251                active_editor.focus(cx);
252            }
253        });
254
255        self.dismissed(cx);
256    }
257
258    fn dismissed(&mut self, cx: &mut ViewContext<Picker<OutlineViewDelegate>>) {
259        self.outline_view
260            .update(cx, |_, cx| cx.emit(DismissEvent))
261            .log_err();
262        self.restore_active_editor(cx);
263    }
264
265    fn render_match(
266        &self,
267        ix: usize,
268        selected: bool,
269        cx: &mut ViewContext<Picker<Self>>,
270    ) -> Option<Self::ListItem> {
271        let mat = self.matches.get(ix)?;
272        let outline_item = self.outline.items.get(mat.candidate_id)?;
273
274        Some(
275            ListItem::new(ix)
276                .inset(true)
277                .spacing(ListItemSpacing::Sparse)
278                .selected(selected)
279                .child(
280                    div()
281                        .text_ui(cx)
282                        .pl(rems(outline_item.depth as f32))
283                        .child(language::render_item(outline_item, mat.ranges(), cx)),
284                ),
285        )
286    }
287}
288
289#[cfg(test)]
290mod tests {
291    use super::*;
292    use gpui::{TestAppContext, VisualTestContext};
293    use indoc::indoc;
294    use language::{Language, LanguageConfig, LanguageMatcher};
295    use project::{FakeFs, Project};
296    use serde_json::json;
297    use workspace::{AppState, Workspace};
298
299    #[gpui::test]
300    async fn test_outline_view_row_highlights(cx: &mut TestAppContext) {
301        init_test(cx);
302        let fs = FakeFs::new(cx.executor());
303        fs.insert_tree(
304            "/dir",
305            json!({
306                "a.rs": indoc!{"
307                    struct SingleLine; // display line 0
308                                       // display line 1
309                    struct MultiLine { // display line 2
310                        field_1: i32,  // display line 3
311                        field_2: i32,  // display line 4
312                    }                  // display line 5
313                "}
314            }),
315        )
316        .await;
317
318        let project = Project::test(fs, ["/dir".as_ref()], cx).await;
319        project.read_with(cx, |project, _| project.languages().add(rust_lang()));
320
321        let (workspace, cx) = cx.add_window_view(|cx| Workspace::test_new(project.clone(), cx));
322        let worktree_id = workspace.update(cx, |workspace, cx| {
323            workspace.project().update(cx, |project, cx| {
324                project.worktrees(cx).next().unwrap().read(cx).id()
325            })
326        });
327        let _buffer = project
328            .update(cx, |project, cx| project.open_local_buffer("/dir/a.rs", cx))
329            .await
330            .unwrap();
331        let editor = workspace
332            .update(cx, |workspace, cx| {
333                workspace.open_path((worktree_id, "a.rs"), None, true, cx)
334            })
335            .await
336            .unwrap()
337            .downcast::<Editor>()
338            .unwrap();
339        let ensure_outline_view_contents =
340            |outline_view: &View<Picker<OutlineViewDelegate>>, cx: &mut VisualTestContext| {
341                assert_eq!(query(outline_view, cx), "");
342                assert_eq!(
343                    outline_names(outline_view, cx),
344                    vec![
345                        "struct SingleLine",
346                        "struct MultiLine",
347                        "field_1",
348                        "field_2"
349                    ],
350                );
351            };
352
353        let outline_view = open_outline_view(&workspace, cx);
354        ensure_outline_view_contents(&outline_view, cx);
355        assert_eq!(
356            highlighted_display_rows(&editor, cx),
357            Vec::<u32>::new(),
358            "Initially opened outline view should have no highlights"
359        );
360        assert_single_caret_at_row(&editor, 0, cx);
361
362        cx.dispatch_action(menu::SelectNext);
363        ensure_outline_view_contents(&outline_view, cx);
364        assert_eq!(
365            highlighted_display_rows(&editor, cx),
366            vec![2, 3, 4, 5],
367            "Second struct's rows should be highlighted"
368        );
369        assert_single_caret_at_row(&editor, 0, cx);
370
371        cx.dispatch_action(menu::SelectPrev);
372        ensure_outline_view_contents(&outline_view, cx);
373        assert_eq!(
374            highlighted_display_rows(&editor, cx),
375            vec![0],
376            "First struct's row should be highlighted"
377        );
378        assert_single_caret_at_row(&editor, 0, cx);
379
380        cx.dispatch_action(menu::Cancel);
381        ensure_outline_view_contents(&outline_view, cx);
382        assert_eq!(
383            highlighted_display_rows(&editor, cx),
384            Vec::<u32>::new(),
385            "No rows should be highlighted after outline view is cancelled and closed"
386        );
387        assert_single_caret_at_row(&editor, 0, cx);
388
389        let outline_view = open_outline_view(&workspace, cx);
390        ensure_outline_view_contents(&outline_view, cx);
391        assert_eq!(
392            highlighted_display_rows(&editor, cx),
393            Vec::<u32>::new(),
394            "Reopened outline view should have no highlights"
395        );
396        assert_single_caret_at_row(&editor, 0, cx);
397
398        let expected_first_highlighted_row = 2;
399        cx.dispatch_action(menu::SelectNext);
400        ensure_outline_view_contents(&outline_view, cx);
401        assert_eq!(
402            highlighted_display_rows(&editor, cx),
403            vec![expected_first_highlighted_row, 3, 4, 5]
404        );
405        assert_single_caret_at_row(&editor, 0, cx);
406        cx.dispatch_action(menu::Confirm);
407        ensure_outline_view_contents(&outline_view, cx);
408        assert_eq!(
409            highlighted_display_rows(&editor, cx),
410            Vec::<u32>::new(),
411            "No rows should be highlighted after outline view is confirmed and closed"
412        );
413        // On confirm, should place the caret on the first row of the highlighted rows range.
414        assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx);
415    }
416
417    fn open_outline_view(
418        workspace: &View<Workspace>,
419        cx: &mut VisualTestContext,
420    ) -> View<Picker<OutlineViewDelegate>> {
421        cx.dispatch_action(ToggleOutline);
422        workspace.update(cx, |workspace, cx| {
423            workspace
424                .active_modal::<OutlineView>(cx)
425                .unwrap()
426                .read(cx)
427                .picker
428                .clone()
429        })
430    }
431
432    fn query(
433        outline_view: &View<Picker<OutlineViewDelegate>>,
434        cx: &mut VisualTestContext,
435    ) -> String {
436        outline_view.update(cx, |outline_view, cx| outline_view.query(cx))
437    }
438
439    fn outline_names(
440        outline_view: &View<Picker<OutlineViewDelegate>>,
441        cx: &mut VisualTestContext,
442    ) -> Vec<String> {
443        outline_view.update(cx, |outline_view, _| {
444            let items = &outline_view.delegate.outline.items;
445            outline_view
446                .delegate
447                .matches
448                .iter()
449                .map(|hit| items[hit.candidate_id].text.clone())
450                .collect::<Vec<_>>()
451        })
452    }
453
454    fn highlighted_display_rows(editor: &View<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
455        editor.update(cx, |editor, cx| {
456            editor
457                .highlighted_display_rows(cx)
458                .into_keys()
459                .map(|r| r.0)
460                .collect()
461        })
462    }
463
464    fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
465        cx.update(|cx| {
466            let state = AppState::test(cx);
467            language::init(cx);
468            crate::init(cx);
469            editor::init(cx);
470            workspace::init_settings(cx);
471            Project::init_settings(cx);
472            state
473        })
474    }
475
476    fn rust_lang() -> Arc<Language> {
477        Arc::new(
478            Language::new(
479                LanguageConfig {
480                    name: "Rust".into(),
481                    matcher: LanguageMatcher {
482                        path_suffixes: vec!["rs".to_string()],
483                        ..Default::default()
484                    },
485                    ..Default::default()
486                },
487                Some(tree_sitter_rust::LANGUAGE.into()),
488            )
489            .with_outline_query(
490                r#"(struct_item
491            (visibility_modifier)? @context
492            "struct" @context
493            name: (_) @name) @item
494
495        (enum_item
496            (visibility_modifier)? @context
497            "enum" @context
498            name: (_) @name) @item
499
500        (enum_variant
501            (visibility_modifier)? @context
502            name: (_) @name) @item
503
504        (impl_item
505            "impl" @context
506            trait: (_)? @name
507            "for"? @context
508            type: (_) @name) @item
509
510        (trait_item
511            (visibility_modifier)? @context
512            "trait" @context
513            name: (_) @name) @item
514
515        (function_item
516            (visibility_modifier)? @context
517            (function_modifiers)? @context
518            "fn" @context
519            name: (_) @name) @item
520
521        (function_signature_item
522            (visibility_modifier)? @context
523            (function_modifiers)? @context
524            "fn" @context
525            name: (_) @name) @item
526
527        (macro_definition
528            . "macro_rules!" @context
529            name: (_) @name) @item
530
531        (mod_item
532            (visibility_modifier)? @context
533            "mod" @context
534            name: (_) @name) @item
535
536        (type_item
537            (visibility_modifier)? @context
538            "type" @context
539            name: (_) @name) @item
540
541        (associated_type
542            "type" @context
543            name: (_) @name) @item
544
545        (const_item
546            (visibility_modifier)? @context
547            "const" @context
548            name: (_) @name) @item
549
550        (field_declaration
551            (visibility_modifier)? @context
552            name: (_) @name) @item
553"#,
554            )
555            .unwrap(),
556        )
557    }
558
559    #[track_caller]
560    fn assert_single_caret_at_row(
561        editor: &View<Editor>,
562        buffer_row: u32,
563        cx: &mut VisualTestContext,
564    ) {
565        let selections = editor.update(cx, |editor, cx| {
566            editor
567                .selections
568                .all::<rope::Point>(cx)
569                .into_iter()
570                .map(|s| s.start..s.end)
571                .collect::<Vec<_>>()
572        });
573        assert!(
574            selections.len() == 1,
575            "Expected one caret selection but got: {selections:?}"
576        );
577        let selection = &selections[0];
578        assert!(
579            selection.start == selection.end,
580            "Expected a single caret selection, but got: {selection:?}"
581        );
582        assert_eq!(selection.start.row, buffer_row);
583    }
584}