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.
This commit is contained in:
Karthik Nishanth authored and GitHub committed 2026-03-16 19:01:28 +02:00
1 parent 9c8d9871c1
commit 002bb34dbc
4 files changed
+362

No files matched your search

+4
View File
@@ -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.
+102
View File
@@ -18125,6 +18125,108 @@ impl Editor {
};
}
fn go_to_symbol_by_offset(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
offset: i8,
) -> Task<Result<()>> {
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::<MultiBufferOffset>(&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<OutlineItem<text::Anchor>> = 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>,
) {
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>,
) {
self.go_to_symbol_by_offset(window, cx, -1).detach();
}
pub fn go_to_reference_before_or_after_position(
&mut self,
direction: Direction,
+254
View File
@@ -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, |_| {});
+2
View File
@@ -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) {