From 249f427f10addf9d2644e34f29219f833c761a39 Mon Sep 17 00:00:00 2001 From: Mikhail Pertsev Date: Tue, 12 May 2026 23:46:18 +0200 Subject: [PATCH] vim: Add Helix jump-to-word support to Vim mode (#55492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #55481 Adds Vim-mode access to the existing Helix jump-to-word overlay via `g z`. We use `g z` because it is currently unassigned in Vim mode, while `g w` is already used for rewrap. Most of the implementation lives in `helix.rs` because the existing jump overlay, label generation, and Helix/Vim modal behavior are currently intertwined there. This keeps the change small and reuses the existing navigation overlay logic instead of doing a broader refactor. In Vim normal mode, jump labels behave like a cursor motion: selecting a label moves the cursor to the start of the target word without selecting it. In Vim visual mode, jump labels extend the selection like a Vim word-start motion, preserving Vim’s inclusive visual-selection behavior. 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 Release Notes: - Added Vim-mode jump-to-word navigation on `g z`. --- crates/vim/src/helix.rs | 99 ++++++++++++++++++++++++++++++++++++++--- crates/vim/src/state.rs | 2 + crates/vim/src/vim.rs | 26 +++++++++++ docs/src/vim.md | 11 +++++ 4 files changed, 131 insertions(+), 7 deletions(-) diff --git a/crates/vim/src/helix.rs b/crates/vim/src/helix.rs index 796d69b2822..314327b0487 100644 --- a/crates/vim/src/helix.rs +++ b/crates/vim/src/helix.rs @@ -983,10 +983,17 @@ impl Vim { window: &mut Window, cx: &mut Context, ) { - let behaviour = if self.mode.is_visual() { - HelixJumpBehaviour::Extend - } else { - HelixJumpBehaviour::Move + let behaviour = match self.mode { + // Vim normal mode treats jump-to-word as a cursor motion, while Helix + // normal mode treats the cursor as a single-character selection. + Mode::Normal => HelixJumpBehaviour::MoveToWordStart, + // Vim visual mode extends like a motion, so the cursor stops at the + // same word boundary as normal mode instead of selecting the word. + Mode::Visual | Mode::VisualLine | Mode::VisualBlock => { + HelixJumpBehaviour::ExtendToWordStart + } + Mode::HelixSelect => HelixJumpBehaviour::Extend, + _ => HelixJumpBehaviour::Move, }; self.start_helix_jump(behaviour, window, cx); } @@ -1709,7 +1716,7 @@ mod test { use editor::{HighlightKey, MultiBufferOffset}; use gpui::{KeyBinding, UpdateGlobal, VisualTestContext}; use indoc::indoc; - use language::Point; + use language::{CursorShape, Point}; use project::FakeFs; use search::{ProjectSearchView, project_search}; use serde_json::json; @@ -1718,7 +1725,7 @@ mod test { use util::path; use workspace::{DeploySearch, MultiWorkspace}; - use super::HELIX_JUMP_LABEL_LIMIT; + use super::{HELIX_JUMP_LABEL_LIMIT, HelixJumpToWord}; use crate::{ HELIX_JUMP_OVERLAY_KEY, Vim, VimAddon, state::{Mode, Operator}, @@ -1772,7 +1779,11 @@ mod test { } fn jump_to_word(cx: &mut VimTestContext, target_word: &str) { - cx.simulate_keystrokes("g w"); + jump_to_word_with_keystrokes(cx, "g w", target_word); + } + + fn jump_to_word_with_keystrokes(cx: &mut VimTestContext, keystrokes: &str, target_word: &str) { + cx.simulate_keystrokes(keystrokes); let label = helix_jump_label_for_word(cx, target_word); @@ -1782,6 +1793,16 @@ mod test { cx.simulate_keystrokes(&format!("{first} {second}")); } + fn bind_vim_jump_to_word(cx: &mut VimTestContext, keystrokes: &'static str) { + cx.update(|_, cx| { + cx.bind_keys([KeyBinding::new( + keystrokes, + HelixJumpToWord, + Some("vim_mode == normal || vim_mode == visual"), + )]) + }); + } + fn active_helix_jump_overlay_counts(cx: &mut VimTestContext) -> (usize, usize) { let covered_text_range_count = cx.update_editor(|editor, window, cx| { let snapshot = editor.snapshot(window, cx); @@ -3468,6 +3489,70 @@ mod test { assert_eq!(cx.active_operator(), None); } + #[gpui::test] + async fn test_vim_jump_moves_to_target_word_start(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + bind_vim_jump_to_word(&mut cx, "g z"); + cx.set_state("ˇone two three", Mode::Normal); + + jump_to_word_with_keystrokes(&mut cx, "g z", "two"); + + cx.assert_state("one ˇtwo three", Mode::Normal); + assert_eq!(cx.active_operator(), None); + } + + #[gpui::test] + async fn test_vim_jump_keeps_normal_cursor_shape(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + bind_vim_jump_to_word(&mut cx, "g z"); + cx.update(|_, cx| { + SettingsStore::update_global(cx, |store, cx| { + store.update_user_settings(cx, |settings| { + settings.vim.get_or_insert_default().cursor_shape = + Some(settings::CursorShapeSettings { + normal: Some(settings::CursorShape::Bar), + ..Default::default() + }); + }); + }); + }); + cx.set_state("ˇone two three", Mode::Normal); + + cx.simulate_keystrokes("g z"); + + assert!( + matches!(cx.active_operator(), Some(Operator::HelixJump { .. })), + "expected HelixJump operator to be active" + ); + cx.update_editor(|editor, _, _| { + assert_eq!(editor.cursor_shape(), CursorShape::Bar); + }); + } + + #[gpui::test] + async fn test_vim_visual_jump_extends_selection(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + bind_vim_jump_to_word(&mut cx, "g z"); + cx.set_state("one «twoˇ» three four", Mode::Visual); + + jump_to_word_with_keystrokes(&mut cx, "g z", "three"); + + cx.assert_state("one «two tˇ»hree four", Mode::Visual); + assert_eq!(cx.active_operator(), None); + } + + #[gpui::test] + async fn test_vim_visual_jump_extends_selection_backward(cx: &mut gpui::TestAppContext) { + let mut cx = VimTestContext::new(cx, true).await; + bind_vim_jump_to_word(&mut cx, "g z"); + cx.set_state("one two «threeˇ» four", Mode::Visual); + + jump_to_word_with_keystrokes(&mut cx, "g z", "one"); + + cx.assert_state("«ˇone two three» four", Mode::Visual); + assert_eq!(cx.active_operator(), None); + } + #[gpui::test] async fn test_helix_jump_extends_selection_forward(cx: &mut gpui::TestAppContext) { let mut cx = VimTestContext::new(cx, true).await; diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index 85bf84d8878..1c15c7d4866 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -176,7 +176,9 @@ pub struct HelixJumpLabel { #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HelixJumpBehaviour { Move, + MoveToWordStart, Extend, + ExtendToWordStart, } #[derive(Default, Clone, Debug)] diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index d247e240310..b8e387e9728 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -1362,6 +1362,10 @@ impl Vim { Mode::Normal => { if let Some(operator) = self.operator_stack.last() { match operator { + // Vim jump labels are transient navigation, so keep the + // user's normal cursor shape while waiting for the label. + Operator::HelixJump { .. } => cursor_shape.normal, + // Navigation operators -> Block cursor Operator::FindForward { .. } | Operator::FindBackward { .. } @@ -1828,6 +1832,28 @@ impl Vim { s.select_anchor_ranges([candidate.range.clone()]) }); } + HelixJumpBehaviour::MoveToWordStart => { + editor.change_selections(Default::default(), window, cx, |s| { + // Vim users expect jump labels to behave like motions, leaving + // normal mode at the label instead of selecting the word. + s.select_anchor_ranges([candidate.range.start..candidate.range.start]) + }); + } + HelixJumpBehaviour::ExtendToWordStart => { + editor.change_selections(Default::default(), window, cx, |s| { + s.move_with(&mut |map, selection| { + let word_start = candidate.range.start.to_display_point(map); + let tail = selection.tail(); + + if word_start >= tail { + selection + .set_head(motion::right(map, word_start, 1), SelectionGoal::None); + } else { + selection.set_head_tail(word_start, selection.end, SelectionGoal::None); + } + }); + }); + } HelixJumpBehaviour::Extend => { editor.change_selections(Default::default(), window, cx, |s| { s.move_with(&mut |map, selection| { diff --git a/docs/src/vim.md b/docs/src/vim.md index 1f777537ba8..b8447b83e56 100644 --- a/docs/src/vim.md +++ b/docs/src/vim.md @@ -540,6 +540,17 @@ The [Sneak motion](https://github.com/justinmk/vim-sneak) feature allows for qui } ``` +The Helix-style jump-to-word action shows jump labels at visible word starts. It has no default binding in Vim mode, but you can enable it by adding a keybinding to your keymap. This example uses `g w`, which matches the default Helix binding, but overrides Vim mode's default rewrap binding. + +```json [keymap] +{ + "context": "vim_mode == normal || vim_mode == visual", + "bindings": { + "g w": "vim::HelixJumpToWord" + } +} +``` + The [vim-exchange](https://github.com/tommcdo/vim-exchange) feature does not have a default binding for visual mode, as the `shift-x` binding conflicts with the default `shift-x` binding for visual mode (`vim::VisualDeleteLine`). To assign the default vim-exchange binding, add the following keybinding to your keymap: ```json [keymap]