Enable copy and paste in vim mode

Keith Simmons created

Change summary

assets/keymaps/vim.json         |   3 
crates/editor/src/editor.rs     |   2 
crates/editor/src/element.rs    |   2 
crates/text/src/selection.rs    |   5 +
crates/vim/src/normal.rs        | 124 ++++++++++++++++++++++++++++++++++
crates/vim/src/normal/change.rs |  22 +----
crates/vim/src/normal/delete.rs |   3 
crates/vim/src/utils.rs         |  26 +++++++
crates/vim/src/vim.rs           |  14 ++-
crates/vim/src/visual.rs        |  16 ++-
10 files changed, 183 insertions(+), 34 deletions(-)

Detailed changes

crates/editor/src/editor.rs 🔗

@@ -3,7 +3,7 @@ mod element;
 pub mod items;
 pub mod movement;
 mod multi_buffer;
-mod selections_collection;
+pub mod selections_collection;
 
 #[cfg(test)]
 mod test;

crates/editor/src/element.rs 🔗

@@ -489,7 +489,7 @@ impl EditorElement {
         cx: &mut PaintContext,
     ) {
         if range.start != range.end || line_mode {
-            let row_range = if range.end.column() == 0 {
+            let row_range = if range.end.column() == 0 && !line_mode {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row(), end_row)
             } else {
                 cmp::max(range.start.row(), start_row)..cmp::min(range.end.row() + 1, end_row)

crates/text/src/selection.rs 🔗

@@ -1,6 +1,7 @@
 use crate::Anchor;
 use crate::{rope::TextDimension, BufferSnapshot};
 use std::cmp::Ordering;
+use std::ops::Range;
 
 #[derive(Copy, Clone, Debug, Eq, PartialEq)]
 pub enum SelectionGoal {
@@ -83,6 +84,10 @@ impl<T: Copy + Ord> Selection<T> {
         self.goal = new_goal;
         self.reversed = false;
     }
+
+    pub fn range(&self) -> Range<T> {
+        self.start..self.end
+    }
 }
 
 impl Selection<usize> {

crates/vim/src/normal.rs 🔗

@@ -1,6 +1,8 @@
 mod change;
 mod delete;
 
+use std::borrow::Cow;
+
 use crate::{
     motion::Motion,
     state::{Mode, Operator},
@@ -8,9 +10,9 @@ use crate::{
 };
 use change::init as change_init;
 use collections::HashSet;
-use editor::{Autoscroll, Bias, DisplayPoint};
+use editor::{Autoscroll, Bias, ClipboardSelection, DisplayPoint};
 use gpui::{actions, MutableAppContext, ViewContext};
-use language::SelectionGoal;
+use language::{Point, SelectionGoal};
 use workspace::Workspace;
 
 use self::{change::change_over, delete::delete_over};
@@ -27,6 +29,8 @@ actions!(
         DeleteRight,
         ChangeToEndOfLine,
         DeleteToEndOfLine,
+        Paste,
+        Yank,
     ]
 );
 
@@ -56,6 +60,7 @@ pub fn init(cx: &mut MutableAppContext) {
             delete_over(vim, Motion::EndOfLine, cx);
         })
     });
+    cx.add_action(paste);
 
     change_init(cx);
 }
@@ -187,6 +192,98 @@ fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContex
     });
 }
 
+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| {
+                if let Some(item) = cx.as_mut().read_from_clipboard() {
+                    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_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;
+                                }
+
+                                // 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 (point, _) = display_map
+                                        .next_line_boundary(selection.start.to_point(&display_map));
+
+                                    if !to_insert.starts_with('\n') {
+                                        // Add newline before pasted text so that it shows up
+                                        edits.push((point..point, "\n"));
+                                    }
+                                    // 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
+                                } else {
+                                    let range = selection.map(|p| p.to_point(&display_map)).range();
+                                    new_selections.push(selection.map(|_| range.start.clone()));
+                                    range
+                                };
+
+                                if linewise && to_insert.ends_with('\n') {
+                                    edits.push((
+                                        range,
+                                        &to_insert[0..to_insert.len().saturating_sub(1)],
+                                    ))
+                                } else {
+                                    edits.push((range, to_insert));
+                                }
+                            }
+                            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);
+                    }
+                }
+            });
+        });
+    });
+}
+
 #[cfg(test)]
 mod test {
     use indoc::indoc;
@@ -1026,4 +1123,27 @@ mod test {
                 brown fox"},
         );
     }
