vim: Fix visual mode entry at line end near trailing newline (#50709)

Skanda Bhat , SkandaBhat , and dino created

In Helix, selecting a line with `x` creates a selection from column 0 of
the current row to column 0 of the next row. The default
`InsertEndOfLine` uses the selection head (which is on the next row) to
find the line end, placing the cursor on the wrong line. 

This commit introduces a new `HelixInsertEndOfLine`, mapped by default
to `shift-a` when Helix mode is enabled, that moves left from the head
first to land on the correct line.

Release Notes:

- Fixed `shift-a` in Helix select mode placing the cursor on the wrong
line after selecting with `x`

---------

Co-authored-by: SkandaBhat <9384046+SkandaBhat@users.noreply.github.com>
Co-authored-by: dino <dinojoaocosta@gmail.com>

Change summary

assets/keymaps/vim.json |   1 
crates/vim/src/helix.rs | 119 +++++++++++++++++++++++++++++++++++++++++++
2 files changed, 120 insertions(+)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -427,6 +427,7 @@
       "escape": "vim::SwitchToHelixNormalMode",
       "i": "vim::HelixInsert",
       "a": "vim::HelixAppend",
+      "shift-a": "vim::HelixInsertEndOfLine",
       "ctrl-[": "editor::Cancel",
     },
   },

crates/vim/src/helix.rs 🔗

@@ -36,6 +36,8 @@ actions!(
         HelixInsert,
         /// Appends at the end of the selection.
         HelixAppend,
+        /// Inserts at the end of the current Helix cursor line.
+        HelixInsertEndOfLine,
         /// Goes to the location of the last modification.
         HelixGotoLastModification,
         /// Select entire line or multiple lines, extending downwards.
@@ -64,6 +66,7 @@ pub fn register(editor: &mut Editor, cx: &mut Context<Vim>) {
     Vim::action(editor, cx, Vim::helix_select_lines);
     Vim::action(editor, cx, Vim::helix_insert);
     Vim::action(editor, cx, Vim::helix_append);
+    Vim::action(editor, cx, Vim::helix_insert_end_of_line);
     Vim::action(editor, cx, Vim::helix_yank);
     Vim::action(editor, cx, Vim::helix_goto_last_modification);
     Vim::action(editor, cx, Vim::helix_paste);
@@ -600,6 +603,34 @@ impl Vim {
         });
     }
 
+    /// Helix-specific implementation of `shift-a` that accounts for Helix's
+    /// selection model, where selecting a line with `x` creates a selection
+    /// from column 0 of the current row to column 0 of the next row, so the
+    /// default [`vim::normal::InsertEndOfLine`] would move the cursor to the
+    /// end of the wrong line.
+    fn helix_insert_end_of_line(
+        &mut self,
+        _: &HelixInsertEndOfLine,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        self.start_recording(cx);
+        self.switch_mode(Mode::Insert, false, window, cx);
+        self.update_editor(cx, |_, editor, cx| {
+            editor.change_selections(Default::default(), window, cx, |s| {
+                s.move_with(&mut |map, selection| {
+                    let cursor = if !selection.is_empty() && !selection.reversed {
+                        movement::left(map, selection.head())
+                    } else {
+                        selection.head()
+                    };
+                    selection
+                        .collapse_to(motion::next_line_end(map, cursor, 1), SelectionGoal::None);
+                });
+            });
+        });
+    }
+
     pub fn helix_replace(&mut self, text: &str, window: &mut Window, cx: &mut Context<Self>) {
         self.update_editor(cx, |_, editor, cx| {
             editor.transact(window, cx, |editor, window, cx| {
@@ -1447,6 +1478,47 @@ mod test {
             ˇ»line five"},
             Mode::HelixNormal,
         );
+
+        // Test selecting with an empty line below the current line
+        cx.set_state(
+            indoc! {"
+            line one
+            line twoˇ
+
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+            ˇ»
+            line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+
+            ˇ»line four
+            line five"},
+            Mode::HelixNormal,
+        );
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            line one
+            «line two
+
+            line four
+            ˇ»line five"},
+            Mode::HelixNormal,
+        );
     }
 
     #[gpui::test]
@@ -1848,4 +1920,51 @@ mod test {
             Mode::HelixSelect,
         );
     }
+
+    #[gpui::test]
+    async fn test_helix_insert_end_of_line(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.enable_helix();
+
+        // Ensure that, when lines are selected using `x`, pressing `shift-a`
+        // actually puts the cursor at the end of the selected lines and not at
+        // the end of the line below.
+        cx.set_state(
+            indoc! {"
+            line oˇne
+            line two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("x");
+        cx.assert_state(
+            indoc! {"
+            «line one
+            ˇ»line two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("shift-a");
+        cx.assert_state(
+            indoc! {"
+            line oneˇ
+            line two"},
+            Mode::Insert,
+        );
+
+        cx.set_state(
+            indoc! {"
+            line «one
+            lineˇ» two"},
+            Mode::HelixNormal,
+        );
+
+        cx.simulate_keystrokes("shift-a");
+        cx.assert_state(
+            indoc! {"
+            line one
+            line twoˇ"},
+            Mode::Insert,
+        );
+    }
 }