Fix wrong selection in outline panel (#52673)

Om Chillure and dino created

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 <dinojoaocosta@gmail.com>

Change summary

crates/outline_panel/src/outline_panel.rs | 117 ++++++++++++++++++++++++
1 file changed, 115 insertions(+), 2 deletions(-)

Detailed changes

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::<Editor>()
+            .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<String> {
+                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'"
+        );
+    }
 }