Skip to repository content

tenant.openagents/omega

No repository description is available.

OpenAgents Git authority 2026-07-28T03:01:04.304Z Public web read
NIP-34 coordinate30617:7649603503856e5148d571eac2766b288a8ff1e9e35d380337a1d2b0015b4f92:omega
MaintainersHidden in public view
References2 branches · 1 tag
Read-only clonegit clone https://openagents.com/git/tenant.openagents/omega.git
Browse files

outline.rs

1235 lines · 45.6 KB · rust
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
Served at tenant.openagents/omega Member data and write actions are omitted.