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