Rewrite paste

Conrad Irwin created

- vim: support P for paste before
- vim: support P in visual mode for paste without overriding clipboard
- vim: fix position when using `p` on text copied outside zed
- vim: fix indentation when using `p` on text copied from zed

Change summary

assets/keymaps/vim.json                           |  14 
crates/editor/src/editor.rs                       |  25 
crates/editor/src/test/editor_lsp_test_context.rs |  26 
crates/vim/src/normal.rs                          | 185 ------
crates/vim/src/normal/paste.rs                    | 468 +++++++++++++++++
crates/vim/src/test/neovim_backed_test_context.rs |  84 ++
crates/vim/src/test/neovim_connection.rs          |  31 +
crates/vim/src/utils.rs                           |   8 
crates/vim/src/visual.rs                          | 171 ------
crates/vim/test_data/test_p.json                  |  13 
crates/vim/test_data/test_paste.json              |  31 +
crates/vim/test_data/test_paste_visual.json       |  42 +
crates/vim/test_data/test_paste_visual_block.json |  31 +
crates/vim/test_data/test_visual_paste.json       |  26 
14 files changed, 779 insertions(+), 376 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -287,6 +287,12 @@
       "shift-o": "vim::InsertLineAbove",
       "~": "vim::ChangeCase",
       "p": "vim::Paste",
+      "shift-p": [
+        "vim::Paste",
+        {
+          "before": true
+        }
+      ],
       "u": "editor::Undo",
       "ctrl-r": "editor::Redo",
       "/": "vim::Search",
@@ -375,7 +381,13 @@
       "d": "vim::VisualDelete",
       "x": "vim::VisualDelete",
       "y": "vim::VisualYank",
-      "p": "vim::VisualPaste",
+      "p": "vim::Paste",
+      "shift-p": [
+        "vim::Paste",
+        {
+          "preserveClipboard": true
+        }
+      ],
       "s": "vim::Substitute",
       "c": "vim::Substitute",
       "~": "vim::ChangeCase",

crates/editor/src/editor.rs 🔗

@@ -1736,6 +1736,31 @@ impl Editor {
         });
     }
 
