vim: Add Helix _ action to trim whitespace from selections (#55253)

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é <tom@tomhoule.com>
This commit is contained in:
Jorge Gomez 2026-07-28 09:33:21 -04:00 committed by GitHub
parent b2131e9df8
commit b6ebe0ff35
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 134 additions and 0 deletions

View file

@ -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",

View file

@ -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>) {
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<Self>,
) {
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::<MultiBufferOffset>(&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::<MultiBufferOffset>(&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>) {
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);
}
}