outline.rs

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