From 9649a72e2500e8e1973b700dad25dd6fbc74f9b1 Mon Sep 17 00:00:00 2001 From: Om Chillure Date: Mon, 30 Mar 2026 17:28:48 +0530 Subject: [PATCH] Fix wrong selection in outline panel (#52673) Fix the outline entry sort order to prefer the entry whose heading starts at the cursor position. Closes #52418 Release Notes: - Fixed Outline Panel selection being off-by-one in Markdown heading boundaries --------- Co-authored-by: dino --- crates/outline_panel/src/outline_panel.rs | 117 +++++++++++++++++++++- 1 file changed, 115 insertions(+), 2 deletions(-) diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 4dc6088451ec9e98c0cf823d85316951151cf126..141c31a269073605f5ddc5fe4cc7fd6bdd553a35 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -3393,9 +3393,16 @@ impl OutlinePanel { selection_display_point - outline_range.end }; + // An outline item's range can extend to the same row the next + // item starts on, so when the cursor is at the start of that + // row, prefer the item that starts there over any item whose + // range merely overlaps that row. + let cursor_not_at_outline_start = outline_range.start != selection_display_point; ( + cursor_not_at_outline_start, cmp::Reverse(outline.depth), - distance_from_start + distance_from_end, + distance_from_start, + distance_from_end, ) }) .map(|(_, (_, outline))| *outline) @@ -5361,7 +5368,7 @@ impl GenerationState { mod tests { use db::indoc; use gpui::{TestAppContext, UpdateGlobal, VisualTestContext, WindowHandle}; - use language::{self, FakeLspAdapter, rust_lang}; + use language::{self, FakeLspAdapter, markdown_lang, rust_lang}; use pretty_assertions::assert_eq; use project::FakeFs; use search::{ @@ -8089,4 +8096,110 @@ outline: struct Foo <==== selected ); }); } + + #[gpui::test] + async fn test_markdown_outline_selection_at_heading_boundaries(cx: &mut TestAppContext) { + init_test(cx); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + "/test", + json!({ + "doc.md": indoc!(" + # Section A + + ## Sub Section A + + ## Sub Section B + + # Section B + + ") + }), + ) + .await; + + let project = Project::test(fs.clone(), [Path::new("/test")], cx).await; + project.read_with(cx, |project, _| project.languages().add(markdown_lang())); + let (window, workspace) = add_outline_panel(&project, cx).await; + let cx = &mut VisualTestContext::from_window(window.into(), cx); + let outline_panel = outline_panel(&workspace, cx); + outline_panel.update_in(cx, |outline_panel, window, cx| { + outline_panel.set_active(true, window, cx) + }); + + let editor = workspace + .update_in(cx, |workspace, window, cx| { + workspace.open_abs_path( + PathBuf::from("/test/doc.md"), + OpenOptions { + visible: Some(OpenVisible::All), + ..Default::default() + }, + window, + cx, + ) + }) + .await + .unwrap() + .downcast::() + .unwrap(); + + cx.run_until_parked(); + + outline_panel.update_in(cx, |panel, window, cx| { + panel.update_non_fs_items(window, cx); + panel.update_cached_entries(Some(UPDATE_DEBOUNCE), window, cx); + }); + + // Helper function to move the cursor to the first column of a given row + // and return the selected outline entry's text. + let move_cursor_and_get_selection = + |row: u32, cx: &mut VisualTestContext| -> Option { + cx.update(|window, cx| { + editor.update(cx, |editor, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.select_ranges(Some( + language::Point::new(row, 0)..language::Point::new(row, 0), + )) + }); + }); + }); + + cx.run_until_parked(); + + outline_panel.read_with(cx, |panel, _cx| { + panel.selected_entry().and_then(|entry| match entry { + PanelEntry::Outline(OutlineEntry::Outline(outline)) => { + Some(outline.outline.text.clone()) + } + _ => None, + }) + }) + }; + + assert_eq!( + move_cursor_and_get_selection(0, cx).as_deref(), + Some("# Section A"), + "Cursor at row 0 should select '# Section A'" + ); + + assert_eq!( + move_cursor_and_get_selection(2, cx).as_deref(), + Some("## Sub Section A"), + "Cursor at row 2 should select '## Sub Section A'" + ); + + assert_eq!( + move_cursor_and_get_selection(4, cx).as_deref(), + Some("## Sub Section B"), + "Cursor at row 4 should select '## Sub Section B'" + ); + + assert_eq!( + move_cursor_and_get_selection(6, cx).as_deref(), + Some("# Section B"), + "Cursor at row 6 should select '# Section B'" + ); + } }