From 8a720e6c52c041f3412ebba3a098ee41e6b5a97c Mon Sep 17 00:00:00 2001 From: saberoueslati Date: Mon, 15 Jun 2026 12:03:26 +0100 Subject: [PATCH] Anchor sticky scroll to symbol name rows (#56333) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Context Sticky scroll was anchoring multiline signatures to the first outline context row instead of the row containing the symbol name, which could leave the sticky header showing unhelpful context. This change carries each outline item’s `selection_range` through the tree-sitter, LSP, multi-buffer, and editor paths, then uses that range to choose the sticky header row. This is intended to improve sticky scroll generically, not only for one language. It should help wherever symbol data distinguishes the symbol name from the larger symbol range: - tree-sitter outlines that provide a distinct `@name` capture - LSP document symbols whose `selectionRange` is more precise than `range` The actual improvement is still language-dependent, because it relies on the quality of each language’s outline query or language server symbol metadata. Closes #55587 Manual video of the test : [Screencast from 2026-05-10 14-05-46.webm](https://github.com/user-attachments/assets/40f327d3-cab2-4b85-887b-08b541a102bf) ## How to Review `crates/language/src/outline.rs`, `crates/language/src/buffer.rs`, and `crates/language/src/buffer_tests.rs`: Adds `selection_range` to tree-sitter outline items and verifies that multiline signatures can anchor sticky scroll on the symbol-name row while preserving the existing displayed outline text. `crates/project/src/lsp_store/document_symbols.rs`: Threads LSP document symbol `selection_range` into `OutlineItem`, keeping enriched labels and highlight ranges intact while preserving the distinction between the full symbol range and the selected name range. `crates/multi_buffer/src/multi_buffer.rs`, `crates/editor/src/document_symbols.rs`, `crates/editor/src/editor.rs`, `crates/outline/src/outline.rs`, and `crates/outline_panel/src/outline_panel.rs`: Propagates `selection_range` through the places that remap outline items between buffers, multi-buffers, editors, and outline views. `crates/editor/src/element.rs` and `crates/editor/src/editor_tests.rs`: Uses `selection_range` when computing sticky header rows and adds a regression test covering a multiline signature case from the issue. `crates/language/Cargo.toml` and `Cargo.lock`: Adds `tree-sitter-c` for the language-level regression test fixture. ## Self-Review Checklist - [x] I've reviewed my own diff for quality, security, and reliability - [ ] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the UI/UX checklist - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Fixed sticky scroll anchoring to unhelpful context rows for multiline signatures when symbol metadata provides a more precise name range. --- Cargo.lock | 1 + crates/editor/src/document_symbols.rs | 19 +++++- crates/editor/src/editor.rs | 2 + crates/editor/src/editor_tests.rs | 60 +++++++++++++++++++ crates/editor/src/element/header.rs | 12 +++- crates/language/Cargo.toml | 1 + crates/language/src/buffer.rs | 10 ++++ crates/language/src/buffer_tests.rs | 43 +++++++++++++ crates/language/src/outline.rs | 8 +++ crates/multi_buffer/src/multi_buffer.rs | 5 ++ crates/outline/src/outline.rs | 2 + crates/outline_panel/src/outline_panel.rs | 1 + .../project/src/lsp_store/document_symbols.rs | 23 ++++++- 13 files changed, 184 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0009d37afa..f2cb97bf96 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9652,6 +9652,7 @@ dependencies = [ "toml 0.8.23", "tracing", "tree-sitter", + "tree-sitter-c", "tree-sitter-elixir", "tree-sitter-embedded-template", "tree-sitter-heex", diff --git a/crates/editor/src/document_symbols.rs b/crates/editor/src/document_symbols.rs index 8523bcb7b0..58483e6dab 100644 --- a/crates/editor/src/document_symbols.rs +++ b/crates/editor/src/document_symbols.rs @@ -103,6 +103,9 @@ impl Editor { Some(OutlineItem { depth: item.depth, range: range_start..range_end, + selection_range: multi_buffer_snapshot + .anchor_in_buffer(item.selection_range.start)? + ..multi_buffer_snapshot.anchor_in_buffer(item.selection_range.end)?, source_range_for_text: source_range_for_text_start..source_range_for_text_end, text: item.text.clone(), highlight_ranges: item.highlight_ranges.clone(), @@ -331,7 +334,9 @@ mod tests { use futures::StreamExt as _; use gpui::TestAppContext; + use multi_buffer::ToPoint; use settings::{DocumentSymbols, SettingsStore}; + use text::Point; use util::path; use zed_actions::editor::{MoveDown, MoveUp}; @@ -755,7 +760,7 @@ mod tests { assert!(symbol_request.next().await.is_some()); cx.run_until_parked(); - cx.update_editor(|editor, _window, _cx| { + cx.update_editor(|editor, _window, cx| { let (_, symbols) = editor .outline_symbols_at_cursor .as_ref() @@ -763,7 +768,19 @@ mod tests { assert_eq!(symbols.len(), 1); let symbol = &symbols[0]; + let multi_buffer_snapshot = editor.buffer.read(cx).snapshot(cx); assert_eq!(symbol.text, "fn test"); + assert_eq!( + symbol + .selection_range + .start + .to_point(&multi_buffer_snapshot), + Point::new(1, 3) + ); + assert_eq!( + symbol.selection_range.end.to_point(&multi_buffer_snapshot), + Point::new(1, 7) + ); // Verify all highlight ranges are valid byte boundaries in the text for (range, _style) in &symbol.highlight_ranges { diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index c91f92f563..e65140d53f 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1793,6 +1793,8 @@ impl Editor { depth: outline_item.depth, range: multi_buffer .buffer_anchor_range_to_anchor_range(outline_item.range)?, + selection_range: multi_buffer + .buffer_anchor_range_to_anchor_range(outline_item.selection_range)?, source_range_for_text: multi_buffer.buffer_anchor_range_to_anchor_range( outline_item.source_range_for_text, )?, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 22b0e1a635..6a71776648 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -33931,6 +33931,66 @@ async fn test_sticky_scroll_with_decoration_prefix_in_item(cx: &mut TestAppConte assert_eq!(sticky_headers(7.0), vec![]); } +#[gpui::test] +async fn test_sticky_scroll_anchors_multiline_c_signature_on_name_row(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + let mut cx = EditorTestContext::new(cx).await; + + let buffer = indoc! {" + ˇvoid + evdev_post_scroll(struct evdev_device *device, + usec_t time, + enum libinput_pointer_axis_source source, + const struct normalized_coords *delta) + { + const struct normalized_coords tilt_rot = { + cos(SCROLL_DELTA_TILT_ANGLE), + sin(SCROLL_DELTA_TILT_ANGLE), + }; + } + "}; + cx.set_state(buffer); + + cx.update_editor(|editor, _, cx| { + editor + .buffer() + .read(cx) + .as_singleton() + .unwrap() + .update(cx, |buffer, cx| { + buffer.set_language( + Some(languages::language("c", tree_sitter_c::LANGUAGE.into())), + cx, + ); + }) + }); + + let mut sticky_headers = |offset: ScrollOffset| { + cx.update_editor(|editor, window, cx| { + editor.scroll(gpui::Point { x: 0., y: offset }, None, window, cx); + }); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| { + EditorElement::sticky_headers(&editor, &editor.snapshot(window, cx)) + .into_iter() + .map( + |StickyHeader { + start_point, + offset, + .. + }| { (start_point, offset) }, + ) + .collect::>() + }) + }; + + let function_name_row = Point { row: 1, column: 0 }; + + assert_eq!(sticky_headers(1.0), vec![]); + assert_eq!(sticky_headers(1.5), vec![(function_name_row, 0.0)]); + assert_eq!(sticky_headers(5.0), vec![(function_name_row, 0.0)]); +} + #[gpui::test] async fn test_sticky_scroll_with_expanded_deleted_diff_hunks( executor: BackgroundExecutor, diff --git a/crates/editor/src/element/header.rs b/crates/editor/src/element/header.rs index 052afe1de6..e51f29514a 100644 --- a/crates/editor/src/element/header.rs +++ b/crates/editor/src/element/header.rs @@ -69,10 +69,20 @@ impl EditorElement { let mut rows = Vec::::new(); for item in editor.sticky_headers.iter().flatten() { - let start_point = item + let selection_start = item + .selection_range + .start + .to_point(snapshot.buffer_snapshot()); + let source_text_start = item .source_range_for_text .start .to_point(snapshot.buffer_snapshot()); + let start_column = if source_text_start.row == selection_start.row { + source_text_start.column + } else { + 0 + }; + let start_point = Point::new(selection_start.row, start_column); let end_point = item.range.end.to_point(snapshot.buffer_snapshot()); let sticky_row = snapshot diff --git a/crates/language/Cargo.toml b/crates/language/Cargo.toml index bde0659cf1..58a7517d2c 100644 --- a/crates/language/Cargo.toml +++ b/crates/language/Cargo.toml @@ -95,6 +95,7 @@ tree-sitter-json.workspace = true tree-sitter-md.workspace = true tree-sitter-python.workspace = true tree-sitter-ruby.workspace = true +tree-sitter-c.workspace = true tree-sitter-rust.workspace = true tree-sitter-typescript.workspace = true toml.workspace = true diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index 2973414211..5b2b511192 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -4535,6 +4535,7 @@ impl BufferSnapshot { anchor_items.push(OutlineItem { depth: item_ends_stack.len(), range: range_callback(self, item.range.clone()), + selection_range: range_callback(self, item.selection_range.clone()), source_range_for_text: range_callback(self, item.source_range_for_text.clone()), text: item.text, highlight_ranges: item.highlight_ranges, @@ -4612,6 +4613,14 @@ impl BufferSnapshot { } let source_range_for_text = buffer_ranges.first().unwrap().0.start..buffer_ranges.last().unwrap().0.end; + let selection_range = buffer_ranges + .iter() + .filter(|(_, node_is_name)| *node_is_name) + .map(|(buffer_range, _)| buffer_range.clone()) + .reduce(|mut combined_range, next_range| { + combined_range.end = next_range.end; + combined_range + })?; let mut text = String::new(); let mut highlight_ranges = Vec::new(); @@ -4669,6 +4678,7 @@ impl BufferSnapshot { Some(OutlineItem { depth: 0, // We'll calculate the depth later range: item_point_range, + selection_range: selection_range.to_point(self), source_range_for_text: source_range_for_text.to_point(self), text: text.into(), highlight_ranges, diff --git a/crates/language/src/buffer_tests.rs b/crates/language/src/buffer_tests.rs index 691a6f9448..ba7fd7ea95 100644 --- a/crates/language/src/buffer_tests.rs +++ b/crates/language/src/buffer_tests.rs @@ -977,6 +977,35 @@ async fn test_outline_with_extra_context(cx: &mut gpui::TestAppContext) { ); } +#[gpui::test] +async fn test_outline_selection_range_for_multiline_c_signature(cx: &mut gpui::TestAppContext) { + let text = indoc! {" + void + evdev_post_scroll(struct evdev_device *device, + usec_t time, + enum libinput_pointer_axis_source source, + const struct normalized_coords *delta) + { + return; + } + "}; + + let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(c_lang(), cx)); + let snapshot = buffer.update(cx, |buffer, _| buffer.snapshot()); + let outline = snapshot.outline(None); + + let item = outline + .items + .iter() + .find(|item| item.text.contains("evdev_post_scroll")) + .unwrap() + .to_point(&snapshot); + + assert_eq!(item.source_range_for_text.start, Point::new(0, 0)); + assert_eq!(item.selection_range.start, Point::new(1, 0)); + assert_eq!(item.text, "void evdev_post_scroll( )"); +} + #[gpui::test] fn test_outline_annotations(cx: &mut App) { // Add this new test case @@ -4049,6 +4078,20 @@ fn javascript_lang() -> Language { .unwrap() } +fn c_lang() -> Arc { + Arc::new( + Language::new( + LanguageConfig { + name: "C".into(), + ..Default::default() + }, + Some(tree_sitter_c::LANGUAGE.into()), + ) + .with_outline_query(include_str!("../../grammars/src/c/outline.scm")) + .unwrap(), + ) +} + pub fn markdown_inline_lang() -> Language { Language::new( LanguageConfig { diff --git a/crates/language/src/outline.rs b/crates/language/src/outline.rs index 5ff5c028ff..c85b7be78f 100644 --- a/crates/language/src/outline.rs +++ b/crates/language/src/outline.rs @@ -19,6 +19,7 @@ pub struct Outline { pub struct OutlineItem { pub depth: usize, pub range: Range, + pub selection_range: Range, pub source_range_for_text: Range, pub text: SharedString, pub highlight_ranges: Vec<(Range, HighlightStyle)>, @@ -71,6 +72,8 @@ impl OutlineItem { OutlineItem { depth: self.depth, range: self.range.start.to_point(buffer)..self.range.end.to_point(buffer), + selection_range: self.selection_range.start.to_point(buffer) + ..self.selection_range.end.to_point(buffer), source_range_for_text: self.source_range_for_text.start.to_point(buffer) ..self.source_range_for_text.end.to_point(buffer), text: self.text.clone(), @@ -315,6 +318,7 @@ mod tests { let item = OutlineItem { depth: 0, range: range.clone(), + selection_range: range.clone(), source_range_for_text: range, text: "completion".into(), highlight_ranges: Vec::new(), @@ -332,6 +336,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), + selection_range: Point::new(0, 6)..Point::new(0, 9), source_range_for_text: Point::new(0, 0)..Point::new(0, 9), text: "class Foo".into(), highlight_ranges: vec![], @@ -342,6 +347,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(2, 0)..Point::new(2, 7), + selection_range: Point::new(2, 0)..Point::new(2, 7), source_range_for_text: Point::new(0, 0)..Point::new(0, 7), text: "private".into(), highlight_ranges: vec![], @@ -373,6 +379,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(0, 0)..Point::new(5, 0), + selection_range: Point::new(0, 3)..Point::new(0, 10), source_range_for_text: Point::new(0, 0)..Point::new(0, 10), text: "fn process".into(), highlight_ranges: vec![], @@ -383,6 +390,7 @@ mod tests { OutlineItem { depth: 0, range: Point::new(7, 0)..Point::new(12, 0), + selection_range: Point::new(0, 7)..Point::new(0, 20), source_range_for_text: Point::new(0, 0)..Point::new(0, 20), text: "struct DataProcessor".into(), highlight_ranges: vec![], diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 4b776b2d81..5b61e6237e 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -6293,6 +6293,7 @@ impl MultiBufferSnapshot { .map(|item| OutlineItem { depth: item.depth, range: Anchor::range_in_buffer(path_key_index, item.range), + selection_range: Anchor::range_in_buffer(path_key_index, item.selection_range), source_range_for_text: Anchor::range_in_buffer( path_key_index, item.source_range_for_text, @@ -6335,6 +6336,10 @@ impl MultiBufferSnapshot { .flat_map(|item| { Some(OutlineItem { depth: item.depth, + selection_range: Anchor::range_in_buffer( + excerpt.path_key_index, + item.selection_range, + ), source_range_for_text: Anchor::range_in_buffer( excerpt.path_key_index, item.source_range_for_text, diff --git a/crates/outline/src/outline.rs b/crates/outline/src/outline.rs index c3a5cc4804..20662f322f 100644 --- a/crates/outline/src/outline.rs +++ b/crates/outline/src/outline.rs @@ -90,6 +90,8 @@ fn outline_for_editor( depth: item.depth, range: multibuffer.anchor_in_buffer(item.range.start)? ..multibuffer.anchor_in_buffer(item.range.end)?, + selection_range: multibuffer.anchor_in_buffer(item.selection_range.start)? + ..multibuffer.anchor_in_buffer(item.selection_range.end)?, source_range_for_text: multibuffer .anchor_in_buffer(item.source_range_for_text.start)? ..multibuffer.anchor_in_buffer(item.source_range_for_text.end)?, diff --git a/crates/outline_panel/src/outline_panel.rs b/crates/outline_panel/src/outline_panel.rs index 6bd05b807d..a8786aaa40 100644 --- a/crates/outline_panel/src/outline_panel.rs +++ b/crates/outline_panel/src/outline_panel.rs @@ -2567,6 +2567,7 @@ impl OutlinePanel { depth, annotation_range: None, range: search_data.context_range.clone(), + selection_range: search_data.context_range.clone(), text: search_data.context_text.clone().into(), source_range_for_text: search_data.context_range.clone(), highlight_ranges: search_data diff --git a/crates/project/src/lsp_store/document_symbols.rs b/crates/project/src/lsp_store/document_symbols.rs index e993865d4f..98f66d9fdb 100644 --- a/crates/project/src/lsp_store/document_symbols.rs +++ b/crates/project/src/lsp_store/document_symbols.rs @@ -270,6 +270,7 @@ fn flatten_document_symbols( output.push(OutlineItem { depth, range, + selection_range: selection_range.clone(), source_range_for_text, text: text.into(), highlight_ranges: Vec::new(), @@ -327,7 +328,7 @@ fn enriched_symbol_text( mod tests { use super::*; use gpui::TestAppContext; - use text::Unclipped; + use text::{OffsetRangeExt, Point, Unclipped}; fn make_symbol( name: &str, @@ -422,22 +423,42 @@ mod tests { assert_eq!(items[0].depth, 0); assert_eq!(items[0].text, "struct Foo"); assert_eq!(items[0].name_ranges, vec![7..10]); + assert_eq!( + items[0].selection_range.to_point(&snapshot), + Point::new(0, 7)..Point::new(0, 10) + ); assert_eq!(items[1].depth, 1); assert_eq!(items[1].text, "bar"); assert_eq!(items[1].name_ranges, vec![0..3]); + assert_eq!( + items[1].selection_range.to_point(&snapshot), + Point::new(1, 4)..Point::new(1, 7) + ); assert_eq!(items[2].depth, 1); assert_eq!(items[2].text, "baz"); assert_eq!(items[2].name_ranges, vec![0..3]); + assert_eq!( + items[2].selection_range.to_point(&snapshot), + Point::new(2, 4)..Point::new(2, 7) + ); assert_eq!(items[3].depth, 0); assert_eq!(items[3].text, "impl Foo"); assert_eq!(items[3].name_ranges, vec![5..8]); + assert_eq!( + items[3].selection_range.to_point(&snapshot), + Point::new(5, 5)..Point::new(5, 8) + ); assert_eq!(items[4].depth, 1); assert_eq!(items[4].text, "fn new"); assert_eq!(items[4].name_ranges, vec![3..6]); + assert_eq!( + items[4].selection_range.to_point(&snapshot), + Point::new(6, 7)..Point::new(6, 10) + ); } #[gpui::test]