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