From 58ff46962d204097336cfc91c59ffa363fdf603e Mon Sep 17 00:00:00 2001 From: jneem Date: Thu, 16 Oct 2025 12:23:09 -0500 Subject: [PATCH] Add a helix-specific substitute method (#38735) `vim::Substitute` is a little different from the helix behavior, so this PR adds helix versions. The most important difference (for my usage, at least) is that if you're selecting whole lines then helix drops the `\n` from the selection (much like vim's lines mode, except that helix bases this behavior on the selection instead of having a different mode). Release Notes: - N/A --- assets/keymaps/vim.json | 3 +- crates/vim/src/helix.rs | 119 +++++++++++++++++++++++++++++++++++++++- 2 files changed, 120 insertions(+), 2 deletions(-) diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index d9e13e36b4eb5b5d04c8e51adadc516054dc0775..ac83f906627912e0938e892ca0a8afcac395b856 100644 --- a/assets/keymaps/vim.json +++ b/assets/keymaps/vim.json @@ -497,7 +497,8 @@ "shift-u": "editor::Redo", "ctrl-c": "editor::ToggleComments", "d": "vim::HelixDelete", - "c": "vim::Substitute", + "c": "vim::HelixSubstitute", + "alt-c": "vim::HelixSubstituteNoYank", "shift-c": "vim::HelixDuplicateBelow", "alt-shift-c": "vim::HelixDuplicateAbove", ",": "vim::HelixKeepNewestSelection" diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 764a00d3487dd6f6c83112e586ed1f1e8c97307a..ed7abaa11c1a6b95e436b019de1605792c82fc9d 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -18,7 +18,7 @@ use text::{Bias, SelectionGoal}; use workspace::searchable; use workspace::searchable::FilteredSearchRange; -use crate::motion; +use crate::motion::{self, MotionKind}; use crate::state::SearchState; use crate::{ Vim, @@ -48,6 +48,10 @@ actions!( HelixDuplicateBelow, /// Copies all selections above. HelixDuplicateAbove, + /// Delete the selection and enter edit mode. + HelixSubstitute, + /// Delete the selection and enter edit mode, without yanking the selection. + HelixSubstituteNoYank, ] ); @@ -68,6 +72,8 @@ pub fn register(editor: &mut Editor, cx: &mut Context) { let times = Vim::take_count(cx); vim.helix_duplicate_selections_above(times, window, cx); }); + Vim::action(editor, cx, Vim::helix_substitute); + Vim::action(editor, cx, Vim::helix_substitute_no_yank); } impl Vim { @@ -604,6 +610,54 @@ impl Vim { editor.change_selections(Default::default(), window, cx, |s| s.select(vec![newest])); }); } + + 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); + editor.transact(window, cx, |editor, window, cx| { + editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| { + s.move_with(|map, selection| { + if selection.start == selection.end { + selection.end = movement::right(map, selection.end); + } + + // If the selection starts and ends on a newline, we exclude the last one. + if !selection.is_empty() + && selection.start.column() == 0 + && selection.end.column() == 0 + { + selection.end = movement::left(map, selection.end); + } + }) + }); + if yank { + vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx); + } + let selections = editor.selections.all::(cx).into_iter(); + let edits = selections.map(|selection| (selection.start..selection.end, "")); + editor.edit(edits, cx); + }); + }); + self.switch_mode(Mode::Insert, true, window, cx); + } + + fn helix_substitute( + &mut self, + _: &HelixSubstitute, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_substitute(true, window, cx); + } + + fn helix_substitute_no_yank( + &mut self, + _: &HelixSubstituteNoYank, + window: &mut Window, + cx: &mut Context, + ) { + self.do_helix_substitute(false, window, cx); + } } #[cfg(test)] @@ -1241,4 +1295,67 @@ mod test { cx.simulate_keystrokes("s o n e enter"); cx.assert_state("ˇone two one", Mode::HelixNormal); } + + #[gpui::test] + async fn test_helix_substitute(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + + cx.set_state("ˇone two", Mode::HelixNormal); + cx.simulate_keystrokes("c"); + cx.assert_state("ˇne two", Mode::Insert); + + cx.set_state("«oneˇ» two", Mode::HelixNormal); + cx.simulate_keystrokes("c"); + cx.assert_state("ˇ two", Mode::Insert); + + cx.set_state( + indoc! {" + oneˇ two + three + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("x c"); + cx.assert_state( + indoc! {" + ˇ + three + "}, + Mode::Insert, + ); + + cx.set_state( + indoc! {" + one twoˇ + three + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("c"); + cx.assert_state( + indoc! {" + one twoˇthree + "}, + Mode::Insert, + ); + + // Helix doesn't set the cursor to the first non-blank one when + // replacing lines: it uses language-dependent indent queries instead. + cx.set_state( + indoc! {" + one two + « indented + three not indentedˇ» + "}, + Mode::HelixNormal, + ); + cx.simulate_keystrokes("c"); + cx.set_state( + indoc! {" + one two + ˇ + "}, + Mode::Insert, + ); + } }