1use std::ops::Range;
2use std::{cmp, sync::Arc};
3
4use editor::scroll::ScrollOffset;
5use editor::{Anchor, AnchorRangeExt, Editor, scroll::Autoscroll};
6use editor::{MultiBufferOffset, RowHighlightOptions, SelectionEffects};
7use fuzzy::StringMatch;
8use gpui::{
9 App, Context, DismissEvent, Entity, EventEmitter, FocusHandle, Focusable, HighlightStyle,
10 ParentElement, Point, Render, Styled, StyledText, Task, TextStyle, WeakEntity, Window, div,
11 rems,
12};
13use language::{Outline, OutlineItem};
14use ordered_float::OrderedFloat;
15use picker::{Picker, PickerDelegate};
16use settings::Settings;
17use theme::{ActiveTheme, ThemeSettings};
18use ui::{ListItem, ListItemSpacing, prelude::*};
19use util::ResultExt;
20use workspace::{DismissDecision, ModalView};
21
22pub fn init(cx: &mut App) {
23 cx.observe_new(OutlineView::register).detach();
24 zed_actions::outline::TOGGLE_OUTLINE
25 .set(|view, window, cx| {
26 let Ok(editor) = view.downcast::<Editor>() else {
27 return;
28 };
29
30 toggle(editor, &Default::default(), window, cx);
31 })
32 .ok();
33}
34
35pub fn toggle(
36 editor: Entity<Editor>,
37 _: &zed_actions::outline::ToggleOutline,
38 window: &mut Window,
39 cx: &mut App,
40) {
41 let Some(workspace) = editor.read(cx).workspace() else {
42 return;
43 };
44 if workspace.read(cx).active_modal::<OutlineView>(cx).is_some() {
45 workspace.update(cx, |workspace, cx| {
46 workspace.toggle_modal(window, cx, |window, cx| {
47 OutlineView::new(Outline::new(Vec::new()), editor.clone(), window, cx)
48 });
49 });
50 return;
51 }
52
53 let Some(task) = outline_for_editor(&editor, cx) else {
54 return;
55 };
56 let editor = editor.clone();
57 window
58 .spawn(cx, async move |cx| {
59 let items = task.await;
60 if items.is_empty() {
61 return;
62 }
63 cx.update(|window, cx| {
64 let outline = Outline::new(items);
65 workspace.update(cx, |workspace, cx| {
66 workspace.toggle_modal(window, cx, |window, cx| {
67 OutlineView::new(outline, editor, window, cx)
68 });
69 });
70 })
71 .ok();
72 })
73 .detach();
74}
75
76fn outline_for_editor(
77 editor: &Entity<Editor>,
78 cx: &mut App,
79) -> Option<Task<Vec<OutlineItem<Anchor>>>> {
80 let multibuffer = editor.read(cx).buffer().read(cx).snapshot(cx);
81 let (excerpt_id, _, buffer_snapshot) = multibuffer.as_singleton()?;
82 let buffer_id = buffer_snapshot.remote_id();
83 let task = editor.update(cx, |editor, cx| editor.buffer_outline_items(buffer_id, cx));
84
85 Some(cx.background_executor().spawn(async move {
86 task.await
87 .into_iter()
88 .map(|item| OutlineItem {
89 depth: item.depth,
90 range: Anchor::range_in_buffer(excerpt_id, item.range),
91 source_range_for_text: Anchor::range_in_buffer(
92 excerpt_id,
93 item.source_range_for_text,
94 ),
95 text: item.text,
96 highlight_ranges: item.highlight_ranges,
97 name_ranges: item.name_ranges,
98 body_range: item
99 .body_range
100 .map(|r| Anchor::range_in_buffer(excerpt_id, r)),
101 annotation_range: item
102 .annotation_range
103 .map(|r| Anchor::range_in_buffer(excerpt_id, r)),
104 })
105 .collect()
106 }))
107}
108
109pub struct OutlineView {
110 picker: Entity<Picker<OutlineViewDelegate>>,
111}
112
113impl Focusable for OutlineView {
114 fn focus_handle(&self, cx: &App) -> FocusHandle {
115 self.picker.focus_handle(cx)
116 }
117}
118
119impl EventEmitter<DismissEvent> for OutlineView {}
120impl ModalView for OutlineView {
121 fn on_before_dismiss(
122 &mut self,
123 window: &mut Window,
124 cx: &mut Context<Self>,
125 ) -> DismissDecision {
126 self.picker.update(cx, |picker, cx| {
127 picker.delegate.restore_active_editor(window, cx)
128 });
129 DismissDecision::Dismiss(true)
130 }
131}
132
133impl Render for OutlineView {
134 fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
135 v_flex()
136 .w(rems(34.))
137 .on_action(cx.listener(
138 |_this: &mut OutlineView,
139 _: &zed_actions::outline::ToggleOutline,
140 _window: &mut Window,
141 cx: &mut Context<OutlineView>| {
142 // When outline::Toggle is triggered while the outline is open, dismiss it
143 cx.emit(DismissEvent);
144 },
145 ))
146 .child(self.picker.clone())
147 }
148}
149
150impl OutlineView {
151 fn register(editor: &mut Editor, _: Option<&mut Window>, cx: &mut Context<Editor>) {
152 if editor.mode().is_full() {
153 let handle = cx.entity().downgrade();
154 editor
155 .register_action(move |action, window, cx| {
156 if let Some(editor) = handle.upgrade() {
157 toggle(editor, action, window, cx);
158 }
159 })
160 .detach();
161 }
162 }
163
164 fn new(
165 outline: Outline<Anchor>,
166 editor: Entity<Editor>,
167 window: &mut Window,
168 cx: &mut Context<Self>,
169 ) -> OutlineView {
170 let delegate = OutlineViewDelegate::new(cx.entity().downgrade(), outline, editor, cx);
171 let picker = cx.new(|cx| {
172 Picker::uniform_list(delegate, window, cx)
173 .max_height(Some(vh(0.75, window)))
174 .show_scrollbar(true)
175 });
176 OutlineView { picker }
177 }
178}
179
180struct OutlineViewDelegate {
181 outline_view: WeakEntity<OutlineView>,
182 active_editor: Entity<Editor>,
183 outline: Arc<Outline<Anchor>>,
184 selected_match_index: usize,
185 prev_scroll_position: Option<Point<ScrollOffset>>,
186 matches: Vec<StringMatch>,
187}
188
189enum OutlineRowHighlights {}
190
191impl OutlineViewDelegate {
192 fn new(
193 outline_view: WeakEntity<OutlineView>,
194 outline: Outline<Anchor>,
195 editor: Entity<Editor>,
196
197 cx: &mut Context<OutlineView>,
198 ) -> Self {
199 Self {
200 outline_view,
201 matches: Default::default(),
202 selected_match_index: 0,
203 prev_scroll_position: Some(editor.update(cx, |editor, cx| editor.scroll_position(cx))),
204 active_editor: editor,
205 outline: Arc::new(outline),
206 }
207 }
208
209 fn restore_active_editor(&mut self, window: &mut Window, cx: &mut App) {
210 self.active_editor.update(cx, |editor, cx| {
211 editor.clear_row_highlights::<OutlineRowHighlights>();
212 if let Some(scroll_position) = self.prev_scroll_position {
213 editor.set_scroll_position(scroll_position, window, cx);
214 }
215 })
216 }
217
218 fn set_selected_index(
219 &mut self,
220 ix: usize,
221 navigate: bool,
222
223 cx: &mut Context<Picker<OutlineViewDelegate>>,
224 ) {
225 self.selected_match_index = ix;
226
227 if navigate && !self.matches.is_empty() {
228 let selected_match = &self.matches[self.selected_match_index];
229 let outline_item = &self.outline.items[selected_match.candidate_id];
230
231 self.active_editor.update(cx, |active_editor, cx| {
232 active_editor.clear_row_highlights::<OutlineRowHighlights>();
233 active_editor.highlight_rows::<OutlineRowHighlights>(
234 outline_item.range.start..outline_item.range.end,
235 cx.theme().colors().editor_highlighted_line_background,
236 RowHighlightOptions {
237 autoscroll: true,
238 ..Default::default()
239 },
240 cx,
241 );
242 active_editor.request_autoscroll(Autoscroll::center(), cx);
243 });
244 }
245 }
246}
247
248impl PickerDelegate for OutlineViewDelegate {
249 type ListItem = ListItem;
250
251 fn placeholder_text(&self, _window: &mut Window, _cx: &mut App) -> Arc<str> {
252 "Search buffer symbols...".into()
253 }
254
255 fn match_count(&self) -> usize {
256 self.matches.len()
257 }
258
259 fn selected_index(&self) -> usize {
260 self.selected_match_index
261 }
262
263 fn set_selected_index(
264 &mut self,
265 ix: usize,
266 _: &mut Window,
267 cx: &mut Context<Picker<OutlineViewDelegate>>,
268 ) {
269 self.set_selected_index(ix, true, cx);
270 }
271
272 fn update_matches(
273 &mut self,
274 query: String,
275 window: &mut Window,
276 cx: &mut Context<Picker<OutlineViewDelegate>>,
277 ) -> Task<()> {
278 let is_query_empty = query.is_empty();
279 if is_query_empty {
280 self.restore_active_editor(window, cx);
281 }
282
283 let outline = self.outline.clone();
284 cx.spawn_in(window, async move |this, cx| {
285 let matches = if is_query_empty {
286 outline
287 .items
288 .iter()
289 .enumerate()
290 .map(|(index, _)| StringMatch {
291 candidate_id: index,
292 score: Default::default(),
293 positions: Default::default(),
294 string: Default::default(),
295 })
296 .collect()
297 } else {
298 outline
299 .search(&query, cx.background_executor().clone())
300 .await
301 };
302
303 let _ = this.update(cx, |this, cx| {
304 this.delegate.matches = matches;
305 let selected_index = if is_query_empty {
306 let (buffer, cursor_offset) =
307 this.delegate.active_editor.update(cx, |editor, cx| {
308 let snapshot = editor.display_snapshot(cx);
309 let cursor_offset = editor
310 .selections
311 .newest::<MultiBufferOffset>(&snapshot)
312 .head();
313 (snapshot.buffer().clone(), cursor_offset)
314 });
315 this.delegate
316 .matches
317 .iter()
318 .enumerate()
319 .filter_map(|(ix, m)| {
320 let item = &this.delegate.outline.items[m.candidate_id];
321 let range = item.range.to_offset(&buffer);
322 range.contains(&cursor_offset).then_some((ix, item.depth))
323 })
324 .max_by_key(|(ix, depth)| (*depth, cmp::Reverse(*ix)))
325 .map(|(ix, _)| ix)
326 .unwrap_or(0)
327 } else {
328 this.delegate
329 .matches
330 .iter()
331 .enumerate()
332 .max_by(|(ix_a, a), (ix_b, b)| {
333 OrderedFloat(a.score)
334 .cmp(&OrderedFloat(b.score))
335 .then(ix_b.cmp(ix_a))
336 })
337 .map(|(ix, _)| ix)
338 .unwrap_or(0)
339 };
340
341 this.delegate
342 .set_selected_index(selected_index, !is_query_empty, cx);
343 });
344 })
345 }
346
347 fn confirm(
348 &mut self,
349 _: bool,
350 window: &mut Window,
351 cx: &mut Context<Picker<OutlineViewDelegate>>,
352 ) {
353 self.prev_scroll_position.take();
354 self.set_selected_index(self.selected_match_index, true, cx);
355
356 self.active_editor.update(cx, |active_editor, cx| {
357 let highlight = active_editor
358 .highlighted_rows::<OutlineRowHighlights>()
359 .next();
360 if let Some((rows, _)) = highlight {
361 active_editor.change_selections(
362 SelectionEffects::scroll(Autoscroll::center()),
363 window,
364 cx,
365 |s| s.select_ranges([rows.start..rows.start]),
366 );
367 active_editor.clear_row_highlights::<OutlineRowHighlights>();
368 window.focus(&active_editor.focus_handle(cx), cx);
369 }
370 });
371
372 self.dismissed(window, cx);
373 }
374
375 fn dismissed(&mut self, window: &mut Window, cx: &mut Context<Picker<OutlineViewDelegate>>) {
376 self.outline_view
377 .update(cx, |_, cx| cx.emit(DismissEvent))
378 .log_err();
379 self.restore_active_editor(window, cx);
380 }
381
382 fn render_match(
383 &self,
384 ix: usize,
385 selected: bool,
386 _: &mut Window,
387 cx: &mut Context<Picker<Self>>,
388 ) -> Option<Self::ListItem> {
389 let mat = self.matches.get(ix)?;
390 let outline_item = self.outline.items.get(mat.candidate_id)?;
391
392 Some(
393 ListItem::new(ix)
394 .inset(true)
395 .spacing(ListItemSpacing::Sparse)
396 .toggle_state(selected)
397 .child(
398 div()
399 .text_ui(cx)
400 .pl(rems(outline_item.depth as f32))
401 .child(render_item(outline_item, mat.ranges(), cx)),
402 ),
403 )
404 }
405}
406
407pub fn render_item<T>(
408 outline_item: &OutlineItem<T>,
409 match_ranges: impl IntoIterator<Item = Range<usize>>,
410 cx: &App,
411) -> StyledText {
412 let highlight_style = HighlightStyle {
413 background_color: Some(cx.theme().colors().text_accent.alpha(0.3)),
414 ..Default::default()
415 };
416 let custom_highlights = match_ranges
417 .into_iter()
418 .map(|range| (range, highlight_style));
419
420 let settings = ThemeSettings::get_global(cx);
421
422 // TODO: We probably shouldn't need to build a whole new text style here
423 // but I'm not sure how to get the current one and modify it.
424 // Before this change TextStyle::default() was used here, which was giving us the wrong font and text color.
425 let text_style = TextStyle {
426 color: cx.theme().colors().text,
427 font_family: settings.buffer_font.family.clone(),
428 font_features: settings.buffer_font.features.clone(),
429 font_fallbacks: settings.buffer_font.fallbacks.clone(),
430 font_size: settings.buffer_font_size(cx).into(),
431 font_weight: settings.buffer_font.weight,
432 line_height: relative(1.),
433 ..Default::default()
434 };
435 let highlights = gpui::combine_highlights(
436 custom_highlights,
437 outline_item.highlight_ranges.iter().cloned(),
438 );
439
440 StyledText::new(outline_item.text.clone()).with_default_highlights(&text_style, highlights)
441}
442
443#[cfg(test)]
444mod tests {
445 use std::time::Duration;
446
447 use super::*;
448 use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
449 use indoc::indoc;
450 use language::FakeLspAdapter;
451 use project::{FakeFs, Project};
452 use serde_json::json;
453 use settings::SettingsStore;
454 use smol::stream::StreamExt as _;
455 use util::{path, rel_path::rel_path};
456 use workspace::{AppState, MultiWorkspace, Workspace};
457
458 #[gpui::test]
459 async fn test_outline_view_row_highlights(cx: &mut TestAppContext) {
460 init_test(cx);
461 let fs = FakeFs::new(cx.executor());
462 fs.insert_tree(
463 path!("/dir"),
464 json!({
465 "a.rs": indoc!{"
466 // display line 0
467 struct SingleLine; // display line 1
468 // display line 2
469 struct MultiLine { // display line 3
470 field_1: i32, // display line 4
471 field_2: i32, // display line 5
472 } // display line 6
473 "}
474 }),
475 )
476 .await;
477
478 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
479 project.read_with(cx, |project, _| {
480 project.languages().add(language::rust_lang())
481 });
482
483 let (workspace, cx) =
484 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
485
486 let workspace = cx.read(|cx| workspace.read(cx).workspace().clone());
487 let worktree_id = workspace.update(cx, |workspace, cx| {
488 workspace.project().update(cx, |project, cx| {
489 project.worktrees(cx).next().unwrap().read(cx).id()
490 })
491 });
492 let _buffer = project
493 .update(cx, |project, cx| {
494 project.open_local_buffer(path!("/dir/a.rs"), cx)
495 })
496 .await
497 .unwrap();
498 let editor = workspace
499 .update_in(cx, |workspace, window, cx| {
500 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
501 })
502 .await
503 .unwrap()
504 .downcast::<Editor>()
505 .unwrap();
506 let ensure_outline_view_contents =
507 |outline_view: &Entity<Picker<OutlineViewDelegate>>, cx: &mut VisualTestContext| {
508 assert_eq!(query(outline_view, cx), "");
509 assert_eq!(
510 outline_names(outline_view, cx),
511 vec![
512 "struct SingleLine",
513 "struct MultiLine",
514 "field_1",
515 "field_2"
516 ],
517 );
518 };
519
520 let outline_view = open_outline_view(&workspace, cx);
521 ensure_outline_view_contents(&outline_view, cx);
522 assert_eq!(
523 highlighted_display_rows(&editor, cx),
524 Vec::<u32>::new(),
525 "Initially opened outline view should have no highlights"
526 );
527 assert_single_caret_at_row(&editor, 0, cx);
528
529 cx.dispatch_action(menu::Confirm);
530 // Ensures that outline still goes to entry even if no queries have been made
531 assert_single_caret_at_row(&editor, 1, cx);
532
533 let outline_view = open_outline_view(&workspace, cx);
534
535 cx.dispatch_action(menu::SelectNext);
536 ensure_outline_view_contents(&outline_view, cx);
537 assert_eq!(
538 highlighted_display_rows(&editor, cx),
539 vec![3, 4, 5, 6],
540 "Second struct's rows should be highlighted"
541 );
542 assert_single_caret_at_row(&editor, 1, cx);
543
544 cx.dispatch_action(menu::SelectPrevious);
545 ensure_outline_view_contents(&outline_view, cx);
546 assert_eq!(
547 highlighted_display_rows(&editor, cx),
548 vec![1],
549 "First struct's row should be highlighted"
550 );
551 assert_single_caret_at_row(&editor, 1, cx);
552
553 cx.dispatch_action(menu::Cancel);
554 ensure_outline_view_contents(&outline_view, cx);
555 assert_eq!(
556 highlighted_display_rows(&editor, cx),
557 Vec::<u32>::new(),
558 "No rows should be highlighted after outline view is cancelled and closed"
559 );
560 assert_single_caret_at_row(&editor, 1, cx);
561
562 let outline_view = open_outline_view(&workspace, cx);
563 ensure_outline_view_contents(&outline_view, cx);
564 assert_eq!(
565 highlighted_display_rows(&editor, cx),
566 Vec::<u32>::new(),
567 "Reopened outline view should have no highlights"
568 );
569 assert_single_caret_at_row(&editor, 1, cx);
570
571 let expected_first_highlighted_row = 3;
572 cx.dispatch_action(menu::SelectNext);
573 ensure_outline_view_contents(&outline_view, cx);
574 assert_eq!(
575 highlighted_display_rows(&editor, cx),
576 vec![expected_first_highlighted_row, 4, 5, 6]
577 );
578 assert_single_caret_at_row(&editor, 1, cx);
579 cx.dispatch_action(menu::Confirm);
580 ensure_outline_view_contents(&outline_view, cx);
581 assert_eq!(
582 highlighted_display_rows(&editor, cx),
583 Vec::<u32>::new(),
584 "No rows should be highlighted after outline view is confirmed and closed"
585 );
586 // On confirm, should place the caret on the first row of the highlighted rows range.
587 assert_single_caret_at_row(&editor, expected_first_highlighted_row, cx);
588 }
589
590 #[gpui::test]
591 async fn test_outline_empty_query_prefers_deepest_containing_symbol_else_first(
592 cx: &mut TestAppContext,
593 ) {
594 init_test(cx);
595
596 let fs = FakeFs::new(cx.executor());
597 fs.insert_tree(
598 path!("/dir"),
599 json!({
600 "a.rs": indoc! {"
601 // display line 0
602 struct Outer { // display line 1
603 fn top(&self) {// display line 2
604 let _x = 1;// display line 3
605 } // display line 4
606 } // display line 5
607
608 struct Another; // display line 7
609 "}
610 }),
611 )
612 .await;
613
614 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
615 project.read_with(cx, |project, _| {
616 project.languages().add(language::rust_lang())
617 });
618
619 let (workspace, cx) =
620 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
621
622 let workspace = cx.read(|cx| workspace.read(cx).workspace().clone());
623 let worktree_id = workspace.update(cx, |workspace, cx| {
624 workspace.project().update(cx, |project, cx| {
625 project.worktrees(cx).next().unwrap().read(cx).id()
626 })
627 });
628 let _buffer = project
629 .update(cx, |project, cx| {
630 project.open_local_buffer(path!("/dir/a.rs"), cx)
631 })
632 .await
633 .unwrap();
634 let editor = workspace
635 .update_in(cx, |workspace, window, cx| {
636 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
637 })
638 .await
639 .unwrap()
640 .downcast::<Editor>()
641 .unwrap();
642
643 set_single_caret_at_row(&editor, 3, cx);
644 let outline_view = open_outline_view(&workspace, cx);
645 cx.run_until_parked();
646 let (selected_candidate_id, expected_deepest_containing_candidate_id) = outline_view
647 .update(cx, |outline_view, cx| {
648 let delegate = &outline_view.delegate;
649 let selected_candidate_id =
650 delegate.matches[delegate.selected_match_index].candidate_id;
651 let (buffer, cursor_offset) = delegate.active_editor.update(cx, |editor, cx| {
652 let buffer = editor.buffer().read(cx).snapshot(cx);
653 let cursor_offset = editor
654 .selections
655 .newest::<MultiBufferOffset>(&editor.display_snapshot(cx))
656 .head();
657 (buffer, cursor_offset)
658 });
659 let deepest_containing_candidate_id = delegate
660 .outline
661 .items
662 .iter()
663 .enumerate()
664 .filter_map(|(ix, item)| {
665 item.range
666 .to_offset(&buffer)
667 .contains(&cursor_offset)
668 .then_some((ix, item.depth))
669 })
670 .max_by(|(ix_a, depth_a), (ix_b, depth_b)| {
671 depth_a.cmp(depth_b).then(ix_b.cmp(ix_a))
672 })
673 .map(|(ix, _)| ix)
674 .unwrap();
675 (selected_candidate_id, deepest_containing_candidate_id)
676 });
677 assert_eq!(
678 selected_candidate_id, expected_deepest_containing_candidate_id,
679 "Empty query should select the deepest symbol containing the cursor"
680 );
681
682 cx.dispatch_action(menu::Cancel);
683 cx.run_until_parked();
684
685 set_single_caret_at_row(&editor, 0, cx);
686 let outline_view = open_outline_view(&workspace, cx);
687 cx.run_until_parked();
688 let selected_candidate_id = outline_view.read_with(cx, |outline_view, _| {
689 let delegate = &outline_view.delegate;
690 delegate.matches[delegate.selected_match_index].candidate_id
691 });
692 assert_eq!(
693 selected_candidate_id, 0,
694 "Empty query should fall back to the first symbol when cursor is outside all symbol ranges"
695 );
696 }
697
698 #[gpui::test]
699 async fn test_outline_filtered_selection_prefers_first_match_on_score_ties(
700 cx: &mut TestAppContext,
701 ) {
702 init_test(cx);
703
704 let fs = FakeFs::new(cx.executor());
705 fs.insert_tree(
706 path!("/dir"),
707 json!({
708 "a.rs": indoc! {"
709 struct A;
710 impl A {
711 fn f(&self) {}
712 fn g(&self) {}
713 }
714
715 struct B;
716 impl B {
717 fn f(&self) {}
718 fn g(&self) {}
719 }
720
721 struct C;
722 impl C {
723 fn f(&self) {}
724 fn g(&self) {}
725 }
726 "}
727 }),
728 )
729 .await;
730
731 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
732 project.read_with(cx, |project, _| {
733 project.languages().add(language::rust_lang())
734 });
735
736 let (workspace, cx) =
737 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
738
739 let workspace = cx.read(|cx| workspace.read(cx).workspace().clone());
740 let worktree_id = workspace.update(cx, |workspace, cx| {
741 workspace.project().update(cx, |project, cx| {
742 project.worktrees(cx).next().unwrap().read(cx).id()
743 })
744 });
745 let _buffer = project
746 .update(cx, |project, cx| {
747 project.open_local_buffer(path!("/dir/a.rs"), cx)
748 })
749 .await
750 .unwrap();
751 let editor = workspace
752 .update_in(cx, |workspace, window, cx| {
753 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
754 })
755 .await
756 .unwrap()
757 .downcast::<Editor>()
758 .unwrap();
759
760 assert_single_caret_at_row(&editor, 0, cx);
761 let outline_view = open_outline_view(&workspace, cx);
762 let match_ids = |outline_view: &Entity<Picker<OutlineViewDelegate>>,
763 cx: &mut VisualTestContext| {
764 outline_view.read_with(cx, |outline_view, _| {
765 let delegate = &outline_view.delegate;
766 let selected_match = &delegate.matches[delegate.selected_match_index];
767 let scored_ids = delegate
768 .matches
769 .iter()
770 .filter(|m| m.score > 0.0)
771 .map(|m| m.candidate_id)
772 .collect::<Vec<_>>();
773 (
774 selected_match.candidate_id,
775 *scored_ids.first().unwrap(),
776 *scored_ids.last().unwrap(),
777 scored_ids.len(),
778 )
779 })
780 };
781
782 outline_view
783 .update_in(cx, |outline_view, window, cx| {
784 outline_view
785 .delegate
786 .update_matches("f".to_string(), window, cx)
787 })
788 .await;
789 let (selected_id, first_scored_id, last_scored_id, scored_match_count) =
790 match_ids(&outline_view, cx);
791
792 assert!(
793 scored_match_count > 1,
794 "Expected multiple scored matches for `f` in outline filtering"
795 );
796 assert_eq!(
797 selected_id, first_scored_id,
798 "Filtered query should pick the first scored match when scores tie"
799 );
800 assert_ne!(
801 selected_id, last_scored_id,
802 "Selection should not default to the last scored match"
803 );
804
805 set_single_caret_at_row(&editor, 12, cx);
806 outline_view
807 .update_in(cx, |outline_view, window, cx| {
808 outline_view
809 .delegate
810 .update_matches("f".to_string(), window, cx)
811 })
812 .await;
813 let (selected_id, first_scored_id, last_scored_id, scored_match_count) =
814 match_ids(&outline_view, cx);
815
816 assert!(
817 scored_match_count > 1,
818 "Expected multiple scored matches for `f` in outline filtering"
819 );
820 assert_eq!(
821 selected_id, first_scored_id,
822 "Filtered selection should stay score-ordered and not switch based on cursor proximity"
823 );
824 assert_ne!(
825 selected_id, last_scored_id,
826 "Selection should not default to the last scored match"
827 );
828 }
829
830 fn open_outline_view(
831 workspace: &Entity<Workspace>,
832 cx: &mut VisualTestContext,
833 ) -> Entity<Picker<OutlineViewDelegate>> {
834 cx.dispatch_action(zed_actions::outline::ToggleOutline);
835 cx.executor().advance_clock(Duration::from_millis(200));
836 workspace.update(cx, |workspace, cx| {
837 workspace
838 .active_modal::<OutlineView>(cx)
839 .unwrap()
840 .read(cx)
841 .picker
842 .clone()
843 })
844 }
845
846 fn query(
847 outline_view: &Entity<Picker<OutlineViewDelegate>>,
848 cx: &mut VisualTestContext,
849 ) -> String {
850 outline_view.update(cx, |outline_view, cx| outline_view.query(cx))
851 }
852
853 fn outline_names(
854 outline_view: &Entity<Picker<OutlineViewDelegate>>,
855 cx: &mut VisualTestContext,
856 ) -> Vec<String> {
857 outline_view.read_with(cx, |outline_view, _| {
858 let items = &outline_view.delegate.outline.items;
859 outline_view
860 .delegate
861 .matches
862 .iter()
863 .map(|hit| items[hit.candidate_id].text.clone())
864 .collect::<Vec<_>>()
865 })
866 }
867
868 fn highlighted_display_rows(editor: &Entity<Editor>, cx: &mut VisualTestContext) -> Vec<u32> {
869 editor.update_in(cx, |editor, window, cx| {
870 editor
871 .highlighted_display_rows(window, cx)
872 .into_keys()
873 .map(|r| r.0)
874 .collect()
875 })
876 }
877
878 fn set_single_caret_at_row(
879 editor: &Entity<Editor>,
880 buffer_row: u32,
881 cx: &mut VisualTestContext,
882 ) {
883 editor.update_in(cx, |editor, window, cx| {
884 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
885 s.select_ranges([rope::Point::new(buffer_row, 0)..rope::Point::new(buffer_row, 0)])
886 });
887 });
888 }
889
890 fn init_test(cx: &mut TestAppContext) -> Arc<AppState> {
891 cx.update(|cx| {
892 let state = AppState::test(cx);
893 crate::init(cx);
894 editor::init(cx);
895 state
896 })
897 }
898
899 #[gpui::test]
900 async fn test_outline_modal_lsp_document_symbols(cx: &mut TestAppContext) {
901 init_test(cx);
902
903 let fs = FakeFs::new(cx.executor());
904 fs.insert_tree(
905 path!("/dir"),
906 json!({
907 "a.rs": indoc!{"
908 struct Foo {
909 bar: u32,
910 baz: String,
911 }
912 "}
913 }),
914 )
915 .await;
916
917 let project = Project::test(fs, [path!("/dir").as_ref()], cx).await;
918 let language_registry = project.read_with(cx, |project, _| {
919 project.languages().add(language::rust_lang());
920 project.languages().clone()
921 });
922
923 let mut fake_language_servers = language_registry.register_fake_lsp(
924 "Rust",
925 FakeLspAdapter {
926 capabilities: lsp::ServerCapabilities {
927 document_symbol_provider: Some(lsp::OneOf::Left(true)),
928 ..lsp::ServerCapabilities::default()
929 },
930 initializer: Some(Box::new(|fake_language_server| {
931 #[allow(deprecated)]
932 fake_language_server
933 .set_request_handler::<lsp::request::DocumentSymbolRequest, _, _>(
934 move |_, _| async move {
935 Ok(Some(lsp::DocumentSymbolResponse::Nested(vec![
936 lsp::DocumentSymbol {
937 name: "Foo".to_string(),
938 detail: None,
939 kind: lsp::SymbolKind::STRUCT,
940 tags: None,
941 deprecated: None,
942 range: lsp::Range::new(
943 lsp::Position::new(0, 0),
944 lsp::Position::new(3, 1),
945 ),
946 selection_range: lsp::Range::new(
947 lsp::Position::new(0, 7),
948 lsp::Position::new(0, 10),
949 ),
950 children: Some(vec![
951 lsp::DocumentSymbol {
952 name: "bar".to_string(),
953 detail: None,
954 kind: lsp::SymbolKind::FIELD,
955 tags: None,
956 deprecated: None,
957 range: lsp::Range::new(
958 lsp::Position::new(1, 4),
959 lsp::Position::new(1, 13),
960 ),
961 selection_range: lsp::Range::new(
962 lsp::Position::new(1, 4),
963 lsp::Position::new(1, 7),
964 ),
965 children: None,
966 },
967 lsp::DocumentSymbol {
968 name: "lsp_only_field".to_string(),
969 detail: None,
970 kind: lsp::SymbolKind::FIELD,
971 tags: None,
972 deprecated: None,
973 range: lsp::Range::new(
974 lsp::Position::new(2, 4),
975 lsp::Position::new(2, 15),
976 ),
977 selection_range: lsp::Range::new(
978 lsp::Position::new(2, 4),
979 lsp::Position::new(2, 7),
980 ),
981 children: None,
982 },
983 ]),
984 },
985 ])))
986 },
987 );
988 })),
989 ..FakeLspAdapter::default()
990 },
991 );
992
993 let (multi_workspace, cx) =
994 cx.add_window_view(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
995 let workspace = cx.read(|cx| multi_workspace.read(cx).workspace().clone());
996 let worktree_id = workspace.update(cx, |workspace, cx| {
997 workspace.project().update(cx, |project, cx| {
998 project.worktrees(cx).next().unwrap().read(cx).id()
999 })
1000 });
1001 let _buffer = project
1002 .update(cx, |project, cx| {
1003 project.open_local_buffer(path!("/dir/a.rs"), cx)
1004 })
1005 .await
1006 .unwrap();
1007 let editor = workspace
1008 .update_in(cx, |workspace, window, cx| {
1009 workspace.open_path((worktree_id, rel_path("a.rs")), None, true, window, cx)
1010 })
1011 .await
1012 .unwrap()
1013 .downcast::<Editor>()
1014 .unwrap();
1015
1016 let _fake_language_server = fake_language_servers.next().await.unwrap();
1017 cx.run_until_parked();
1018
1019 // Step 1: tree-sitter outlines by default
1020 let outline_view = open_outline_view(&workspace, cx);
1021 let tree_sitter_names = outline_names(&outline_view, cx);
1022 assert_eq!(
1023 tree_sitter_names,
1024 vec!["struct Foo", "bar", "baz"],
1025 "Step 1: tree-sitter outlines should be displayed by default"
1026 );
1027 cx.dispatch_action(menu::Cancel);
1028 cx.run_until_parked();
1029
1030 // Step 2: Switch to LSP document symbols
1031 cx.update(|_, cx| {
1032 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
1033 store.update_user_settings(cx, |settings| {
1034 settings.project.all_languages.defaults.document_symbols =
1035 Some(settings::DocumentSymbols::On);
1036 });
1037 });
1038 });
1039 let outline_view = open_outline_view(&workspace, cx);
1040 let lsp_names = outline_names(&outline_view, cx);
1041 assert_eq!(
1042 lsp_names,
1043 vec!["struct Foo", "bar", "lsp_only_field"],
1044 "Step 2: LSP-provided symbols should be displayed"
1045 );
1046 assert_eq!(
1047 highlighted_display_rows(&editor, cx),
1048 Vec::<u32>::new(),
1049 "Step 2: initially opened outline view should have no highlights"
1050 );
1051 assert_single_caret_at_row(&editor, 0, cx);
1052
1053 cx.dispatch_action(menu::SelectNext);
1054 assert_eq!(
1055 highlighted_display_rows(&editor, cx),
1056 vec![1],
1057 "Step 2: bar's row should be highlighted after SelectNext"
1058 );
1059 assert_single_caret_at_row(&editor, 0, cx);
1060
1061 cx.dispatch_action(menu::Confirm);
1062 cx.run_until_parked();
1063 assert_single_caret_at_row(&editor, 1, cx);
1064
1065 // Step 3: Switch back to tree-sitter
1066 cx.update(|_, cx| {
1067 SettingsStore::update_global(cx, |store: &mut SettingsStore, cx| {
1068 store.update_user_settings(cx, |settings| {
1069 settings.project.all_languages.defaults.document_symbols =
1070 Some(settings::DocumentSymbols::Off);
1071 });
1072 });
1073 });
1074
1075 let outline_view = open_outline_view(&workspace, cx);
1076 let restored_names = outline_names(&outline_view, cx);
1077 assert_eq!(
1078 restored_names,
1079 vec!["struct Foo", "bar", "baz"],
1080 "Step 3: tree-sitter outlines should be restored after switching back"
1081 );
1082 }
1083
1084 #[track_caller]
1085 fn assert_single_caret_at_row(
1086 editor: &Entity<Editor>,
1087 buffer_row: u32,
1088 cx: &mut VisualTestContext,
1089 ) {
1090 let selections = editor.update(cx, |editor, cx| {
1091 editor
1092 .selections
1093 .all::<rope::Point>(&editor.display_snapshot(cx))
1094 .into_iter()
1095 .map(|s| s.start..s.end)
1096 .collect::<Vec<_>>()
1097 });
1098 assert!(
1099 selections.len() == 1,
1100 "Expected one caret selection but got: {selections:?}"
1101 );
1102 let selection = &selections[0];
1103 assert!(
1104 selection.start == selection.end,
1105 "Expected a single caret selection, but got: {selection:?}"
1106 );
1107 assert_eq!(selection.start.row, buffer_row);
1108 }
1109}