diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index 46d24edd7d..c0de3420f2 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index cbc7a7cd42..37a7a93c15 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -1736,6 +1736,31 @@ impl Editor { }); } + pub fn edit_with_block_indent( + &mut self, + edits: I, + original_indent_columns: Vec, + cx: &mut ViewContext, + ) where + I: IntoIterator, T)>, + S: ToOffset, + T: Into>, + { + 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.hide_context_menu(cx); @@ -4741,6 +4766,7 @@ impl Editor { let mut clipboard_selections = Vec::with_capacity(selections.len()); { let max_point = buffer.max_point(); + let mut is_first = true; for selection in &mut selections { let is_entire_line = selection.is_empty() || self.selections.line_mode; if is_entire_line { @@ -4748,6 +4774,11 @@ impl Editor { selection.end = cmp::min(max_point, Point::new(selection.end.row + 1, 0)); selection.goal = SelectionGoal::None; } + if is_first { + is_first = false; + } else { + text += "\n"; + } let mut len = 0; for chunk in buffer.text_for_range(selection.start..selection.end) { text.push_str(chunk); @@ -4778,6 +4809,7 @@ impl Editor { let mut clipboard_selections = Vec::with_capacity(selections.len()); { let max_point = buffer.max_point(); + let mut is_first = true; for selection in selections.iter() { let mut start = selection.start; let mut end = selection.end; @@ -4786,6 +4818,11 @@ impl Editor { start = Point::new(start.row, 0); end = cmp::min(max_point, Point::new(end.row + 1, 0)); } + if is_first { + is_first = false; + } else { + text += "\n"; + } let mut len = 0; for chunk in buffer.text_for_range(start..end) { text.push_str(chunk); @@ -4805,7 +4842,7 @@ impl Editor { pub fn paste(&mut self, _: &Paste, cx: &mut ViewContext) { self.transact(cx, |this, cx| { if let Some(item) = cx.read_from_clipboard() { - let mut clipboard_text = Cow::Borrowed(item.text()); + let clipboard_text = Cow::Borrowed(item.text()); if let Some(mut clipboard_selections) = item.metadata::>() { let old_selections = this.selections.all::(cx); let all_selections_were_entire_line = @@ -4813,18 +4850,7 @@ impl Editor { let first_selection_indent_column = clipboard_selections.first().map(|s| s.first_line_indent); if clipboard_selections.len() != old_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); + clipboard_selections.drain(..); } this.buffer.update(cx, |buffer, cx| { @@ -4840,8 +4866,9 @@ impl Editor { 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]; + dbg!(start_offset, end_offset, &clipboard_text, &to_insert); entire_line = clipboard_selection.is_entire_line; - start_offset = end_offset; + start_offset = end_offset + 1; original_indent_column = Some(clipboard_selection.first_line_indent); } else { diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 4d0e9c1d2a..668d6abf21 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/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"); diff --git a/crates/vim/src/normal.rs b/crates/vim/src/normal.rs index ca26a7a217..3a2d15a878 100644 --- a/crates/vim/src/normal.rs +++ b/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) { - 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::>() - { - 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 = 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, 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; diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs new file mode 100644 index 0000000000..3d16bb3552 --- /dev/null +++ b/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) { + 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::>() + .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, + ); + } +} diff --git a/crates/vim/src/test/neovim_backed_test_context.rs b/crates/vim/src/test/neovim_backed_test_context.rs index 263692b36e..f4b0e96183 100644 --- a/crates/vim/src/test/neovim_backed_test_context.rs +++ b/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 { diff --git a/crates/vim/src/test/neovim_connection.rs b/crates/vim/src/test/neovim_connection.rs index fc677f032c..68f3374772 100644 --- a/crates/vim/src/test/neovim_connection.rs +++ b/crates/vim/src/test/neovim_connection.rs @@ -40,6 +40,7 @@ pub enum NeovimData { Put { state: String }, Key(String), Get { state: String, mode: Option }, + 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 diff --git a/crates/vim/src/utils.rs b/crates/vim/src/utils.rs index 7f9a549367..c8ca4df72b 100644 --- a/crates/vim/src/utils.rs +++ b/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); } diff --git a/crates/vim/src/visual.rs b/crates/vim/src/visual.rs index df7c8cfa45..1a11721a4e 100644 --- a/crates/vim/src/visual.rs +++ b/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, cx: &mut WindowContext) { @@ -331,110 +329,6 @@ pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext) }); } -pub fn paste(_: &mut Workspace, _: &VisualPaste, cx: &mut ViewContext) { - 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::>() - { - 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, 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; diff --git a/crates/vim/test_data/test_p.json b/crates/vim/test_data/test_p.json deleted file mode 100644 index 57fc863392..0000000000 --- a/crates/vim/test_data/test_p.json +++ /dev/null @@ -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"}} diff --git a/crates/vim/test_data/test_paste.json b/crates/vim/test_data/test_paste.json new file mode 100644 index 0000000000..e70da6c435 --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_paste_visual.json b/crates/vim/test_data/test_paste_visual.json new file mode 100644 index 0000000000..5d85540820 --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_paste_visual_block.json b/crates/vim/test_data/test_paste_visual_block.json new file mode 100644 index 0000000000..559e950724 --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_visual_paste.json b/crates/vim/test_data/test_visual_paste.json new file mode 100644 index 0000000000..a0ad377378 --- /dev/null +++ b/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"}}