From 002bb34dbc9beca7ab3bb35fe977c99dac8258e3 Mon Sep 17 00:00:00 2001 From: Karthik Nishanth <7759435+nishanthkarthik@users.noreply.github.com> Date: Mon, 16 Mar 2026 10:01:28 -0700 Subject: [PATCH] editor: Go to previous and next symbol actions (#50777) Closes discussion #34890 This is similar to the vim prev/next method/section motion, but more flexible because this follows the items in editor's outline (Tree sitter or LSP provided). Before you mark this PR as ready for review, make sure that you have: - [x] Added a solid test coverage and/or screenshots from doing manual testing - [x] Done a self-review taking into account security and performance aspects - [x] Aligned any UI changes with the [UI checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) Release Notes: - Added actions `editor::GoToPreviousSymbol` and `editor::GoToNextSymbol` actions to go to the previous and next outline symbol. This is either the tree sitter outline, or the LSP provided outline depending on the configuration. --- crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 102 ++++++++++++ crates/editor/src/editor_tests.rs | 254 ++++++++++++++++++++++++++++++ crates/editor/src/element.rs | 2 + 4 files changed, 362 insertions(+) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 7cc41752f3..7451aaced9 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -568,6 +568,10 @@ actions!( GoToParentModule, /// Goes to the previous change in the file. GoToPreviousChange, + /// Goes to the next symbol. + GoToNextSymbol, + /// Goes to the previous symbol. + GoToPreviousSymbol, /// Goes to the next reference to the symbol under the cursor. GoToNextReference, /// Goes to the previous reference to the symbol under the cursor. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 204011412e..da99dc4500 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -18125,6 +18125,108 @@ impl Editor { }; } + fn go_to_symbol_by_offset( + &mut self, + window: &mut Window, + cx: &mut Context, + offset: i8, + ) -> Task> { + let editor_snapshot = self.snapshot(window, cx); + + // We don't care about multi-buffer symbols + let Some((excerpt_id, _, _)) = editor_snapshot.as_singleton() else { + return Task::ready(Ok(())); + }; + + let cursor_offset = self + .selections + .newest::(&editor_snapshot.display_snapshot) + .head(); + + cx.spawn_in(window, async move |editor, wcx| -> Result<()> { + let Ok(Some(remote_id)) = editor.update(wcx, |ed, cx| { + let buffer = ed.buffer.read(cx).as_singleton()?; + Some(buffer.read(cx).remote_id()) + }) else { + return Ok(()); + }; + + let task = editor.update(wcx, |ed, cx| ed.buffer_outline_items(remote_id, cx))?; + let outline_items: Vec> = task.await; + + let multi_snapshot = editor_snapshot.buffer(); + let buffer_range = |range: &Range<_>| { + Anchor::range_in_buffer(excerpt_id, range.clone()).to_offset(multi_snapshot) + }; + + wcx.update_window(wcx.window_handle(), |_, window, acx| { + let current_idx = outline_items + .iter() + .enumerate() + .filter_map(|(idx, item)| { + // Find the closest outline item by distance between outline text and cursor location + let source_range = buffer_range(&item.source_range_for_text); + let distance_to_closest_endpoint = cmp::min( + (source_range.start.0 as isize - cursor_offset.0 as isize).abs(), + (source_range.end.0 as isize - cursor_offset.0 as isize).abs(), + ); + + let item_towards_offset = + (source_range.start.0 as isize - cursor_offset.0 as isize).signum() + == (offset as isize).signum(); + + let source_range_contains_cursor = source_range.contains(&cursor_offset); + + // To pick the next outline to jump to, we should jump in the direction of the offset, and + // we should not already be within the outline's source range. We then pick the closest outline + // item. + (item_towards_offset && !source_range_contains_cursor) + .then_some((distance_to_closest_endpoint, idx)) + }) + .min() + .map(|(_, idx)| idx); + + let Some(idx) = current_idx else { + return; + }; + + let range = buffer_range(&outline_items[idx].source_range_for_text); + let selection = [range.start..range.start]; + + let _ = editor + .update(acx, |editor, ecx| { + editor.change_selections( + SelectionEffects::scroll(Autoscroll::newest()), + window, + ecx, + |s| s.select_ranges(selection), + ); + }) + .ok(); + })?; + + Ok(()) + }) + } + + fn go_to_next_symbol( + &mut self, + _: &GoToNextSymbol, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_symbol_by_offset(window, cx, 1).detach(); + } + + fn go_to_previous_symbol( + &mut self, + _: &GoToPreviousSymbol, + window: &mut Window, + cx: &mut Context, + ) { + self.go_to_symbol_by_offset(window, cx, -1).detach(); + } + pub fn go_to_reference_before_or_after_position( &mut self, direction: Direction, diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 683995e8ff..04d3babf20 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -19328,6 +19328,260 @@ fn test_split_words_for_snippet_prefix() { assert_eq!(split("a.s"), &["s", ".s", "a.s"]); } +#[gpui::test] +async fn test_move_to_syntax_node_relative_jumps(tcx: &mut TestAppContext) { + init_test(tcx, |_| {}); + + let mut cx = EditorLspTestContext::new( + Arc::into_inner(markdown_lang()).unwrap(), + Default::default(), + tcx, + ) + .await; + + async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) + .await + .unwrap(); + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + const ABOVE: i8 = -1; + const BELOW: i8 = 1; + + assert( + ABOVE, + indoc! {" + # Foo + + ˇFoo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + ABOVE, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + ˇ# Foo + + Foo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + ˇFoo foo foo + + # Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + ˇ# Bar + + Bar bar bar + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + # Foo + + Foo foo foo + + # Bar + ˇ + Bar bar bar + "}, + indoc! {" + # Foo + + Foo foo foo + + # Bar + ˇ + Bar bar bar + "}, + &mut cx, + ) + .await; +} + +#[gpui::test] +async fn test_move_to_syntax_node_relative_dead_zone(tcx: &mut TestAppContext) { + init_test(tcx, |_| {}); + + let mut cx = EditorLspTestContext::new( + Arc::into_inner(rust_lang()).unwrap(), + Default::default(), + tcx, + ) + .await; + + async fn assert(offset: i8, before: &str, after: &str, cx: &mut EditorLspTestContext) { + let _state_context = cx.set_state(before); + cx.run_until_parked(); + cx.update_editor(|editor, window, cx| editor.go_to_symbol_by_offset(window, cx, offset)) + .await + .unwrap(); + cx.run_until_parked(); + cx.assert_editor_state(after); + } + + const ABOVE: i8 = -1; + const BELOW: i8 = 1; + + assert( + ABOVE, + indoc! {" + fn foo() { + // foo fn + } + + ˇ// this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + indoc! {" + ˇfn foo() { + // foo fn + } + + // this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + &mut cx, + ) + .await; + + assert( + BELOW, + indoc! {" + fn foo() { + // foo fn + } + + ˇ// this zone is not inside any top level outline node + + fn bar() { + // bar fn + let _ = 2; + } + "}, + indoc! {" + fn foo() { + // foo fn + } + + // this zone is not inside any top level outline node + + ˇfn bar() { + // bar fn + let _ = 2; + } + "}, + &mut cx, + ) + .await; +} + #[gpui::test] async fn test_move_to_enclosing_bracket(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index ab00de0df2..7128c60b7f 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -540,6 +540,8 @@ impl EditorElement { register_action(editor, window, Editor::go_to_next_change); register_action(editor, window, Editor::go_to_prev_reference); register_action(editor, window, Editor::go_to_next_reference); + register_action(editor, window, Editor::go_to_previous_symbol); + register_action(editor, window, Editor::go_to_next_symbol); register_action(editor, window, |editor, action, window, cx| { if let Some(task) = editor.format(action, window, cx) {