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