From 19099e808c3941ca4ff80c5e5bda9e7dea222491 Mon Sep 17 00:00:00 2001 From: Anthony Eid <56899983+Anthony-Eid@users.noreply.github.com> Date: Wed, 29 Oct 2025 11:26:09 -0400 Subject: [PATCH] editor: Add action to move between snippet tabstop positions (#41466) Closes #41407 This solves a problem where users couldn't navigate between snippet tabstops while the completion menu was open. I named the action {Next, Previous}SnippetTabstop instead of Placeholder to be more inline with the LSP spec naming convention and our codebase names. Release Notes: - Editor: Add actions to move between snippet tabstop positions --- assets/keymaps/default-linux.json | 8 ++ assets/keymaps/default-macos.json | 8 ++ assets/keymaps/default-windows.json | 8 ++ crates/editor/src/actions.rs | 4 + crates/editor/src/editor.rs | 36 ++++++++ crates/editor/src/editor_tests.rs | 123 ++++++++++++++++++++++++++++ crates/editor/src/element.rs | 2 + 7 files changed, 189 insertions(+) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 4108e601d45f29262896cce036abb08acd17b4f3..d745474e09e1730127522e8c3170356864fd83b2 100644 --- a/assets/keymaps/default-linux.json +++ b/assets/keymaps/default-linux.json @@ -731,6 +731,14 @@ "tab": "editor::ComposeCompletion" } }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, // Bindings for accepting edit predictions // // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 65092df2496cd3c40847a4cbf164e26973648d44..50fa44be02703e0a0935e14de501070c53c4df87 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -801,6 +801,14 @@ "tab": "editor::ComposeCompletion" } }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, { "context": "Editor && edit_prediction", "bindings": { diff --git a/assets/keymaps/default-windows.json b/assets/keymaps/default-windows.json index f867517027e12e692683f48723c0f188c5aec48d..ef454ff12d2a437bda4b3fba0f214651a0c74396 100644 --- a/assets/keymaps/default-windows.json +++ b/assets/keymaps/default-windows.json @@ -736,6 +736,14 @@ "tab": "editor::ComposeCompletion" } }, + { + "context": "Editor && in_snippet", + "use_key_equivalents": true, + "bindings": { + "alt-right": "editor::NextSnippetTabstop", + "alt-left": "editor::PreviousSnippetTabstop" + } + }, // Bindings for accepting edit predictions // // alt-l is provided as an alternative to tab/alt-tab. and will be displayed in the UI. This is diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 38ae42c3814fa09e50a92dcc20f0a34bad82ea40..276f20a7aacc9315f27a929876984342edc8d394 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -621,6 +621,8 @@ actions!( NextEditPrediction, /// Scrolls to the next screen. NextScreen, + /// Goes to the next snippet tabstop if one exists. + NextSnippetTabstop, /// Opens the context menu at cursor position. OpenContextMenu, /// Opens excerpts from the current file. @@ -654,6 +656,8 @@ actions!( Paste, /// Navigates to the previous edit prediction. PreviousEditPrediction, + /// Goes to the previous snippet tabstop if one exists. + PreviousSnippetTabstop, /// Redoes the last undone edit. Redo, /// Redoes the last selection change. diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index bcabf122400e1604aac59c70007aaa472a5a8787..3839da917078ae2340ead97f9cf4fa624b5c588a 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -2439,6 +2439,10 @@ impl Editor { key_context.add("renaming"); } + if !self.snippet_stack.is_empty() { + key_context.add("in_snippet"); + } + match self.context_menu.borrow().as_ref() { Some(CodeContextMenu::Completions(menu)) => { if menu.visible() { @@ -9947,6 +9951,38 @@ impl Editor { self.outdent(&Outdent, window, cx); } + pub fn next_snippet_tabstop( + &mut self, + _: &NextSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + return; + } + + if self.move_to_next_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + } + + pub fn previous_snippet_tabstop( + &mut self, + _: &PreviousSnippetTabstop, + window: &mut Window, + cx: &mut Context, + ) { + if self.mode.is_single_line() || self.snippet_stack.is_empty() { + return; + } + + if self.move_to_prev_snippet_tabstop(window, cx) { + self.hide_mouse_cursor(HideMouseCursorOrigin::TypingAction, cx); + return; + } + } + pub fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { if self.mode.is_single_line() { cx.propagate(); diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 1d277b8b99b5f60f02b450dcc06997b15cd37184..06fbd9d3381f70955049ddde1c7a395945d67c66 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -11066,6 +11066,129 @@ async fn test_snippet_placeholder_choices(cx: &mut TestAppContext) { }); } +#[gpui::test] +async fn test_snippet_tabstop_navigation_with_placeholders(cx: &mut TestAppContext) { + init_test(cx, |_| {}); + + fn assert_state(editor: &mut Editor, cx: &mut Context, marked_text: &str) { + let (expected_text, selection_ranges) = marked_text_ranges(marked_text, false); + assert_eq!(editor.text(cx), expected_text); + assert_eq!( + editor + .selections + .ranges::(&editor.display_snapshot(cx)), + selection_ranges + ); + } + + let (text, insertion_ranges) = marked_text_ranges( + indoc! {" + ˇ + "}, + false, + ); + + let buffer = cx.update(|cx| MultiBuffer::build_simple(&text, cx)); + let (editor, cx) = cx.add_window_view(|window, cx| build_editor(buffer, window, cx)); + + _ = editor.update_in(cx, |editor, window, cx| { + let snippet = Snippet::parse("type ${1|,i32,u32|} = $2; $3").unwrap(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state( + editor, + cx, + indoc! {" + type «» = ;• + "}, + ); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = «»;• + "}, + ); + + assert!( + !editor.context_menu_visible(), + "Context menu should be hidden after moving to next tabstop" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state( + editor, + cx, + indoc! {" + type = ; ˇ + "}, + ); + }); + + _ = editor.update_in(cx, |editor, window, cx| { + editor.select_all(&SelectAll, window, cx); + editor.backspace(&Backspace, window, cx); + + let snippet = Snippet::parse("fn ${1|,foo,bar|} = ${2:value}; $3").unwrap(); + let insertion_ranges = editor + .selections + .all(&editor.display_snapshot(cx)) + .iter() + .map(|s| s.range()) + .collect::>(); + + editor + .insert_snippet(&insertion_ranges, snippet, window, cx) + .unwrap(); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible for placeholder choices" + ); + + editor.next_snippet_tabstop(&NextSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn = «valueˇ»;•"); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + + assert!( + editor.context_menu_visible(), + "Context menu should be visible again after returning to first tabstop" + ); + + editor.previous_snippet_tabstop(&PreviousSnippetTabstop, window, cx); + + assert_state(editor, cx, "fn «» = value;•"); + }); +} + #[gpui::test] async fn test_snippets(cx: &mut TestAppContext) { init_test(cx, |_| {}); diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 101b424e4e99c7fdb4ce536d3635db61d8b3bc8e..17b9ea9ced8d34396426e0a2640904b6e8df97a4 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -232,6 +232,8 @@ impl EditorElement { register_action(editor, window, Editor::blame_hover); register_action(editor, window, Editor::delete); register_action(editor, window, Editor::tab); + register_action(editor, window, Editor::next_snippet_tabstop); + register_action(editor, window, Editor::previous_snippet_tabstop); register_action(editor, window, Editor::backtab); register_action(editor, window, Editor::indent); register_action(editor, window, Editor::outdent);