helix: Allow yank without a selection (#35612)

Romans Malinovskis and Conrad Irwin created

Related https://github.com/zed-industries/zed/issues/4642

Release Notes:
- Helix: without active selection, pressing `y` in helix mode will yank
a single character under cursor.

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                 |  2 
crates/vim/src/helix.rs                 | 69 +++++++++++++++++++++++++++
crates/vim/src/test/vim_test_context.rs | 30 +++++++++++
3 files changed, 100 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -390,7 +390,7 @@
       "right": "vim::WrappingRight",
       "h": "vim::WrappingLeft",
       "l": "vim::WrappingRight",
-      "y": "editor::Copy",
+      "y": "vim::HelixYank",
       "alt-;": "vim::OtherEnd",
       "ctrl-r": "vim::Redo",
       "f": ["vim::PushFindForward", { "before": false, "multiline": true }],

crates/vim/src/helix.rs 🔗

@@ -15,6 +15,8 @@ actions!(
     [
         /// Switches to normal mode after the cursor (Helix-style).
         HelixNormalAfter,
+        /// Yanks the current selection or character if no selection.
+        HelixYank,
         /// Inserts at the beginning of the selection.
         HelixInsert,
         /// Appends at the end of the selection.
@@ -26,6 +28,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_normal_after);
     Vim::action(editor, cx, Vim::helix_insert);
     Vim::action(editor, cx, Vim::helix_append);
+    Vim::action(editor, cx, Vim::helix_yank);
 }
 
 impl Vim {
@@ -310,6 +313,47 @@ impl Vim {
         }
     }
 
+    pub fn helix_yank(&mut self, _: &HelixYank, window: &mut Window, cx: &mut Context<Self>) {
+        self.update_editor(cx, |vim, editor, cx| {
+            let has_selection = editor
+                .selections
+                .all_adjusted(cx)
+                .iter()
+                .any(|selection| !selection.is_empty());
+
+            if !has_selection {
+                // If no selection, expand to current character (like 'v' does)
+                editor.change_selections(Default::default(), window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        let head = selection.head();
+                        let new_head = movement::saturating_right(map, head);
+                        selection.set_tail(head, SelectionGoal::None);
+                        selection.set_head(new_head, SelectionGoal::None);
+                    });
+                });
+                vim.yank_selections_content(
+                    editor,
+                    crate::motion::MotionKind::Exclusive,
+                    window,
+                    cx,
+                );
+                editor.change_selections(Default::default(), window, cx, |s| {
+                    s.move_with(|_map, selection| {
+                        selection.collapse_to(selection.start, SelectionGoal::None);
+                    });
+                });
+            } else {
+                // Yank the selection(s)
+                vim.yank_selections_content(
+                    editor,
+                    crate::motion::MotionKind::Exclusive,
+                    window,
+                    cx,
+                );
+            }
+        });
+    }
+
     fn helix_insert(&mut self, _: &HelixInsert, window: &mut Window, cx: &mut Context<Self>) {
         self.start_recording(cx);
         self.update_editor(cx, |_, editor, cx| {
@@ -703,4 +747,29 @@ mod test {
 
         cx.assert_state("«xxˇ»", Mode::HelixNormal);
     }
+
+    #[gpui::test]
+    async fn test_helix_yank(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // Test yanking current character with no selection
+        cx.set_state("hello ˇworld", Mode::HelixNormal);
+        cx.simulate_keystrokes("y");
+
+        // Test cursor remains at the same position after yanking single character
+        cx.assert_state("hello ˇworld", Mode::HelixNormal);
+        cx.shared_clipboard().assert_eq("w");
+
+        // Move cursor and yank another character
+        cx.simulate_keystrokes("l");
+        cx.simulate_keystrokes("y");
+        cx.shared_clipboard().assert_eq("o");
+
+        // Test yanking with existing selection
+        cx.set_state("hello «worlˇ»d", Mode::HelixNormal);
+        cx.simulate_keystrokes("y");
+        cx.shared_clipboard().assert_eq("worl");
+        cx.assert_state("hello «worlˇ»d", Mode::HelixNormal);
+    }
 }

crates/vim/src/test/vim_test_context.rs 🔗

@@ -143,6 +143,16 @@ impl VimTestContext {
         })
     }
 
+    pub fn enable_helix(&mut self) {
+        self.cx.update(|_, cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                store.update_user_settings::<vim_mode_setting::HelixModeSetting>(cx, |s| {
+                    *s = Some(true)
+                });
+            });
+        })
+    }
+
     pub fn mode(&mut self) -> Mode {
         self.update_editor(|editor, _, cx| editor.addon::<VimAddon>().unwrap().entity.read(cx).mode)
     }
@@ -210,6 +220,26 @@ impl VimTestContext {
         assert_eq!(self.mode(), Mode::Normal, "{}", self.assertion_context());
         assert_eq!(self.active_operator(), None, "{}", self.assertion_context());
     }
+
+    pub fn shared_clipboard(&mut self) -> VimClipboard {
+        VimClipboard {
+            editor: self
+                .read_from_clipboard()
+                .map(|item| item.text().unwrap().to_string())
+                .unwrap_or_default(),
+        }
+    }
+}
+
+pub struct VimClipboard {
+    editor: String,
+}
+
+impl VimClipboard {
+    #[track_caller]
+    pub fn assert_eq(&self, expected: &str) {
+        assert_eq!(self.editor, expected);
+    }
 }
 
 impl Deref for VimTestContext {