outline.rs

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