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, rel_path::rel_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, rel_path("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}