+
+    #[gpui::test]
+    async fn test_p(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state(
+            indoc! {"
+            The quick brown
+            fox ju|mps over
+            the lazy dog"},
+            Mode::Normal,
+        );
+
+        cx.simulate_keystrokes(["d", "d"]);
+        cx.assert_editor_state(indoc! {"
+            The quick brown
+            the la|zy dog"});
+
+        cx.simulate_keystroke("p");
+        cx.assert_editor_state(indoc! {"
+            The quick brown
+            the lazy dog
+            |fox jumps over"});
+    }
 }

crates/vim/src/normal/change.rs 🔗

@@ -1,6 +1,6 @@
-use crate::{motion::Motion, state::Mode, Vim};
-use editor::{char_kind, movement, Autoscroll, ClipboardSelection};
-use gpui::{impl_actions, ClipboardItem, MutableAppContext, ViewContext};
+use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
+use editor::{char_kind, movement, Autoscroll};
+use gpui::{impl_actions, MutableAppContext, ViewContext};
 use serde::Deserialize;
 use workspace::Workspace;
 
@@ -22,26 +22,13 @@ pub fn change_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
         editor.transact(cx, |editor, cx| {
             // We are swapping to insert mode anyway. Just set the line end clipping behavior now
             editor.set_clip_at_line_ends(false, cx);
-            let mut text = String::new();
-            let buffer = editor.buffer().read(cx).snapshot(cx);
-            let mut clipboard_selections = Vec::with_capacity(editor.selections.count());
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     motion.expand_selection(map, selection, false);
-                    let mut len = 0;
-                    let range = selection.start.to_point(map)..selection.end.to_point(map);
-                    for chunk in buffer.text_for_range(range) {
-                        text.push_str(chunk);
-                        len += chunk.len();
-                    }
-                    clipboard_selections.push(ClipboardSelection {
-                        len,
-                        is_entire_line: motion.linewise(),
-                    });
                 });
             });
+            copy_selections_content(editor, motion.linewise(), cx);
             editor.insert(&"", cx);
-            cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
         });
     });
     vim.switch_mode(Mode::Insert, cx)
@@ -79,6 +66,7 @@ fn change_word(
                             });
                     });
                 });
+                copy_selections_content(editor, false, cx);
                 editor.insert(&"", cx);
             });
         });

crates/vim/src/normal/delete.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{motion::Motion, Vim};
+use crate::{motion::Motion, utils::copy_selections_content, Vim};
 use collections::HashMap;
 use editor::{Autoscroll, Bias};
 use gpui::MutableAppContext;
@@ -15,6 +15,7 @@ pub fn delete_over(vim: &mut Vim, motion: Motion, cx: &mut MutableAppContext) {
                     original_columns.insert(selection.id, original_head.column());
                 });
             });
+            copy_selections_content(editor, motion.linewise(), cx);
             editor.insert(&"", cx);
 
             // Fixup cursor position after the deletion

crates/vim/src/utils.rs 🔗

@@ -0,0 +1,26 @@
+use editor::{ClipboardSelection, Editor};
+use gpui::{ClipboardItem, MutableAppContext};
+use language::Point;
+
+pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut MutableAppContext) {
+    let selections = editor.selections.all::<Point>(cx);
+    let buffer = editor.buffer().read(cx).snapshot(cx);
+    let mut text = String::new();
+    let mut clipboard_selections = Vec::with_capacity(selections.len());
+    {
+        for selection in selections.iter() {
+            let initial_len = text.len();
+            let start = selection.start;
+            let end = selection.end;
+            for chunk in buffer.text_for_range(start..end) {
+                text.push_str(chunk);
+            }
+            clipboard_selections.push(ClipboardSelection {
+                len: text.len() - initial_len,
+                is_entire_line: linewise,
+            });
+        }
+    }
+
+    cx.write_to_clipboard(ClipboardItem::new(text).with_metadata(clipboard_selections));
+}

