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