+    pub fn edit_with_block_indent<I, S, T>(
+        &mut self,
+        edits: I,
+        original_indent_columns: Vec<u32>,
+        cx: &mut ViewContext<Self>,
+    ) where
+        I: IntoIterator<Item = (Range<S>, T)>,
+        S: ToOffset,
+        T: Into<Arc<str>>,
+    {
+        if self.read_only {
+            return;
+        }
+
+        self.buffer.update(cx, |buffer, cx| {
+            buffer.edit(
+                edits,
+                Some(AutoindentMode::Block {
+                    original_indent_columns,
+                }),
+                cx,
+            )
+        });
+    }
+
     fn select(&mut self, phase: SelectPhase, cx: &mut ViewContext<Self>) {
         self.hide_context_menu(cx);
 

crates/editor/src/test/editor_lsp_test_context.rs 🔗

@@ -162,6 +162,15 @@ impl<'a> EditorLspTestContext<'a> {
             LanguageConfig {
                 name: "Typescript".into(),
                 path_suffixes: vec!["ts".to_string()],
+                brackets: language::BracketPairConfig {
+                    pairs: vec![language::BracketPair {
+                        start: "{".to_string(),
+                        end: "}".to_string(),
+                        close: true,
+                        newline: true,
+                    }],
+                    disabled_scopes_by_bracket_ix: Default::default(),
+                },
                 word_characters,
                 ..Default::default()
             },
@@ -174,6 +183,23 @@ impl<'a> EditorLspTestContext<'a> {
                 ("{" @open "}" @close)
                 ("<" @open ">" @close)
                 ("\"" @open "\"" @close)"#})),
+            indents: Some(Cow::from(indoc! {r#"
+                [
+                    (call_expression)
+                    (assignment_expression)
+                    (member_expression)
+                    (lexical_declaration)
+                    (variable_declaration)
+                    (assignment_expression)
+                    (if_statement)
+                    (for_statement)
+                ] @indent
+
+                (_ "[" "]" @end) @indent
+                (_ "<" ">" @end) @indent
+                (_ "{" "}" @end) @indent
+                (_ "(" ")" @end) @indent
+                "#})),
             ..Default::default()
         })
         .expect("Could not parse queries");

crates/vim/src/normal.rs 🔗

@@ -1,12 +1,13 @@
 mod case;
 mod change;
 mod delete;
+mod paste;
 mod scroll;
 mod search;
 pub mod substitute;
 mod yank;
 
-use std::{borrow::Cow, sync::Arc};
+use std::sync::Arc;
 
 use crate::{
     motion::Motion,
@@ -14,13 +15,11 @@ use crate::{
     state::{Mode, Operator},
     Vim,
 };
-use collections::{HashMap, HashSet};
-use editor::{
-    display_map::ToDisplayPoint, scroll::autoscroll::Autoscroll, Anchor, Bias, ClipboardSelection,
-    DisplayPoint,
-};
+use collections::HashSet;
+use editor::scroll::autoscroll::Autoscroll;
+use editor::{Bias, DisplayPoint};
 use gpui::{actions, AppContext, ViewContext, WindowContext};
-use language::{AutoindentMode, Point, SelectionGoal};
+use language::SelectionGoal;
 use log::error;
 use workspace::Workspace;
 
@@ -44,7 +43,6 @@ actions!(
         DeleteRight,
         ChangeToEndOfLine,
         DeleteToEndOfLine,
-        Paste,
         Yank,
         Substitute,
         ChangeCase,
@@ -89,9 +87,8 @@ pub fn init(cx: &mut AppContext) {
             delete_motion(vim, Motion::EndOfLine, times, cx);
         })
     });
-    cx.add_action(paste);
-
     scroll::init(cx);
+    paste::init(cx);
 }
 
 pub fn normal_motion(
@@ -250,144 +247,6 @@ 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| {
-                editor.set_clip_at_line_ends(false, cx);
-                if let Some(item) = cx.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);
-                        }
-
-                        // If the pasted text is a single line, the cursor should be placed after
-                        // the newly pasted text. This is easiest done with an anchor after the
-                        // insertion, and then with a fixup to move the selection back one position.
-                        // However if the pasted text is linewise, the cursor should be placed at the start
-                        // of the new text on the following line. This is easiest done with a manually adjusted
-                        // point.
-                        // This enum lets us represent both cases
-                        enum NewPosition {
-                            Inside(Point),
-                            After(Anchor),
-                        }
-                        let mut new_selections: HashMap<usize, NewPosition> = Default::default();
-                        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 insert_at = if 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
-                                    new_selections.insert(
-                                        selection.id,
-                                        NewPosition::Inside(Point::new(point.row + 1, 0)),
-                                    );
-                                    point
-                                } else {
-                                    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.insert(
-                                        selection.id,
-                                        if to_insert.contains('\n') {
-                                            NewPosition::Inside(point)
-                                        } else {
-                                            NewPosition::After(snapshot.anchor_after(point))
-                                        },
-                                    );
-                                    point
-                                };
-
-                                if linewise && to_insert.ends_with('\n') {
-                                    edits.push((
-                                        insert_at..insert_at,
-                                        &to_insert[0..to_insert.len().saturating_sub(1)],
-                                    ))
-                                } else {
-                                    edits.push((insert_at..insert_at, to_insert));
-                                }
-                            }
-                            drop(snapshot);
-                            buffer.edit(edits, Some(AutoindentMode::EachLine), cx);
-                        });
-
-                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                            s.move_with(|map, selection| {
-                                if let Some(new_position) = new_selections.get(&selection.id) {
-                                    match new_position {
-                                        NewPosition::Inside(new_point) => {
-                                            selection.collapse_to(
-                                                new_point.to_display_point(map),
-                                                SelectionGoal::None,
-                                            );
-                                        }
-                                        NewPosition::After(after_point) => {
-                                            let mut new_point = after_point.to_display_point(map);
-                                            *new_point.column_mut() =
-                                                new_point.column().saturating_sub(1);
-                                            new_point = map.clip_point(new_point, Bias::Left);
-                                            selection.collapse_to(new_point, SelectionGoal::None);
-                                        }
-                                    }
-                                }
-                            });
-                        });
-                    } else {
-                        editor.insert(&clipboard_text, cx);
-                    }
-                }
-                editor.set_clip_at_line_ends(true, cx);
-            });
-        });
-    });
-}
-
 pub(crate) fn normal_replace(text: Arc<str>, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
@@ -883,36 +742,6 @@ mod test {
             .await;
     }
 
-    #[gpui::test]
-    async fn test_p(cx: &mut gpui::TestAppContext) {
-        let mut cx = NeovimBackedTestContext::new(cx).await;
-        cx.set_shared_state(indoc! {"
-                The quick brown
-                fox juˇmps over
-                the lazy dog"})
-            .await;
-
-        cx.simulate_shared_keystrokes(["d", "d"]).await;
-        cx.assert_state_matches().await;
-
-        cx.simulate_shared_keystroke("p").await;
-        cx.assert_state_matches().await;
-
-        cx.set_shared_state(indoc! {"
-                The quick brown
-                fox ˇjumps over
-                the lazy dog"})
-            .await;
-        cx.simulate_shared_keystrokes(["v", "w", "y"]).await;
-        cx.set_shared_state(indoc! {"
-                The quick brown
-                fox jumps oveˇr
-                the lazy dog"})
-            .await;
-        cx.simulate_shared_keystroke("p").await;
-        cx.assert_state_matches().await;
-    }
-
     #[gpui::test]
     async fn test_repeated_word(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

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

@@ -0,0 +1,468 @@
+use std::{borrow::Cow, cmp};
+
+use editor::{
+    display_map::ToDisplayPoint, movement, scroll::autoscroll::Autoscroll, ClipboardSelection,
+    DisplayPoint,
+};
+use gpui::{impl_actions, AppContext, ViewContext};
+use language::{Bias, SelectionGoal};
+use serde::Deserialize;
+use workspace::Workspace;
+
+use crate::{state::Mode, utils::copy_selections_content, Vim};
+
+#[derive(Clone, Deserialize, PartialEq)]
+#[serde(rename_all = "camelCase")]
+struct Paste {
+    #[serde(default)]
+    before: bool,
+    #[serde(default)]
+    preserve_clipboard: bool,
+}
+
+impl_actions!(vim, [Paste]);
+
+pub(crate) fn init(cx: &mut AppContext) {
+    cx.add_action(paste);
+}
+
+fn paste(_: &mut Workspace, action: &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);
+
+                let Some(item) = cx.read_from_clipboard() else {
+                    return
+                };
+                let clipboard_text = Cow::Borrowed(item.text());
+                if clipboard_text.is_empty() {
+                    return;
+                }
+
+                if !action.preserve_clipboard && vim.state().mode.is_visual() {
+                    copy_selections_content(editor, vim.state().mode == Mode::VisualLine, cx);
+                }
+
+                // if we are copying from multi-cursor (of visual block mode), we want
+                // to
+                let clipboard_selections =
+                    item.metadata::<Vec<ClipboardSelection>>()
+                        .filter(|clipboard_selections| {
+                            clipboard_selections.len() > 1 && vim.state().mode != Mode::VisualLine
+                        });
+
+                let (display_map, current_selections) = editor.selections.all_adjusted_display(cx);
+
+                // unlike zed, if you have a multi-cursor selection from vim block mode,
+                // pasting it will paste it on subsequent lines, even if you don't yet
+                // have a cursor there.
+                let mut selections_to_process = Vec::new();
+                let mut i = 0;
+                while i < current_selections.len() {
+                    selections_to_process
+                        .push((current_selections[i].start..current_selections[i].end, true));
+                    i += 1;
+                }
+                if let Some(clipboard_selections) = clipboard_selections.as_ref() {
+                    let left = current_selections
+                        .iter()
+                        .map(|selection| cmp::min(selection.start.column(), selection.end.column()))
+                        .min()
+                        .unwrap();
+                    let mut row = current_selections.last().unwrap().end.row() + 1;
+                    while i < clipboard_selections.len() {
+                        let cursor =
+                            display_map.clip_point(DisplayPoint::new(row, left), Bias::Left);
+                        selections_to_process.push((cursor..cursor, false));
+                        i += 1;
+                        row += 1;
+                    }
+                }
+
+                let first_selection_indent_column =
+                    clipboard_selections.as_ref().and_then(|zed_selections| {
+                        zed_selections
+                            .first()
+                            .map(|selection| selection.first_line_indent)
+                    });
+                let before = action.before || vim.state().mode == Mode::VisualLine;
+
+                let mut edits = Vec::new();
+                let mut new_selections = Vec::new();
+                let mut original_indent_columns = Vec::new();
+                let mut start_offset = 0;
+
+                for (ix, (selection, preserve)) in selections_to_process.iter().enumerate() {
+                    let (mut to_insert, original_indent_column) =
+                        if let Some(clipboard_selections) = &clipboard_selections {
+                            if let Some(clipboard_selection) = clipboard_selections.get(ix) {
+                                let end_offset = start_offset + clipboard_selection.len;
+                                let text = clipboard_text[start_offset..end_offset].to_string();
+                                start_offset = end_offset + 1;
+                                (text, Some(clipboard_selection.first_line_indent))
+                            } else {
+                                ("".to_string(), first_selection_indent_column)
+                            }
+                        } else {
+                            (clipboard_text.to_string(), first_selection_indent_column)
+                        };
+                    let line_mode = to_insert.ends_with("\n");
+                    let is_multiline = to_insert.contains("\n");
+
+                    if line_mode && !before {
+                        if selection.is_empty() {
+                            to_insert =
+                                "\n".to_owned() + &to_insert[..to_insert.len() - "\n".len()];
+                        } else {
+                            to_insert = "\n".to_owned() + &to_insert;
+                        }
+                    } else if !line_mode && vim.state().mode == Mode::VisualLine {
+                        to_insert = to_insert + "\n";
+                    }
+
+                    let display_range = if !selection.is_empty() {
+                        selection.start..selection.end
+                    } else if line_mode {
+                        let point = if before {
+                            movement::line_beginning(&display_map, selection.start, false)
+                        } else {
+                            movement::line_end(&display_map, selection.start, false)
+                        };
+                        point..point
+                    } else {
+                        let point = if before {
+                            selection.start
+                        } else {
+                            movement::saturating_right(&display_map, selection.start)
+                        };
+                        point..point
+                    };
+
+                    let point_range = display_range.start.to_point(&display_map)
+                        ..display_range.end.to_point(&display_map);
+                    let anchor = if is_multiline || vim.state().mode == Mode::VisualLine {
+                        display_map.buffer_snapshot.anchor_before(point_range.start)
+                    } else {
+                        display_map.buffer_snapshot.anchor_after(point_range.end)
+                    };
+
+                    if *preserve {
+                        new_selections.push((anchor, line_mode, is_multiline));
+                    }
+                    edits.push((point_range, to_insert));
+                    original_indent_columns.extend(original_indent_column);
+                }
+
+                editor.edit_with_block_indent(edits, original_indent_columns, cx);
+
+                // in line_mode vim will insert the new text on the next (or previous if before) line
+                // and put the cursor on the first non-blank character of the first inserted line (or at the end if the first line is blank).
+                // otherwise vim will insert the next text at (or before) the current cursor position,
+                // the cursor will go to the last (or first, if is_multiline) inserted character.
+                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                    s.replace_cursors_with(|map| {
+                        let mut cursors = Vec::new();
+                        for (anchor, line_mode, is_multiline) in &new_selections {
+                            let mut cursor = anchor.to_display_point(map);
+                            if *line_mode {
+                                if !before {
+                                    cursor =
+                                        movement::down(map, cursor, SelectionGoal::None, false).0;
+                                }
+                                cursor = movement::indented_line_beginning(map, cursor, true);
+                            } else if !is_multiline {
+                                cursor = movement::saturating_left(map, cursor)
+                            }
+                            cursors.push(cursor);
+                            if vim.state().mode == Mode::VisualBlock {
+                                break;
+                            }
+                        }
+
+                        cursors
+                    });
+                })
+            });
+        });
+        vim.switch_mode(Mode::Normal, true, cx);
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
+    use indoc::indoc;
+
+    #[gpui::test]
+    async fn test_paste(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // single line
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox ˇjumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "w", "y"]).await;
+        cx.assert_shared_clipboard("jumps o").await;
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jumps oveˇr
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            fox jumps overjumps ˇo
+            the lazy dog"})
+            .await;
+
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jumps oveˇr
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystroke("shift-p").await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            fox jumps ovejumps ˇor
+            the lazy dog"})
+            .await;
+
+        // line mode
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox juˇmps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["d", "d"]).await;
+        cx.assert_shared_clipboard("fox jumps over\n").await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            the laˇzy dog"})
+            .await;
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            the lazy dog
+            ˇfox jumps over"})
+            .await;
+        cx.simulate_shared_keystrokes(["k", "shift-p"]).await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            ˇfox jumps over
+            the lazy dog
+            fox jumps over"})
+            .await;
+
+        // multiline, cursor to first character of pasted text.
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox jumps ˇover
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "j", "y"]).await;
+        cx.assert_shared_clipboard("over\nthe lazy do").await;
+
+        cx.simulate_shared_keystroke("p").await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            fox jumps oˇover
+            the lazy dover
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["u", "shift-p"]).await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            fox jumps ˇover
+            the lazy doover
+            the lazy dog"})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_paste_visual(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // copy in visual mode
+        cx.set_shared_state(indoc! {"
+                The quick brown
+                fox jˇumps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "i", "w", "y"]).await;
+        cx.assert_shared_state(indoc! {"
+                The quick brown
+                fox ˇjumps over
+                the lazy dog"})
+            .await;
+        // paste in visual mode
+        cx.simulate_shared_keystrokes(["w", "v", "i", "w", "p"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+                The quick brown
+                fox jumps jumpˇs
+                the lazy dog"})
+            .await;
+        cx.assert_shared_clipboard("over").await;
+        // paste in visual line mode
+        cx.simulate_shared_keystrokes(["up", "shift-v", "shift-p"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            ˇover
+            fox jumps jumps
+            the lazy dog"})
+            .await;
+        cx.assert_shared_clipboard("over").await;
+        // paste in visual block mode
+        cx.simulate_shared_keystrokes(["ctrl-v", "down", "down", "p"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            oveˇrver
+            overox jumps jumps
+            overhe lazy dog"})
+            .await;
+
+        // copy in visual line mode
+        cx.set_shared_state(indoc! {"
+                The quick brown
+                fox juˇmps over
+                the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-v", "d"]).await;
+        cx.assert_shared_state(indoc! {"
+                The quick brown
+                the laˇzy dog"})
+            .await;
+        // paste in visual mode
+        cx.simulate_shared_keystrokes(["v", "i", "w", "p"]).await;
+        cx.assert_shared_state(
+            &indoc! {"
+                The quick brown
+                the_
+                ˇfox jumps over
+                _dog"}
+            .replace("_", " "), // Hack for trailing whitespace
+        )
+        .await;
+        cx.assert_shared_clipboard("lazy").await;
+        cx.set_shared_state(indoc! {"
+            The quick brown
+            fox juˇmps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-v", "d"]).await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            the laˇzy dog"})
+            .await;
+        // paste in visual line mode
+        cx.simulate_shared_keystrokes(["k", "shift-v", "p"]).await;
+        cx.assert_shared_state(indoc! {"
+            ˇfox jumps over
+            the lazy dog"})
+            .await;
+        cx.assert_shared_clipboard("The quick brown\n").await;
+    }
+
+    #[gpui::test]
+    async fn test_paste_visual_block(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        // copy in visual block mode
+        cx.set_shared_state(indoc! {"
+            The ˇquick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "2", "j", "y"])
+            .await;
+        cx.assert_shared_clipboard("q\nj\nl").await;
+        cx.simulate_shared_keystrokes(["p"]).await;
+        cx.assert_shared_state(indoc! {"
+            The qˇquick brown
+            fox jjumps over
+            the llazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "i", "w", "shift-p"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The ˇq brown
+            fox jjjumps over
+            the lllazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["v", "i", "w", "shift-p"])
+            .await;
+
+        cx.set_shared_state(indoc! {"
+            The ˇquick brown
+            fox jumps over
+            the lazy dog"})
+            .await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "j", "y"]).await;
+        cx.assert_shared_clipboard("q\nj").await;
+        cx.simulate_shared_keystrokes(["l", "ctrl-v", "2", "j", "shift-p"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The qˇqick brown
+            fox jjmps over
+            the lzy dog"})
+            .await;
+
+        cx.simulate_shared_keystrokes(["shift-v", "p"]).await;
+        cx.assert_shared_state(indoc! {"
+            ˇq
+            j
+            fox jjmps over
+            the lzy dog"})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_paste_indent(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new_typescript(cx).await;
+
+        cx.set_state(
+            indoc! {"
+            class A {ˇ
+            }
+        "},
+            Mode::Normal,
+        );
+        cx.simulate_keystrokes(["o", "a", "(", ")", "{", "escape"]);
+        cx.assert_state(
+            indoc! {"
+            class A {
+                a()ˇ{}
+            }
+            "},
+            Mode::Normal,
+        );
+        // cursor goes to the first non-blank character in the line;
+        cx.simulate_keystrokes(["y", "y", "p"]);
+        cx.assert_state(
+            indoc! {"
+            class A {
+                a(){}
+                ˇa(){}
+            }
+            "},
+            Mode::Normal,
+        );
+        // indentation is preserved when pasting
+        cx.simulate_keystrokes(["u", "shift-v", "up", "y", "shift-p"]);
+        cx.assert_state(
+            indoc! {"
+                ˇclass A {
+                    a(){}
+                class A {
+                    a(){}
+                }
+                "},
+            Mode::Normal,
+        );
+    }
+}

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

@@ -129,14 +129,23 @@ impl<'a> NeovimBackedTestContext<'a> {
 
     pub async fn assert_shared_state(&mut self, marked_text: &str) {
         let neovim = self.neovim_state().await;
-        if neovim != marked_text {
-            let initial_state = self
-                .last_set_state
-                .as_ref()
-                .unwrap_or(&"N/A".to_string())
-                .clone();
-            panic!(
-                indoc! {"Test is incorrect (currently expected != neovim state)
+        let editor = self.editor_state();
+        if neovim == marked_text && neovim == editor {
+            return;
+        }
+        let initial_state = self
+            .last_set_state
+            .as_ref()
+            .unwrap_or(&"N/A".to_string())
+            .clone();
+
+        let message = if neovim != marked_text {
+            "Test is incorrect (currently expected != neovim_state)"
+        } else {
+            "Editor does not match nvim behaviour"
+        };
+        panic!(
+            indoc! {"{}
                 # initial state:
                 {}
                 # keystrokes:
@@ -147,14 +156,59 @@ impl<'a> NeovimBackedTestContext<'a> {
                 {}
                 # zed state:
                 {}"},
-                initial_state,
-                self.recent_keystrokes.join(" "),
-                marked_text,
-                neovim,
-                self.editor_state(),
-            )
+            message,
+            initial_state,
+            self.recent_keystrokes.join(" "),
+            marked_text,
+            neovim,
+            editor
+        )
+    }
+
+    pub async fn assert_shared_clipboard(&mut self, text: &str) {
+        let neovim = self.neovim.read_register('"').await;
+        let editor = self
+            .platform()
+            .read_from_clipboard()
+            .unwrap()
+            .text()
+            .clone();
+
+        if text == neovim && text == editor {
+            return;
         }
-        self.assert_editor_state(marked_text)
+
+        let message = if neovim != text {
+            "Test is incorrect (currently expected != neovim)"
+        } else {
+            "Editor does not match nvim behaviour"
+        };
+
+        let initial_state = self
+            .last_set_state
+            .as_ref()
+            .unwrap_or(&"N/A".to_string())
+            .clone();
+
+        panic!(
+            indoc! {"{}
+                # initial state:
+                {}
+                # keystrokes:
+                {}
+                # currently expected:
+                {}
+                # neovim clipboard:
+                {}
+                # zed clipboard:
+                {}"},
+            message,
+            initial_state,
+            self.recent_keystrokes.join(" "),
+            text,
+            neovim,
+            editor
+        )
     }
 
     pub async fn neovim_state(&mut self) -> String {

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

@@ -40,6 +40,7 @@ pub enum NeovimData {
     Put { state: String },
     Key(String),
     Get { state: String, mode: Option<Mode> },
+    ReadRegister { name: char, value: String },
 }
 
 pub struct NeovimConnection {
@@ -221,6 +222,36 @@ impl NeovimConnection {
         );
     }
 
+    #[cfg(not(feature = "neovim"))]
+    pub async fn read_register(&mut self, register: char) -> String {
+        if let Some(NeovimData::Get { .. }) = self.data.front() {
+            self.data.pop_front();
+        };
+        if let Some(NeovimData::ReadRegister { name, value }) = self.data.pop_front() {
+            if name == register {
+                return value;
+            }
+        }
+
+        panic!("operation does not match recorded script. re-record with --features=neovim")
+    }
+
+    #[cfg(feature = "neovim")]
+    pub async fn read_register(&mut self, name: char) -> String {
+        let value = self
+            .nvim
+            .command_output(format!("echo getreg('{}')", name).as_str())
+            .await
+            .unwrap();
+
+        self.data.push_back(NeovimData::ReadRegister {
+            name,
+            value: value.clone(),
+        });
+
+        value
+    }
+
     #[cfg(feature = "neovim")]
     async fn read_position(&mut self, cmd: &str) -> u32 {
         self.nvim

crates/vim/src/utils.rs 🔗

@@ -7,10 +7,16 @@ pub fn copy_selections_content(editor: &mut Editor, linewise: bool, cx: &mut App
     let mut text = String::new();
     let mut clipboard_selections = Vec::with_capacity(selections.len());
     {
+        let mut is_first = true;
         for selection in selections.iter() {
-            let initial_len = text.len();
             let start = selection.start;
             let end = selection.end;
+            if is_first {
+                is_first = false;
+            } else {
+                text.push_str("\n");
+            }
+            let initial_len = text.len();
             for chunk in buffer.text_for_range(start..end) {
                 text.push_str(chunk);
             }

crates/vim/src/visual.rs 🔗

@@ -1,14 +1,14 @@
-use std::{borrow::Cow, cmp, sync::Arc};
+use std::{cmp, sync::Arc};
 
 use collections::HashMap;
 use editor::{
     display_map::{DisplaySnapshot, ToDisplayPoint},
     movement,
     scroll::autoscroll::Autoscroll,
-    Bias, ClipboardSelection, DisplayPoint, Editor,
+    Bias, DisplayPoint, Editor,
 };
 use gpui::{actions, AppContext, ViewContext, WindowContext};
-use language::{AutoindentMode, Selection, SelectionGoal};
+use language::{Selection, SelectionGoal};
 use workspace::Workspace;
 
 use crate::{
@@ -27,7 +27,6 @@ actions!(
         ToggleVisualBlock,
         VisualDelete,
         VisualYank,
-        VisualPaste,
         OtherEnd,
     ]
 );
@@ -47,7 +46,6 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(other_end);
     cx.add_action(delete);
     cx.add_action(yank);
-    cx.add_action(paste);
 }
 
 pub fn visual_motion(motion: Motion, times: Option<usize>, cx: &mut WindowContext) {
@@ -331,110 +329,6 @@ 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.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 adjusted = selection.end;
-                                    // 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;
-                                    new_position.column = 0;
-                                    new_position.row += 1;
-                                    new_position
-                                } else {
-                                    range.start
-                                };
-
-                                new_selections.push(selection.map(|_| new_position));
-
-                                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(edits, Some(AutoindentMode::EachLine), cx);
-                        });
-
-                        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                            s.select(new_selections)
-                        });
-                    } else {
-                        editor.insert(&clipboard_text, cx);
-                    }
-                }
-            });
-        });
-        vim.switch_mode(Mode::Normal, true, cx);
-    });
-}
-
 pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.update_active_editor(cx, |editor, cx| {
@@ -796,65 +690,6 @@ mod test {
             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 «jumpsˇ» over
-                the lazy dog"},
-            Mode::Visual,
-        );
-        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 jumpsjumpˇs over
-                the lazy dog"},
-            Mode::Normal,
-        );
-
-        cx.set_state(
-            indoc! {"
-                The quick brown
-                fox ju«mˇ»ps over
-                the lazy dog"},
-            Mode::VisualLine,
-        );
-        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 «lazyˇ» dog"},
-            Mode::Visual,
-        );
-        cx.simulate_keystroke("p");
-        cx.assert_state(
-            &indoc! {"
-                The quick brown
-                the_
-                ˇfox jumps over
-                dog"}
-            .replace("_", " "), // Hack for trailing whitespace
-            Mode::Normal,
-        );
-    }
-
     #[gpui::test]
     async fn test_visual_block_mode(cx: &mut gpui::TestAppContext) {
         let mut cx = NeovimBackedTestContext::new(cx).await;

crates/vim/test_data/test_p.json 🔗

@@ -1,13 +0,0 @@
-{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
-{"Key":"d"}
-{"Key":"d"}
-{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
-{"Key":"p"}
-{"Get":{"state":"The quick brown\nthe lazy dog\nˇfox jumps over","mode":"Normal"}}
-{"Put":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog"}}
-{"Key":"v"}
-{"Key":"w"}
-{"Key":"y"}
-{"Put":{"state":"The quick brown\nfox jumps oveˇr\nthe lazy dog"}}
-{"Key":"p"}
-{"Get":{"state":"The quick brown\nfox jumps overjumps ˇo\nthe lazy dog","mode":"Normal"}}

crates/vim/test_data/test_paste.json 🔗

@@ -0,0 +1,31 @@
+{"Put":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"w"}
+{"Key":"y"}
+{"ReadRegister":{"name":"\"","value":"jumps o"}}
+{"Put":{"state":"The quick brown\nfox jumps oveˇr\nthe lazy dog"}}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nfox jumps overjumps ˇo\nthe lazy dog","mode":"Normal"}}
+{"Put":{"state":"The quick brown\nfox jumps oveˇr\nthe lazy dog"}}
+{"Key":"shift-p"}
+{"Get":{"state":"The quick brown\nfox jumps ovejumps ˇor\nthe lazy dog","mode":"Normal"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"d"}
+{"Key":"d"}
+{"ReadRegister":{"name":"\"","value":"fox jumps over\n"}}
+{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nthe lazy dog\nˇfox jumps over","mode":"Normal"}}
+{"Key":"k"}
+{"Key":"shift-p"}
+{"Get":{"state":"The quick brown\nˇfox jumps over\nthe lazy dog\nfox jumps over","mode":"Normal"}}
+{"Put":{"state":"The quick brown\nfox jumps ˇover\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"j"}
+{"Key":"y"}
+{"ReadRegister":{"name":"\"","value":"over\nthe lazy do"}}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nfox jumps oˇover\nthe lazy dover\nthe lazy dog","mode":"Normal"}}
+{"Key":"u"}
+{"Key":"shift-p"}
+{"Get":{"state":"The quick brown\nfox jumps ˇover\nthe lazy doover\nthe lazy dog","mode":"Normal"}}

crates/vim/test_data/test_paste_visual.json 🔗

@@ -0,0 +1,42 @@
+{"Put":{"state":"The quick brown\nfox jˇumps over\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"y"}
+{"Get":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"w"}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nfox jumps jumpˇs\nthe lazy dog","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"over"}}
+{"Key":"up"}
+{"Key":"shift-v"}
+{"Key":"shift-p"}
+{"Get":{"state":"ˇover\nfox jumps jumps\nthe lazy dog","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"over"}}
+{"Key":"ctrl-v"}
+{"Key":"down"}
+{"Key":"down"}
+{"Key":"p"}
+{"Get":{"state":"oveˇrver\noverox jumps jumps\noverhe lazy dog","mode":"Normal"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Key":"d"}
+{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nthe \nˇfox jumps over\n dog","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"lazy"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Key":"d"}
+{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
+{"Key":"k"}
+{"Key":"shift-v"}
+{"Key":"p"}
+{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"The quick brown\n"}}

crates/vim/test_data/test_paste_visual_block.json 🔗

@@ -0,0 +1,31 @@
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"2"}
+{"Key":"j"}
+{"Key":"y"}
+{"ReadRegister":{"name":"\"","value":"q\nj\nl"}}
+{"Key":"p"}
+{"Get":{"state":"The qˇquick brown\nfox jjumps over\nthe llazy dog","mode":"Normal"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"shift-p"}
+{"Get":{"state":"The ˇq brown\nfox jjjumps over\nthe lllazy dog","mode":"Normal"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"shift-p"}
+{"Put":{"state":"The ˇquick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"y"}
+{"ReadRegister":{"name":"\"","value":"q\nj"}}
+{"Key":"l"}
+{"Key":"ctrl-v"}
+{"Key":"2"}
+{"Key":"j"}
+{"Key":"shift-p"}
+{"Get":{"state":"The qˇqick brown\nfox jjmps over\nthe lzy dog","mode":"Normal"}}
+{"Key":"shift-v"}
+{"Key":"p"}
+{"Get":{"state":"ˇq\nj\nfox jjmps over\nthe lzy dog","mode":"Normal"}}

crates/vim/test_data/test_visual_paste.json 🔗

@@ -0,0 +1,26 @@
+{"Put":{"state":"The quick brown\nfox jˇumps over\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"y"}
+{"Get":{"state":"The quick brown\nfox ˇjumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nfox jjumpˇsumps over\nthe lazy dog","mode":"Normal"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Key":"d"}
+{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"p"}
+{"Get":{"state":"The quick brown\nthe \nˇfox jumps over\n dog","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"lazy"}}
+{"Put":{"state":"The quick brown\nfox juˇmps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Key":"d"}
+{"Get":{"state":"The quick brown\nthe laˇzy dog","mode":"Normal"}}
+{"Key":"k"}
+{"Key":"shift-v"}
+{"Key":"p"}
+{"Get":{"state":"ˇfox jumps over\nthe lazy dog","mode":"Normal"}}