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