outline.rs

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