From b6ebe0ff35cf3beaecaae2a89ec15b2c4e26a182 Mon Sep 17 00:00:00 2001 From: Jorge Gomez Date: Tue, 28 Jul 2026 09:33:21 -0400 Subject: [PATCH] vim: Add Helix `_` action to trim whitespace from selections (#55253) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [X] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Closes #53517 Release Notes: - Added Helix trim whitespace from selections action on `_`. --------- Co-authored-by: Tom Houlé --- assets/keymaps/vim.json | 1 + crates/vim/src/helix.rs | 133 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 134 insertions(+) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 915e961e373..3acbbc73dad 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -487,6 +487,7 @@ "x": "vim::HelixSelectLine", "*": "buffer_search::UseSelectionForFind", "shift-x": "editor::SelectLine", + "_": "vim::HelixTrimSelections", "ctrl-c": "editor::ToggleComments", "alt-o": "editor::SelectLargerSyntaxNode", "alt-i": "editor::SelectSmallerSyntaxNode", diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index a0b9bf6f3cc..8de9fa58894 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -63,6 +63,9 @@ actions!( HelixSelectNext, /// Select the previous match for the current search query. HelixSelectPrevious, + /// Trim leading and trailing whitespace from each selection. + /// Originally-empty selections (cursors) are dropped before trimming. + HelixTrimSelections, ] ); @@ -89,6 +92,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { Vim::action(editor, cx, Vim::helix_jump_to_word); Vim::action(editor, cx, Vim::helix_select_next); Vim::action(editor, cx, Vim::helix_select_previous); + Vim::action(editor, cx, Vim::helix_trim_selections); Vim::action(editor, cx, |vim, _: &PushHelixSurroundAdd, window, cx| { vim.clear_operator(window, cx); vim.push_operator(Operator::HelixSurroundAdd, window, cx); @@ -912,6 +916,94 @@ impl Vim { }); } + fn helix_trim_selections( + &mut self, + _: &HelixTrimSelections, + window: &mut Window, + cx: &mut Context, + ) { + Vim::take_count(cx); + Vim::take_forced_motion(cx); + self.update_editor(cx, |_, editor, cx| { + let display_snapshot = editor.display_snapshot(cx); + let buffer = display_snapshot.buffer_snapshot(); + let selections = editor + .selections + .all::(&display_snapshot); + let mut trimmed = Vec::new(); + + for selection in &selections { + if selection.is_empty() { + continue; + } + let all_whitespace = buffer + .text_for_range(selection.start..selection.end) + .flat_map(|chunk| chunk.chars()) + .all(|ch| ch.is_whitespace()); + if all_whitespace { + continue; + } + + let mut new_start = selection.start; + for ch in buffer.chars_at(selection.start) { + if !ch.is_whitespace() { + break; + } + new_start += ch.len_utf8(); + } + + let mut new_end = selection.end; + for ch in buffer.reversed_chars_at(selection.end) { + if !ch.is_whitespace() { + break; + } + new_end -= ch.len_utf8(); + } + + let mut new_selection = selection.clone(); + new_selection.start = new_start; + new_selection.end = new_end; + trimmed.push(new_selection); + } + + if !trimmed.is_empty() { + editor.change_selections(Default::default(), window, cx, |s| { + s.select(trimmed); + }); + return; + } + + // All selections were empty or entirely whitespace. Match Helix's + // fallback: `collapse_selection` followed by `keep_primary_selection`. + // Take the newest selection and collapse it to a one-char cursor at + // the head (head - 1 for a forward, non-empty selection; head as-is + // otherwise). + let newest = editor + .selections + .newest::(&display_snapshot); + let head = if newest.reversed { + newest.start + } else { + newest.end + }; + let cursor_offset = if !newest.reversed && !newest.is_empty() { + let mut p = head; + if let Some(ch) = buffer.reversed_chars_at(head).next() { + p -= ch.len_utf8(); + } + p + } else { + head + }; + let mut collapsed = newest; + collapsed.start = cursor_offset; + collapsed.end = cursor_offset; + editor.change_selections(Default::default(), window, cx, |s| { + s.select(vec![collapsed]); + }); + }); + } + fn do_helix_substitute(&mut self, yank: bool, window: &mut Window, cx: &mut Context) { self.update_editor(cx, |vim, editor, cx| { editor.set_clip_at_line_ends(false, cx); @@ -4472,4 +4564,45 @@ mod test { cx.assert_state("const after = 2; console.log(afterˇ)", Mode::HelixNormal); } + + #[gpui::test] + async fn test_helix_trim_selections(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // Canonical case: a line selection (as produced by `x`) including its + // trailing newline. `_` strips the leading indent and trailing newline. + cx.set_state("« indented line\nˇ»next line\n", Mode::HelixNormal); + cx.simulate_keystrokes("_"); + cx.assert_state(" «indented lineˇ»\nnext line\n", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_helix_trim_selections_all_whitespace(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // An entirely-whitespace selection is filtered out before trimming. + // When every selection filters out, Helix falls back to + // `collapse_selection` + `keep_primary_selection`, which collapses the + // primary selection to a one-char cursor at head - 1 (the last + // whitespace char of the original selection). + cx.set_state("aa« ˇ»next\n", Mode::HelixNormal); + cx.simulate_keystrokes("_"); + cx.assert_state("aa ˇ next\n", Mode::HelixNormal); + } + + #[gpui::test] + async fn test_helix_trim_selections_consumes_count(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + cx.enable_helix(); + + // A count is meaningless for `_`, but it must not leak into the next + // command. + cx.set_state("« aa ˇ»\nbbb\nccc\n", Mode::HelixNormal); + cx.simulate_keystrokes("2 _"); + cx.assert_state(" «aaˇ» \nbbb\nccc\n", Mode::HelixNormal); + cx.simulate_keystrokes("x"); + cx.assert_state("« aa \nˇ»bbb\nccc\n", Mode::HelixNormal); + } }