fix pasting at the end of the line in normal mode

Keith Simmons created

Change summary

assets/keymaps/vim.json            |   2 
crates/vim/src/normal.rs           |  68 +++++------
crates/vim/src/vim_test_context.rs |   5 
crates/vim/src/visual.rs           | 173 +++++++++++++++++++++++++++++++
4 files changed, 208 insertions(+), 40 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -141,7 +141,7 @@
             "d": "vim::VisualDelete",
             "x": "vim::VisualDelete",
             "y": "vim::VisualYank",
-            "p": "vim::Paste"
+            "p": "vim::VisualPaste"
         }
     },
     {

crates/vim/src/normal.rs 🔗

@@ -7,7 +7,6 @@ use std::borrow::Cow;
 use crate::{
     motion::Motion,
     state::{Mode, Operator},
-    utils::copy_selections_content,
     Vim,
 };
 use change::init as change_init;
@@ -195,18 +194,17 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
     });
 }
 
-// Supports non empty selections so it can be bound and called from visual mode
 fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
             editor.transact(cx, |editor, cx| {
+                editor.set_clip_at_line_ends(false, cx);
                 if let Some(item) = cx.as_mut().read_from_clipboard() {
-                    copy_selections_content(editor, editor.selections.line_mode, cx);
                     let mut clipboard_text = Cow::Borrowed(item.text());
                     if let Some(mut clipboard_selections) =
                         item.metadata::<Vec<ClipboardSelection>>()
                     {
-                        let (display_map, selections) = editor.selections.all_adjusted_display(cx);
+                        let (display_map, selections) = editor.selections.all_display(cx);
                         let all_selections_were_entire_line =
                             clipboard_selections.iter().all(|s| s.is_entire_line);
                         if clipboard_selections.len() != selections.len() {
@@ -246,7 +244,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                 // If the clipboard text was copied linewise, and the current selection
                                 // is empty, then paste the text after this line and move the selection
                                 // to the start of the pasted text
-                                let range = if selection.is_empty() && linewise {
+                                let insert_at = if linewise {
                                     let (point, _) = display_map
                                         .next_line_boundary(selection.start.to_point(&display_map));
 
@@ -257,37 +255,26 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                                     // Drop selection at the start of the next line
                                     let selection_point = Point::new(point.row + 1, 0);
                                     new_selections.push(selection.map(|_| selection_point.clone()));
-                                    point..point
+                                    point
                                 } else {
-                                    let mut selection = selection.clone();
-                                    if !selection.reversed {
-                                        let mut adjusted = selection.end;
-                                        // Head is at the end of the selection. Adjust the end position to
-                                        // to include the character under the cursor.
-                                        *adjusted.column_mut() = adjusted.column() + 1;
-                                        adjusted = display_map.clip_point(adjusted, Bias::Right);
-                                        // If the selection is empty, move both the start and end forward one
-                                        // character
-                                        if selection.is_empty() {
-                                            selection.start = adjusted;
-                                            selection.end = adjusted;
-                                        } else {
-                                            selection.end = adjusted;
-                                        }
-                                    }
-
-                                    let range = selection.map(|p| p.to_point(&display_map)).range();
-                                    new_selections.push(selection.map(|_| range.start.clone()));
-                                    range
+                                    let mut point = selection.end;
+                                    // Paste the text after the current selection
+                                    *point.column_mut() = point.column() + 1;
+                                    let point = display_map
+                                        .clip_point(point, Bias::Right)
+                                        .to_point(&display_map);
+
+                                    new_selections.push(selection.map(|_| point));
+                                    point
                                 };
 
                                 if linewise && to_insert.ends_with('\n') {
                                     edits.push((
-                                        range,
+                                        insert_at..insert_at,
                                         &to_insert[0..to_insert.len().saturating_sub(1)],
                                     ))
                                 } else {
-                                    edits.push((range, to_insert));
+                                    edits.push((insert_at..insert_at, to_insert));
                                 }
                             }
                             drop(snapshot);
@@ -301,6 +288,7 @@ fn paste(_: &mut Workspace, _: &Paste, cx: &mut ViewContext<Workspace>) {
                         editor.insert(&clipboard_text, cx);
                     }
                 }
+                editor.set_clip_at_line_ends(true, cx);
             });
         });
     });
