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