vim: Replace with Register (#24326)

Conrad Irwin created

Closes #18813

Release Notes:

- vim: Add `gr` for [replace with
register](https://github.com/vim-scripts/ReplaceWithRegister)

Change summary

assets/keymaps/vim.json        |   1 
crates/vim/src/normal.rs       |   6 +
crates/vim/src/normal/paste.rs | 110 +++++++++++++++++++++++++++++++++++
crates/vim/src/state.rs        |   3 
docs/src/vim.md                |   1 
5 files changed, 120 insertions(+), 1 deletion(-)

Detailed changes

assets/keymaps/vim.json ๐Ÿ”—

@@ -102,6 +102,7 @@
       "ctrl-e": "vim::LineDown",
       "ctrl-y": "vim::LineUp",
       // "g" commands
+      "g r": ["vim::PushOperator", "ReplaceWithRegister"],
       "g g": "vim::StartOfDocument",
       "g h": "editor::Hover",
       "g t": "pane::ActivateNextItem",

crates/vim/src/normal.rs ๐Ÿ”—

@@ -182,6 +182,9 @@ impl Vim {
             Some(Operator::ToggleComments) => {
                 self.toggle_comments_motion(motion, times, window, cx)
             }
+            Some(Operator::ReplaceWithRegister) => {
+                self.replace_with_register_motion(motion, times, window, cx)
+            }
             Some(operator) => {
                 // Can't do anything for text objects, Ignoring
                 error!("Unexpected normal mode motion operator: {:?}", operator)
@@ -228,6 +231,9 @@ impl Vim {
                 Some(Operator::ToggleComments) => {
                     self.toggle_comments_object(object, around, window, cx)
                 }
+                Some(Operator::ReplaceWithRegister) => {
+                    self.replace_with_register_object(object, around, window, cx)
+                }
                 _ => {
                     // Can't do anything for namespace operators. Ignoring
                 }

crates/vim/src/normal/paste.rs ๐Ÿ”—

@@ -6,6 +6,8 @@ use serde::Deserialize;
 use std::cmp;
 
 use crate::{
+    motion::Motion,
+    object::Object,
     state::{Mode, Register},
     Vim,
 };
@@ -192,12 +194,85 @@ impl Vim {
         });
         self.switch_mode(Mode::Normal, true, window, cx);
     }
+
+    pub fn replace_with_register_object(
+        &mut self,
+        object: Object,
+        around: bool,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        let selected_register = self.selected_register.take();
+        self.update_editor(window, cx, |_, editor, window, cx| {
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                editor.change_selections(None, window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        object.expand_selection(map, selection, around);
+                    });
+                });
+
+                let Some(Register { text, .. }) = Vim::update_globals(cx, |globals, cx| {
+                    globals.read_register(selected_register, Some(editor), cx)
+                })
+                .filter(|reg| !reg.text.is_empty()) else {
+                    return;
+                };
+                editor.insert(&text, window, cx);
+                editor.set_clip_at_line_ends(true, cx);
+                editor.change_selections(None, window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        selection.start = map.clip_point(selection.start, Bias::Left);
+                        selection.end = selection.start
+                    })
+                })
+            });
+        });
+    }
+
+    pub fn replace_with_register_motion(
+        &mut self,
+        motion: Motion,
+        times: Option<usize>,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.stop_recording(cx);
+        let selected_register = self.selected_register.take();
+        self.update_editor(window, cx, |_, editor, window, cx| {
+            let text_layout_details = editor.text_layout_details(window);
+            editor.transact(window, cx, |editor, window, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                editor.change_selections(None, window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        motion.expand_selection(map, selection, times, false, &text_layout_details);
+                    });
+                });
+
+                let Some(Register { text, .. }) = Vim::update_globals(cx, |globals, cx| {
+                    globals.read_register(selected_register, Some(editor), cx)
+                })
+                .filter(|reg| !reg.text.is_empty()) else {
+                    return;
+                };
+                editor.insert(&text, window, cx);
+                editor.set_clip_at_line_ends(true, cx);
+                editor.change_selections(None, window, cx, |s| {
+                    s.move_with(|map, selection| {
+                        selection.start = map.clip_point(selection.start, Bias::Left);
+                        selection.end = selection.start
+                    })
+                })
+            });
+        });
+    }
 }
 
 #[cfg(test)]
 mod test {
     use crate::{
-        state::Mode,
+        state::{Mode, Register},
         test::{NeovimBackedTestContext, VimTestContext},
         UseSystemClipboard, VimSettings,
     };
@@ -742,4 +817,37 @@ mod test {
             Mode::Normal,
         );
     }
+
+    #[gpui::test]
+    async fn test_replace_with_register(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state(
+            indoc! {"
+                   ห‡fish one
+                   two three
+                   "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("y i w");
+        cx.simulate_keystrokes("w");
+        cx.simulate_keystrokes("g r i w");
+        cx.assert_state(
+            indoc! {"
+                fish fisห‡h
+                two three
+                "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes("j b g r e");
+        cx.assert_state(
+            indoc! {"
+            fish fish
+            two fisห‡h
+            "},
+            Mode::Normal,
+        );
+        let clipboard: Register = cx.read_from_clipboard().unwrap().into();
+        assert_eq!(clipboard.text, "fish");
+    }
 }

crates/vim/src/state.rs ๐Ÿ”—

@@ -111,6 +111,7 @@ pub enum Operator {
     RecordRegister,
     ReplayRegister,
     ToggleComments,
+    ReplaceWithRegister,
 }
 
 #[derive(Default, Clone, Debug)]
@@ -499,6 +500,7 @@ impl Operator {
             Operator::AutoIndent => "eq",
             Operator::ShellCommand => "sh",
             Operator::Rewrap => "gq",
+            Operator::ReplaceWithRegister => "gr",
             Operator::Outdent => "<",
             Operator::Uppercase => "gU",
             Operator::Lowercase => "gu",
@@ -551,6 +553,7 @@ impl Operator {
             | Operator::ShellCommand
             | Operator::Lowercase
             | Operator::Uppercase
+            | Operator::ReplaceWithRegister
             | Operator::Object { .. }
             | Operator::ChangeSurrounds { target: None }
             | Operator::OppositeCase

docs/src/vim.md ๐Ÿ”—

@@ -159,6 +159,7 @@ Zed's vim mode includes some features that are usually provided by very popular
 - You can comment and uncomment selections with `gc` in visual mode and `gcc` in normal mode.
 - The project panel supports many shortcuts modeled after the Vim plugin `netrw`: navigation with `hjkl`, open file with `o`, open file in a new tab with `t`, etc.
 - You can add key bindings to your keymap to navigate "camelCase" names. [Head down to the Optional key bindings](#optional-key-bindings) section to learn how.
+- You can use `gr` to do [ReplaceWithRegister](https://github.com/vim-scripts/ReplaceWithRegister).
 
 ## Command palette