@@ -1157,10 +1145,13 @@ mod test {
             the la|zy dog"});
 
         cx.simulate_keystroke("p");
-        cx.assert_editor_state(indoc! {"
-            The quick brown
-            the lazy dog
-            |fox jumps over"});
+        cx.assert_state(
+            indoc! {"
+                The quick brown
+                the lazy dog
+                |fox jumps over"},
+            Mode::Normal,
+        );
 
         cx.set_state(
             indoc! {"
@@ -1173,14 +1164,17 @@ mod test {
         cx.set_state(
             indoc! {"
                 The quick brown
-                fox jump|s over
+                fox jumps ove|r
                 the lazy dog"},
             Mode::Normal,
         );
         cx.simulate_keystroke("p");
-        cx.assert_editor_state(indoc! {"
-            The quick brown
-            fox jumps|jumps over
-            the lazy dog"});
+        cx.assert_state(
+            indoc! {"
+                The quick brown
+                fox jumps over|jumps
+                the lazy dog"},
+            Mode::Normal,
+        );
     }
 }

crates/vim/src/vim_test_context.rs 🔗

@@ -125,6 +125,11 @@ impl<'a> VimTestContext<'a> {
         self.cx.set_state(text);
     }
 
+    pub fn assert_state(&mut self, text: &str, mode: Mode) {
+        self.assert_editor_state(text);
+        assert_eq!(self.mode(), mode);
+    }
+
     pub fn assert_binding<const COUNT: usize>(
         &mut self,
         keystrokes: [&str; COUNT],

crates/vim/src/visual.rs 🔗

@@ -1,5 +1,7 @@
+use std::borrow::Cow;
+
 use collections::HashMap;
-use editor::{display_map::ToDisplayPoint, Autoscroll, Bias};
+use editor::{display_map::ToDisplayPoint, Autoscroll, Bias, ClipboardSelection};
 use gpui::{actions, MutableAppContext, ViewContext};
 use language::SelectionGoal;
 use workspace::Workspace;
@@ -12,6 +14,7 @@ pub fn init(cx: &mut MutableAppContext) {
     cx.add_action(change);
     cx.add_action(delete);
     cx.add_action(yank);
+    cx.add_action(paste);
 }
 
 pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
@@ -136,7 +139,7 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
         vim.update_active_editor(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
             let line_mode = editor.selections.line_mode;
-            if !editor.selections.line_mode {
+            if !line_mode {
                 editor.change_selections(None, cx, |s| {
                     s.move_with(|map, selection| {
                         if !selection.reversed {
@@ -159,6 +162,114 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>)
     });
 }
 
+pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext<Workspace>) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                if let Some(item) = cx.as_mut().read_from_clipboard() {
+                    copy_selections_content(editor, editor.selections.line_mode, cx);
+                    let mut clipboard_text = Cow::Borrowed(item.text());
+                    if let Some(mut clipboard_selections) =
+                        item.metadata::<Vec<ClipboardSelection>>()
+                    {
+                        let (display_map, selections) = editor.selections.all_adjusted_display(cx);
+                        let all_selections_were_entire_line =
+                            clipboard_selections.iter().all(|s| s.is_entire_line);
+                        if clipboard_selections.len() != selections.len() {
+                            let mut newline_separated_text = String::new();
+                            let mut clipboard_selections =
+                                clipboard_selections.drain(..).peekable();
+                            let mut ix = 0;
+                            while let Some(clipboard_selection) = clipboard_selections.next() {
+                                newline_separated_text
+                                    .push_str(&clipboard_text[ix..ix + clipboard_selection.len]);
+                                ix += clipboard_selection.len;
+                                if clipboard_selections.peek().is_some() {
+                                    newline_separated_text.push('\n');
+                                }
+                            }
+                            clipboard_text = Cow::Owned(newline_separated_text);
+                        }
+
+                        let mut new_selections = Vec::new();
+                        editor.buffer().update(cx, |buffer, cx| {
+                            let snapshot = buffer.snapshot(cx);
+                            let mut start_offset = 0;
+                            let mut edits = Vec::new();
+                            for (ix, selection) in selections.iter().enumerate() {
+                                let to_insert;
+                                let linewise;
+                                if let Some(clipboard_selection) = clipboard_selections.get(ix) {
+                                    let end_offset = start_offset + clipboard_selection.len;
+                                    to_insert = &clipboard_text[start_offset..end_offset];
+                                    linewise = clipboard_selection.is_entire_line;
+                                    start_offset = end_offset;
+                                } else {
+                                    to_insert = clipboard_text.as_str();
+                                    linewise = all_selections_were_entire_line;
+                                }
+
+                                let mut selection = selection.clone();
+                                if !selection.reversed {
+                                    let mut adjusted = selection.end;
+                                    // Head is at the end of the selection. Adjust the end position to
+                                    // to include the character under the cursor.
+                                    *adjusted.column_mut() = adjusted.column() + 1;
+                                    adjusted = display_map.clip_point(adjusted, Bias::Right);
+                                    // If the selection is empty, move both the start and end forward one
+                                    // character
+                                    if selection.is_empty() {
+                                        selection.start = adjusted;
+                                        selection.end = adjusted;
+                                    } else {
+                                        selection.end = adjusted;
+                                    }
+                                }
+
+                                let range = selection.map(|p| p.to_point(&display_map)).range();
+
+                                let new_position = if linewise {
+                                    edits.push((range.start..range.start, "\n"));
+                                    let mut new_position = range.start.clone();
+                                    new_position.column = 0;
+                                    new_position.row += 1;
+                                    new_position
+                                } else {
+                                    range.start.clone()
+                                };
+
+                                new_selections.push(selection.map(|_| new_position.clone()));
+
+                                if linewise && to_insert.ends_with('\n') {
+                                    edits.push((
+                                        range.clone(),
+                                        &to_insert[0..to_insert.len().saturating_sub(1)],
+                                    ))
+                                } else {
+                                    edits.push((range.clone(), to_insert));
+                                }
+
+                                if linewise {
+                                    edits.push((range.end..range.end, "\n"));
+                                }
+                            }
+                            drop(snapshot);
+                            buffer.edit_with_autoindent(edits, cx);
+                        });
+
+                        editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
+                            s.select(new_selections)
+                        });
+                    } else {
+                        editor.insert(&clipboard_text, cx);
+                    }
+                }
+            });
+        });
+        vim.switch_mode(Mode::Normal, cx);
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
@@ -607,4 +718,62 @@ mod test {
             quick brown
             fox jumps o"}));
     }
+
+    #[gpui::test]
+    async fn test_visual_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox [jump}s over
+                the lazy dog"},
+            Mode::Visual { line: false },
+        );
+        cx.simulate_keystroke("y");
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox jump|s over
+                the lazy dog"},
+            Mode::Normal,
+        );
+        cx.simulate_keystroke("p");
+        cx.assert_state(
+            indoc! {"
+                The quick brown
+                fox jumps|jumps over
+                the lazy dog"},
+            Mode::Normal,
+        );
+
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                fox ju|mps over
+                the lazy dog"},
+            Mode::Visual { line: true },
+        );
+        cx.simulate_keystroke("d");
+        cx.assert_state(
+            indoc! {"
+                The quick brown
+                the la|zy dog"},
+            Mode::Normal,
+        );
+        cx.set_state(
+            indoc! {"
+                The quick brown
+                the [laz}y dog"},
+            Mode::Visual { line: false },
+        );
+        cx.simulate_keystroke("p");
+        cx.assert_state(
+            indoc! {"
+                The quick brown
+                the 
+                |fox jumps over
+                 dog"},
+            Mode::Normal,
+        );
+    }
 }