Add actions to move to start and end of larger syntax node (#45331)

Release Notes:

- Added two actions `move_to_start_of_larger_syntax_node` and
`move_to_end_of_larger_syntax_node` that move cursors to the start or
end of the parent tree-sitter node

Following up on my PR #41321, this PR only adds the actions that are
used to enable code navigation across syntax nodes, without binding them
to any keys (such as tab) by default. Both actions use the tree-sitter
syntax tree to find parent nodes of the nodes the cursors are currently
in. `move_to_start_of_larger_syntax_node` will then move each cursor to
the first position of the parent nodes while
`move_to_end_of_larger_syntax_node` to a position right after the parent
nodes.

Related issues and discussions: #22349, #14803, #42828, #13736.

This PR doesn't achieve "tab out" functionality in the exact sense as is
requested in these issues as it does not bind the actions to the tab
key. I hope this PR can start some discussion on what the best way
forward for these issues is. In the meantime, users can configure keys
to use these actions as they see fit to emulate "tab out" behavior. For
example,
```
"context": "Editor && vim_mode == insert && !in_snippet && !showing_completions",
"bindings": {
  "tab": "editor::MoveToEndOfLargerSyntaxNode",
  "shift-tab": "editor::Tab"
}
```
This will enable tab to skip past code structures like brackets when the
cursor is not in a snippet or the autocomplete menu is not open. At the
same time, shift tab will act as a backup tab.
This commit is contained in:
Vamsi Raman Deeduvanu authored and GitHub committed 2026-01-13 21:01:00 -07:00
1 parent 21b868ff12
commit 76072c72f6
4 files changed
+591

No files matched your search

+4
View File
@@ -638,6 +638,10 @@ actions!(
MoveToEndOfExcerpt,
/// Moves cursor to the end of the previous excerpt.
MoveToEndOfPreviousExcerpt,
/// Moves cursor to the start of the next larger syntax node.
MoveToStartOfLargerSyntaxNode,
/// Moves cursor to the end of the next larger syntax node.
MoveToEndOfLargerSyntaxNode,
/// Moves cursor up.
MoveUp,
/// Inserts a new line and moves cursor to it.
+113
View File
@@ -16375,6 +16375,119 @@ impl Editor {
}
}
pub fn move_to_start_of_larger_syntax_node(
&mut self,
_: &MoveToStartOfLargerSyntaxNode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.move_cursors_to_syntax_nodes(window, cx, false);
}
pub fn move_to_end_of_larger_syntax_node(
&mut self,
_: &MoveToEndOfLargerSyntaxNode,
window: &mut Window,
cx: &mut Context<Self>,
) {
self.move_cursors_to_syntax_nodes(window, cx, true);
}
fn move_cursors_to_syntax_nodes(
&mut self,
window: &mut Window,
cx: &mut Context<Self>,
move_to_end: bool,
) -> bool {
let old_selections: Box<[_]> = self
.selections
.all::<MultiBufferOffset>(&self.display_snapshot(cx))
.into();
if old_selections.is_empty() {
return false;
}
self.hide_mouse_cursor(HideMouseCursorOrigin::MovementAction, cx);
let display_map = self.display_map.update(cx, |map, cx| map.snapshot(cx));
let buffer = self.buffer.read(cx).snapshot(cx);
let mut any_cursor_moved = false;
let new_selections = old_selections
.iter()
.map(|selection| {
if !selection.is_empty() {
return selection.clone();
}
let selection_pos = selection.head();
let old_range = selection_pos..selection_pos;
let mut new_pos = selection_pos;
let mut search_range = old_range;
while let Some((node, range)) = buffer.syntax_ancestor(search_range.clone()) {
search_range = range.clone();
if !node.is_named()
|| display_map.intersects_fold(range.start)
|| display_map.intersects_fold(range.end)
// If cursor is already at the end of the syntax node, continue searching
|| (move_to_end && range.end == selection_pos)
// If cursor is already at the start of the syntax node, continue searching
|| (!move_to_end && range.start == selection_pos)
{
continue;
}
// If we found a string_content node, find the largest parent that is still string_content
// Enables us to skip to the end of strings without taking multiple steps inside the string
let (_, final_range) = if node.kind() == "string_content" {
let mut current_node = node;
let mut current_range = range;
while let Some((parent, parent_range)) =
buffer.syntax_ancestor(current_range.clone())
{
if parent.kind() == "string_content" {
current_node = parent;
current_range = parent_range;
} else {
break;
}
}
(current_node, current_range)
} else {
(node, range)
};
new_pos = if move_to_end {
final_range.end
} else {
final_range.start
};
break;
}
any_cursor_moved |= new_pos != selection_pos;
Selection {
id: selection.id,
start: new_pos,
end: new_pos,
goal: SelectionGoal::None,
reversed: false,
}
})
.collect::<Vec<_>>();
self.change_selections(Default::default(), window, cx, |s| {
s.select(new_selections);
});
self.request_autoscroll(Autoscroll::newest(), cx);
any_cursor_moved
}
fn refresh_runnables(&mut self, window: &mut Window, cx: &mut Context<Self>) -> Task<()> {
if !EditorSettings::get_global(cx).gutter.runnables {
self.clear_tasks();
+472
View File
@@ -30399,3 +30399,475 @@ async fn test_diff_review_button_shown_when_ai_enabled(cx: &mut TestAppContext)
);
});
}
#[gpui::test]
async fn test_move_to_start_end_of_larger_syntax_node_single_cursor(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let language = Arc::new(Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::LANGUAGE.into()),
));
let text = r#"
fn main() {
let x = foo(1, 2);
}
"#
.unindent();
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
editor
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
.await;
// Test case 1: Move to end of syntax nodes
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 16)..DisplayPoint::new(DisplayRow(1), 16)
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(ˇ1, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1ˇ, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2)ˇ;
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);ˇ
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);
}ˇ
"#},
cx,
);
});
// Test case 2: Move to start of syntax nodes
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20)
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2ˇ);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = fooˇ(1, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = ˇfoo(1, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
ˇlet x = foo(1, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() ˇ{
let x = foo(1, 2);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
ˇfn main() {
let x = foo(1, 2);
}
"#},
cx,
);
});
}
#[gpui::test]
async fn test_move_to_start_end_of_larger_syntax_node_two_cursors(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let language = Arc::new(Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::LANGUAGE.into()),
));
let text = r#"
fn main() {
let x = foo(1, 2);
let y = bar(3, 4);
}
"#
.unindent();
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
editor
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
.await;
// Test case 1: Move to end of syntax nodes with two cursors
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 20)..DisplayPoint::new(DisplayRow(1), 20),
DisplayPoint::new(DisplayRow(2), 20)..DisplayPoint::new(DisplayRow(2), 20),
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2ˇ);
let y = bar(3, 4ˇ);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2)ˇ;
let y = bar(3, 4)ˇ;
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);ˇ
let y = bar(3, 4);ˇ
}
"#},
cx,
);
});
// Test case 2: Move to start of syntax nodes with two cursors
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 19)..DisplayPoint::new(DisplayRow(1), 19),
DisplayPoint::new(DisplayRow(2), 19)..DisplayPoint::new(DisplayRow(2), 19),
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, ˇ2);
let y = bar(3, ˇ4);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = fooˇ(1, 2);
let y = barˇ(3, 4);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = ˇfoo(1, 2);
let y = ˇbar(3, 4);
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
ˇlet x = foo(1, 2);
ˇlet y = bar(3, 4);
}
"#},
cx,
);
});
}
#[gpui::test]
async fn test_move_to_start_end_of_larger_syntax_node_with_selections_and_strings(
cx: &mut TestAppContext,
) {
init_test(cx, |_| {});
let language = Arc::new(Language::new(
LanguageConfig::default(),
Some(tree_sitter_rust::LANGUAGE.into()),
));
let text = r#"
fn main() {
let x = foo(1, 2);
let msg = "hello world";
}
"#
.unindent();
let buffer = cx.new(|cx| Buffer::local(text, cx).with_language(language, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx));
editor
.condition::<crate::EditorEvent>(cx, |editor, cx| !editor.buffer.read(cx).is_parsing(cx))
.await;
// Test case 1: With existing selection, move_to_end keeps selection
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(1), 12)..DisplayPoint::new(DisplayRow(1), 21)
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = «foo(1, 2)ˇ»;
let msg = "hello world";
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = «foo(1, 2)ˇ»;
let msg = "hello world";
}
"#},
cx,
);
});
// Test case 2: Move to end within a string
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(2), 15)..DisplayPoint::new(DisplayRow(2), 15)
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);
let msg = "ˇhello world";
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_end_of_larger_syntax_node(&MoveToEndOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);
let msg = "hello worldˇ";
}
"#},
cx,
);
});
// Test case 3: Move to start within a string
editor.update_in(cx, |editor, window, cx| {
editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
s.select_display_ranges([
DisplayPoint::new(DisplayRow(2), 21)..DisplayPoint::new(DisplayRow(2), 21)
]);
});
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);
let msg = "hello ˇworld";
}
"#},
cx,
);
});
editor.update_in(cx, |editor, window, cx| {
editor.move_to_start_of_larger_syntax_node(&MoveToStartOfLargerSyntaxNode, window, cx);
});
editor.update(cx, |editor, cx| {
assert_text_with_selections(
editor,
indoc! {r#"
fn main() {
let x = foo(1, 2);
let msg = "ˇhello world";
}
"#},
cx,
);
});
}
+2
View File
@@ -382,6 +382,8 @@ impl EditorElement {
register_action(editor, window, Editor::select_next_syntax_node);
register_action(editor, window, Editor::select_prev_syntax_node);
register_action(editor, window, Editor::unwrap_syntax_node);
register_action(editor, window, Editor::move_to_start_of_larger_syntax_node);
register_action(editor, window, Editor::move_to_end_of_larger_syntax_node);
register_action(editor, window, Editor::select_enclosing_symbol);
register_action(editor, window, Editor::move_to_enclosing_bracket);
register_action(editor, window, Editor::undo_selection);