Fix yank around paragraph missing newline (#43583)

Ramon and dino created

Use `MotionKind::LineWise` in both
`vim::normal::change::Vim.change_object` and
`vim::normal::yank::Vim.yank_object` when dealing with objects that
target `Mode::VisualLine`, for example, paragraphs. This fixes an issue
where yanking and changing paragraphs would not include the trailing
newline character.

Closes #28804

Release Notes:

- Fixed linewise text object operations (`yap`, `cap`, etc.) omitting
trailing blank line in vim mode

---------

Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

crates/vim/src/normal/change.rs                          |  6 
crates/vim/src/normal/yank.rs                            |  6 
crates/vim/src/test.rs                                   | 73 ++++++++++
crates/vim/test_data/test_change_paragraph.json          |  8 +
crates/vim/test_data/test_yank_paragraph_with_paste.json | 10 +
5 files changed, 101 insertions(+), 2 deletions(-)

Detailed changes

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

@@ -121,7 +121,11 @@ impl Vim {
                     });
                 });
                 if objects_found {
-                    vim.copy_selections_content(editor, MotionKind::Exclusive, window, cx);
+                    let kind = match object.target_visual_mode(vim.mode, around) {
+                        Mode::VisualLine => MotionKind::Linewise,
+                        _ => MotionKind::Exclusive,
+                    };
+                    vim.copy_selections_content(editor, kind, window, cx);
                     editor.insert("", window, cx);
                     editor.refresh_edit_prediction(true, false, window, cx);
                 }

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

@@ -81,7 +81,11 @@ impl Vim {
                         start_positions.insert(selection.id, start_position);
                     });
                 });
-                vim.yank_selections_content(editor, MotionKind::Exclusive, window, cx);
+                let kind = match object.target_visual_mode(vim.mode, around) {
+                    Mode::VisualLine => MotionKind::Linewise,
+                    _ => MotionKind::Exclusive,
+                };
+                vim.yank_selections_content(editor, kind, window, cx);
                 editor.change_selections(SelectionEffects::no_scroll(), window, cx, |s| {
                     s.move_with(|_, selection| {
                         let (head, goal) = start_positions.remove(&selection.id).unwrap();

crates/vim/src/test.rs 🔗

@@ -2253,6 +2253,79 @@ async fn test_paragraph_multi_delete(cx: &mut gpui::TestAppContext) {
     cx.shared_state().await.assert_eq(indoc! {"ˇ"});
 }
 
+#[perf]
+#[gpui::test]
+async fn test_yank_paragraph_with_paste(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+    cx.set_shared_state(indoc! {
+        "
+        first paragraph
+        ˇstill first
+
+        second paragraph
+        still second
+
+        third paragraph
+        "
+    })
+    .await;
+
+    cx.simulate_shared_keystrokes("y a p").await;
+    cx.shared_clipboard()
+        .await
+        .assert_eq("first paragraph\nstill first\n\n");
+
+    cx.simulate_shared_keystrokes("j j p").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "
+        first paragraph
+        still first
+
+        ˇfirst paragraph
+        still first
+
+        second paragraph
+        still second
+
+        third paragraph
+        "
+    });
+}
+
+#[perf]
+#[gpui::test]
+async fn test_change_paragraph(cx: &mut gpui::TestAppContext) {
+    let mut cx = NeovimBackedTestContext::new(cx).await;
+    cx.set_shared_state(indoc! {
+        "
+        first paragraph
+        ˇstill first
+
+        second paragraph
+        still second
+
+        third paragraph
+        "
+    })
+    .await;
+
+    cx.simulate_shared_keystrokes("c a p").await;
+    cx.shared_clipboard()
+        .await
+        .assert_eq("first paragraph\nstill first\n\n");
+
+    cx.simulate_shared_keystrokes("escape").await;
+    cx.shared_state().await.assert_eq(indoc! {
+        "
+        ˇ
+        second paragraph
+        still second
+
+        third paragraph
+        "
+    });
+}
+
 #[perf]
 #[gpui::test]
 async fn test_multi_cursor_replay(cx: &mut gpui::TestAppContext) {

crates/vim/test_data/test_change_paragraph.json 🔗

@@ -0,0 +1,8 @@
+{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}}
+{"Key":"c"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Insert"}}
+{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}}
+{"Key":"escape"}
+{"Get":{"state":"ˇ\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}}

crates/vim/test_data/test_yank_paragraph_with_paste.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"first paragraph\nˇstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n"}}
+{"Key":"y"}
+{"Key":"a"}
+{"Key":"p"}
+{"Get":{"state":"ˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}}
+{"ReadRegister":{"name":"\"","value":"first paragraph\nstill first\n\n"}}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"p"}
+{"Get":{"state":"first paragraph\nstill first\n\nˇfirst paragraph\nstill first\n\nsecond paragraph\nstill second\n\nthird paragraph\n","mode":"Normal"}}