crates/vim/src/vim.rs 🔗

@@ -6,6 +6,7 @@ mod insert;
 mod motion;
 mod normal;
 mod state;
+mod utils;
 mod visual;
 
 use collections::HashMap;
@@ -140,11 +141,14 @@ impl Vim {
                     }
 
                     if state.empty_selections_only() {
-                        editor.change_selections(None, cx, |s| {
-                            s.move_with(|_, selection| {
-                                selection.collapse_to(selection.head(), selection.goal)
-                            });
-                        })
+                        // Defer so that access to global settings object doesn't panic
+                        cx.defer(|editor, cx| {
+                            editor.change_selections(None, cx, |s| {
+                                s.move_with(|_, selection| {
+                                    selection.collapse_to(selection.head(), selection.goal)
+                                });
+                            })
+                        });
                     }
                 });
             }

crates/vim/src/visual.rs 🔗

@@ -3,7 +3,7 @@ use editor::{Autoscroll, Bias};
 use gpui::{actions, MutableAppContext, ViewContext};
 use workspace::Workspace;
 
-use crate::{motion::Motion, state::Mode, Vim};
+use crate::{motion::Motion, state::Mode, utils::copy_selections_content, Vim};
 
 actions!(
     vim,
@@ -41,7 +41,7 @@ pub fn visual_motion(motion: Motion, cx: &mut MutableAppContext) {
                         // Head was at the end of the selection, and now is at the start. We need to move the end
                         // forward by one if possible in order to compensate for this change.
                         *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = map.clip_point(selection.end, Bias::Left);
+                        selection.end = map.clip_point(selection.end, Bias::Right);
                     }
                 });
             });
@@ -63,6 +63,7 @@ pub fn change(_: &mut Workspace, _: &VisualChange, cx: &mut ViewContext<Workspac
                     }
                 });
             });
+            copy_selections_content(editor, false, cx);
             editor.insert("", cx);
         });
         vim.switch_mode(Mode::Insert, cx);
@@ -79,6 +80,7 @@ pub fn change_line(_: &mut Workspace, _: &VisualLineChange, cx: &mut ViewContext
                     selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
                 });
             });
+            copy_selections_content(editor, true, cx);
             editor.insert("", cx);
         });
         vim.switch_mode(Mode::Insert, cx);
@@ -87,19 +89,19 @@ pub fn change_line(_: &mut Workspace, _: &VisualLineChange, cx: &mut ViewContext
 
 pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.switch_mode(Mode::Normal, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.set_clip_at_line_ends(false, cx);
             editor.change_selections(Some(Autoscroll::Fit), cx, |s| {
                 s.move_with(|map, selection| {
                     if !selection.reversed {
-                        // Head was at the end of the selection, and now is at the start. We need to move the end
-                        // forward by one if possible in order to compensate for this change.
+                        // Head is at the end of the selection. Adjust the end position to
+                        // to include the character under the cursor.
                         *selection.end.column_mut() = selection.end.column() + 1;
-                        selection.end = map.clip_point(selection.end, Bias::Left);
+                        selection.end = map.clip_point(selection.end, Bias::Right);
                     }
                 });
             });
+            copy_selections_content(editor, false, cx);
             editor.insert("", cx);
 
             // Fixup cursor position after the deletion
@@ -112,6 +114,7 @@ pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspac
                 });
             });
         });
+        vim.switch_mode(Mode::Normal, cx);
     });
 }
 
@@ -138,6 +141,7 @@ pub fn delete_line(_: &mut Workspace, _: &VisualLineDelete, cx: &mut ViewContext
                     selection.end = map.next_line_boundary(selection.end.to_point(map)).1;
                 });
             });
+            copy_selections_content(editor, true, cx);
             editor.insert("", cx);
 
             // Fixup cursor position after the deletion