vim: Improve pasting while in replace mode (#41549)

Dino created

- Update `vim::normal::Vim.normal_replace` to work with more than one
  character
- Add `vim::replace::Vim.paste_replace` to handle pasting the 
  clipboard's contents while in replace mode
- Update vim's handling of the `editor::actions::Paste` action so that
  the `paste_replace` method is called when vim is in replace mode,
  otherwise it'll just call the regular `editor::Editor.paste` method

Closes #41378 

Release Notes:

- Improved pasting while in Vim's Replace mode, ensuring that the Zed
replaces the same number of characters as the length of the contents
being pasted

Change summary

crates/vim/src/normal.rs  | 13 +++++++++++--
crates/vim/src/replace.rs | 39 +++++++++++++++++++++++++++++++++++++--
crates/vim/src/vim.rs     | 12 ++++++++++++
3 files changed, 60 insertions(+), 4 deletions(-)

Detailed changes

crates/vim/src/normal.rs 🔗

@@ -965,8 +965,17 @@ impl Vim {
         window: &mut Window,
         cx: &mut Context<Self>,
     ) {
+        // We need to use `text.chars().count()` instead of `text.len()` here as
+        // `len()` counts bytes, not characters.
+        let char_count = text.chars().count();
+        let count = Vim::take_count(cx).unwrap_or(char_count);
         let is_return_char = text == "\n".into() || text == "\r".into();
-        let count = Vim::take_count(cx).unwrap_or(1);
+        let repeat_count = match (is_return_char, char_count) {
+            (true, _) => 0,
+            (_, 1) => count,
+            (_, _) => 1,
+        };
+
         Vim::take_forced_motion(cx);
         self.stop_recording(cx);
         self.update_editor(cx, |_, editor, cx| {
@@ -989,7 +998,7 @@ impl Vim {
                     edits.push((
                         range.start.to_offset(&display_map, Bias::Left)
                             ..range.end.to_offset(&display_map, Bias::Left),
-                        text.repeat(if is_return_char { 0 } else { count }),
+                        text.repeat(repeat_count),
                     ));
                 }
 

crates/vim/src/replace.rs 🔗

@@ -1,5 +1,5 @@
 use crate::{
-    Vim,
+    Operator, Vim,
     motion::{self, Motion},
     object::Object,
     state::Mode,
@@ -8,7 +8,7 @@ use editor::{
     Anchor, Bias, Editor, EditorSnapshot, SelectionEffects, ToOffset, ToPoint,
     display_map::ToDisplayPoint,
 };
-use gpui::{Context, Window, actions};
+use gpui::{ClipboardEntry, Context, Window, actions};
 use language::{Point, SelectionGoal};
 use std::ops::Range;
 use std::sync::Arc;
@@ -278,10 +278,27 @@ impl Vim {
             );
         }
     }
+
+    /// Pastes the clipboard contents, replacing the same number of characters
+    /// as the clipboard's contents.
+    pub fn paste_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        let clipboard_text =
+            cx.read_from_clipboard()
+                .and_then(|item| match item.entries().first() {
+                    Some(ClipboardEntry::String(text)) => Some(text.text().to_string()),
+                    _ => None,
+                });
+
+        if let Some(text) = clipboard_text {
+            self.push_operator(Operator::Replace, window, cx);
+            self.normal_replace(Arc::from(text), window, cx);
+        }
+    }
 }
 
 #[cfg(test)]
 mod test {
+    use gpui::ClipboardItem;
     use indoc::indoc;
 
     use crate::{
@@ -521,4 +538,22 @@ mod test {
             assert_eq!(0, highlights.len());
         });
     }
+
+    #[gpui::test]
+    async fn test_paste_replace(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+
+        cx.set_state(indoc! {"ˇ123"}, Mode::Replace);
+        cx.write_to_clipboard(ClipboardItem::new_string("456".to_string()));
+        cx.dispatch_action(editor::actions::Paste);
+        cx.assert_state(indoc! {"45ˇ6"}, Mode::Replace);
+
+        // If the clipboard's contents length is greater than the remaining text
+        // length, nothing sould be replace and cursor should remain in the same
+        // position.
+        cx.set_state(indoc! {"ˇ123"}, Mode::Replace);
+        cx.write_to_clipboard(ClipboardItem::new_string("4567".to_string()));
+        cx.dispatch_action(editor::actions::Paste);
+        cx.assert_state(indoc! {"ˇ123"}, Mode::Replace);
+    }
 }

crates/vim/src/vim.rs 🔗

@@ -23,6 +23,7 @@ use collections::HashMap;
 use editor::{
     Anchor, Bias, Editor, EditorEvent, EditorSettings, HideMouseCursorOrigin, SelectionEffects,
     ToPoint,
+    actions::Paste,
     movement::{self, FindRange},
 };
 use gpui::{
@@ -919,6 +920,17 @@ impl Vim {
                 );
             });
 
+            Vim::action(
+                editor,
+                cx,
+                |vim, _: &editor::actions::Paste, window, cx| match vim.mode {
+                    Mode::Replace => vim.paste_replace(window, cx),
+                    _ => {
+                        vim.update_editor(cx, |_, editor, cx| editor.paste(&Paste, window, cx));
+                    }
+                },
+            );
+
             normal::register(editor, cx);
             insert::register(editor, cx);
             helix::register(editor, cx);