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