From 9b5d170ecab57e38ecf3ab32a4826053158bd1bc 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 7cc41752f34d719c27f5954c41f26fa9febfde94..7451aaced9072d3f60483a3d1091caa38f92294b 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 204011412ec9b229ffdd49195e907369baa2d97f..da99dc45001548627317c3d2133859860a971a47 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 683995e8ff0817e9f11c276fba1e85eef29eee7a..04d3babf20f5c866dd3f3447f0909f11becf724d 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 ab00de0df25ca209604c7052367f0ac6ce2142ae..7128c60b7f45147f99b6f46d3bd85b9428d358ef 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) {