Add visual area repeating

Conrad Irwin created

Change summary

assets/keymaps/vim.json                      |   6 
crates/editor/src/editor.rs                  |   2 
crates/vim/src/motion.rs                     |   8 
crates/vim/src/normal.rs                     |  20 
crates/vim/src/normal/case.rs                |  15 +
crates/vim/src/normal/paste.rs               |   2 
crates/vim/src/normal/repeat.rs              | 268 +++++++++++++++++++--
crates/vim/src/normal/substitute.rs          |   2 
crates/vim/src/state.rs                      |  23 +
crates/vim/src/vim.rs                        |  53 +++
crates/vim/src/visual.rs                     |   2 
crates/vim/test_data/test_change_case.json   |   5 
crates/vim/test_data/test_repeat_visual.json |  51 ++++
13 files changed, 393 insertions(+), 64 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -446,12 +446,10 @@
       ],
       "s": "vim::Substitute",
       "shift-s": "vim::SubstituteLine",
+      "shift-r": "vim::SubstituteLine",
       "c": "vim::Substitute",
       "~": "vim::ChangeCase",
-      "shift-i": [
-        "vim::SwitchMode",
-        "Insert"
-      ],
+      "shift-i": "vim::InsertBefore",
       "shift-a": "vim::InsertAfter",
       "r": [
         "vim::PushOperator",

crates/editor/src/editor.rs 🔗

@@ -572,7 +572,7 @@ pub struct Editor {
     project: Option<ModelHandle<Project>>,
     focused: bool,
     blink_manager: ModelHandle<BlinkManager>,
-    show_local_selections: bool,
+    pub show_local_selections: bool,
     mode: EditorMode,
     replica_id_mapping: Option<HashMap<ReplicaId, ReplicaId>>,
     show_gutter: bool,

crates/vim/src/motion.rs 🔗

@@ -65,9 +65,9 @@ struct PreviousWordStart {
 
 #[derive(Clone, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
-struct Up {
+pub(crate) struct Up {
     #[serde(default)]
-    display_lines: bool,
+    pub(crate) display_lines: bool,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -93,9 +93,9 @@ struct EndOfLine {
 
 #[derive(Clone, Deserialize, PartialEq)]
 #[serde(rename_all = "camelCase")]
-struct StartOfLine {
+pub struct StartOfLine {
     #[serde(default)]
-    display_lines: bool,
+    pub(crate) display_lines: bool,
 }
 
 #[derive(Clone, Deserialize, PartialEq)]

crates/vim/src/normal.rs 🔗

@@ -66,21 +66,21 @@ pub fn init(cx: &mut AppContext) {
 
     cx.add_action(|_: &mut Workspace, _: &DeleteLeft, cx| {
         Vim::update(cx, |vim, cx| {
-            vim.record_current_action();
+            vim.record_current_action(cx);
             let times = vim.pop_number_operator(cx);
             delete_motion(vim, Motion::Left, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteRight, cx| {
         Vim::update(cx, |vim, cx| {
-            vim.record_current_action();
+            vim.record_current_action(cx);
             let times = vim.pop_number_operator(cx);
             delete_motion(vim, Motion::Right, times, cx);
         })
     });
     cx.add_action(|_: &mut Workspace, _: &ChangeToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            vim.start_recording();
+            vim.start_recording(cx);
             let times = vim.pop_number_operator(cx);
             change_motion(
                 vim,
@@ -94,7 +94,7 @@ pub fn init(cx: &mut AppContext) {
     });
     cx.add_action(|_: &mut Workspace, _: &DeleteToEndOfLine, cx| {
         Vim::update(cx, |vim, cx| {
-            vim.record_current_action();
+            vim.record_current_action(cx);
             let times = vim.pop_number_operator(cx);
             delete_motion(
                 vim,
@@ -161,7 +161,7 @@ fn move_cursor(vim: &mut Vim, motion: Motion, times: Option<usize>, cx: &mut Win
 
 fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@@ -175,7 +175,7 @@ fn insert_after(_: &mut Workspace, _: &InsertAfter, cx: &mut ViewContext<Workspa
 
 fn insert_before(_: &mut Workspace, _: &InsertBefore, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
     });
 }
@@ -186,7 +186,7 @@ fn insert_first_non_whitespace(
     cx: &mut ViewContext<Workspace>,
 ) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@@ -203,7 +203,7 @@ fn insert_first_non_whitespace(
 
 fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
@@ -217,7 +217,7 @@ fn insert_end_of_line(_: &mut Workspace, _: &InsertEndOfLine, cx: &mut ViewConte
 
 fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.transact(cx, |editor, cx| {
@@ -250,7 +250,7 @@ fn insert_line_above(_: &mut Workspace, _: &InsertLineAbove, cx: &mut ViewContex
 
 fn insert_line_below(_: &mut Workspace, _: &InsertLineBelow, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.start_recording();
+        vim.start_recording(cx);
         vim.switch_mode(Mode::Insert, false, cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.transact(cx, |editor, cx| {

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

@@ -7,7 +7,7 @@ use crate::{normal::ChangeCase, state::Mode, Vim};
 
 pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.record_current_action();
+        vim.record_current_action(cx);
         let count = vim.pop_number_operator(cx).unwrap_or(1) as u32;
         vim.update_active_editor(cx, |editor, cx| {
             let mut ranges = Vec::new();
@@ -22,10 +22,16 @@ pub fn change_case(_: &mut Workspace, _: &ChangeCase, cx: &mut ViewContext<Works
                         ranges.push(start..end);
                         cursor_positions.push(start..start);
                     }
-                    Mode::Visual | Mode::VisualBlock => {
+                    Mode::Visual => {
                         ranges.push(selection.start..selection.end);
                         cursor_positions.push(selection.start..selection.start);
                     }
+                    Mode::VisualBlock => {
+                        ranges.push(selection.start..selection.end);
+                        if cursor_positions.len() == 0 {
+                            cursor_positions.push(selection.start..selection.start);
+                        }
+                    }
                     Mode::Insert | Mode::Normal => {
                         let start = selection.start;
                         let mut end = start;
@@ -97,6 +103,11 @@ mod test {
         cx.simulate_shared_keystrokes(["shift-v", "~"]).await;
         cx.assert_shared_state("ˇABc\n").await;
 
+        // works in visual block mode
+        cx.set_shared_state("ˇaa\nbb\ncc").await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "j", "~"]).await;
+        cx.assert_shared_state("ˇAa\nBb\ncc").await;
+
         // works with multiple cursors (zed only)
         cx.set_state("aˇßcdˇe\n", Mode::Normal);
         cx.simulate_keystroke("~");

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

@@ -28,7 +28,7 @@ pub(crate) fn init(cx: &mut AppContext) {
 
 fn paste(_: &mut Workspace, action: &Paste, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.record_current_action();
+        vim.record_current_action(cx);
         vim.update_active_editor(cx, |editor, cx| {
             editor.transact(cx, |editor, cx| {
                 editor.set_clip_at_line_ends(false, cx);

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

@@ -1,5 +1,7 @@
 use crate::{
-    state::{Mode, ReplayableAction},
+    motion::Motion,
+    state::{Mode, RecordedSelection, ReplayableAction},
+    visual::visual_motion,
     Vim,
 };
 use gpui::{actions, AppContext};
@@ -11,47 +13,127 @@ pub(crate) fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &EndRepeat, cx| {
         Vim::update(cx, |vim, cx| {
             vim.workspace_state.replaying = false;
+            vim.update_active_editor(cx, |editor, _| {
+                editor.show_local_selections = true;
+            });
             vim.switch_mode(Mode::Normal, false, cx)
         });
     });
 
     cx.add_action(|_: &mut Workspace, _: &Repeat, cx| {
-        Vim::update(cx, |vim, cx| {
-            let actions = vim.workspace_state.repeat_actions.clone();
+        let Some((actions, editor, selection)) = Vim::update(cx, |vim, cx| {
+            let actions = vim.workspace_state.recorded_actions.clone();
             let Some(editor) = vim.active_editor.clone() else {
-                return;
+                return None;
             };
-            if let Some(new_count) = vim.pop_number_operator(cx) {
-                vim.workspace_state.recorded_count = Some(new_count);
-            }
+            let count = vim.pop_number_operator(cx);
+
             vim.workspace_state.replaying = true;
 
-            let window = cx.window();
-            cx.app_context()
-                .spawn(move |mut cx| async move {
-                    for action in actions {
-                        match action {
-                            ReplayableAction::Action(action) => window
-                                .dispatch_action(editor.id(), action.as_ref(), &mut cx)
-                                .ok_or_else(|| anyhow::anyhow!("window was closed")),
-                            ReplayableAction::Insertion {
-                                text,
-                                utf16_range_to_replace,
-                            } => editor.update(&mut cx, |editor, cx| {
-                                editor.replay_insert_event(
-                                    &text,
-                                    utf16_range_to_replace.clone(),
-                                    cx,
-                                )
-                            }),
-                        }?
+            let selection = vim.workspace_state.recorded_selection.clone();
+            match selection {
+                RecordedSelection::SingleLine { .. } | RecordedSelection::Visual { .. } => {
+                    vim.workspace_state.recorded_count = None;
+                    vim.switch_mode(Mode::Visual, false, cx)
+                }
+                RecordedSelection::VisualLine { .. } => {
+                    vim.workspace_state.recorded_count = None;
+                    vim.switch_mode(Mode::VisualLine, false, cx)
+                }
+                RecordedSelection::VisualBlock { .. } => {
+                    vim.workspace_state.recorded_count = None;
+                    vim.switch_mode(Mode::VisualBlock, false, cx)
+                }
+                RecordedSelection::None => {
+                    if let Some(count) = count {
+                        vim.workspace_state.recorded_count = Some(count);
                     }
-                    window
-                        .dispatch_action(editor.id(), &EndRepeat, &mut cx)
-                        .ok_or_else(|| anyhow::anyhow!("window was closed"))
+                }
+            }
+
+            if let Some(editor) = editor.upgrade(cx) {
+                editor.update(cx, |editor, _| {
+                    editor.show_local_selections = false;
                 })
-                .detach_and_log_err(cx);
-        });
+            } else {
+                return None;
+            }
+
+            Some((actions, editor, selection))
+        }) else {
+            return;
+        };
+
+        match selection {
+            RecordedSelection::SingleLine { cols } => {
+                if cols > 1 {
+                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+                }
+            }
+            RecordedSelection::Visual { rows, cols } => {
+                visual_motion(
+                    Motion::Down {
+                        display_lines: false,
+                    },
+                    Some(rows as usize),
+                    cx,
+                );
+                visual_motion(
+                    Motion::StartOfLine {
+                        display_lines: false,
+                    },
+                    None,
+                    cx,
+                );
+                if cols > 1 {
+                    visual_motion(Motion::Right, Some(cols as usize - 1), cx)
+                }
+            }
+            RecordedSelection::VisualBlock { rows, cols } => {
+                visual_motion(
+                    Motion::Down {
+                        display_lines: false,
+                    },
+                    Some(rows as usize),
+                    cx,
+                );
+                if cols > 1 {
+                    visual_motion(Motion::Right, Some(cols as usize - 1), cx);
+                }
+            }
+            RecordedSelection::VisualLine { rows } => {
+                visual_motion(
+                    Motion::Down {
+                        display_lines: false,
+                    },
+                    Some(rows as usize),
+                    cx,
+                );
+            }
+            RecordedSelection::None => {}
+        }
+
+        let window = cx.window();
+        cx.app_context()
+            .spawn(move |mut cx| async move {
+                for action in actions {
+                    match action {
+                        ReplayableAction::Action(action) => window
+                            .dispatch_action(editor.id(), action.as_ref(), &mut cx)
+                            .ok_or_else(|| anyhow::anyhow!("window was closed")),
+                        ReplayableAction::Insertion {
+                            text,
+                            utf16_range_to_replace,
+                        } => editor.update(&mut cx, |editor, cx| {
+                            editor.replay_insert_event(&text, utf16_range_to_replace.clone(), cx)
+                        }),
+                    }?
+                }
+                window
+                    .dispatch_action(editor.id(), &EndRepeat, &mut cx)
+                    .ok_or_else(|| anyhow::anyhow!("window was closed"))
+            })
+            .detach_and_log_err(cx);
     });
 }
 
@@ -204,4 +286,128 @@ mod test {
             Mode::Normal,
         );
     }
+
+    #[gpui::test]
+    async fn test_repeat_visual(deterministic: Arc<Deterministic>, cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        // single-line (3 columns)
+        cx.set_shared_state(indoc! {
+            "ˇthe quick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["v", "i", "w", "s", "o", "escape"])
+            .await;
+        cx.assert_shared_state(indoc! {
+            "ˇo quick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "w", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "o quick brown
+            fox ˇops over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["f", "r", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "o quick brown
+            fox ops oveˇothe lazy dog"
+        })
+        .await;
+
+        // visual
+        cx.set_shared_state(indoc! {
+            "the ˇquick brown
+            fox jumps over
+            fox jumps over
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["v", "j", "x"]).await;
+        cx.assert_shared_state(indoc! {
+            "the ˇumps over
+            fox jumps over
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "the ˇumps over
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["w", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "the umps ˇumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "the umps umps over
+            the ˇog"
+        })
+        .await;
+
+        // block mode (3 rows)
+        cx.set_shared_state(indoc! {
+            "ˇthe quick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "j", "j", "shift-i", "o", "escape"])
+            .await;
+        cx.assert_shared_state(indoc! {
+            "ˇothe quick brown
+            ofox jumps over
+            othe lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "4", "l", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "othe quick brown
+            ofoxˇo jumps over
+            otheo lazy dog"
+        })
+        .await;
+
+        // line mode
+        cx.set_shared_state(indoc! {
+            "ˇthe quick brown
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["shift-v", "shift-r", "o", "escape"])
+            .await;
+        cx.assert_shared_state(indoc! {
+            "ˇo
+            fox jumps over
+            the lazy dog"
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["j", "."]).await;
+        deterministic.run_until_parked();
+        cx.assert_shared_state(indoc! {
+            "o
+            ˇo
+            the lazy dog"
+        })
+        .await;
+    }
 }

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

@@ -10,6 +10,7 @@ actions!(vim, [Substitute, SubstituteLine]);
 pub(crate) fn init(cx: &mut AppContext) {
     cx.add_action(|_: &mut Workspace, _: &Substitute, cx| {
         Vim::update(cx, |vim, cx| {
+            vim.start_recording(cx);
             let count = vim.pop_number_operator(cx);
             substitute(vim, count, vim.state().mode == Mode::VisualLine, cx);
         })
@@ -17,6 +18,7 @@ pub(crate) fn init(cx: &mut AppContext) {
 
     cx.add_action(|_: &mut Workspace, _: &SubstituteLine, cx| {
         Vim::update(cx, |vim, cx| {
+            vim.start_recording(cx);
             if matches!(vim.state().mode, Mode::VisualBlock | Mode::Visual) {
                 vim.switch_mode(Mode::VisualLine, false, cx)
             }

crates/vim/src/state.rs 🔗

@@ -50,6 +50,26 @@ pub struct EditorState {
     pub operator_stack: Vec<Operator>,
 }
 
+#[derive(Default, Clone, Debug)]
+pub enum RecordedSelection {
+    #[default]
+    None,
+    Visual {
+        rows: u32,
+        cols: u32,
+    },
+    SingleLine {
+        cols: u32,
+    },
+    VisualBlock {
+        rows: u32,
+        cols: u32,
+    },
+    VisualLine {
+        rows: u32,
+    },
+}
+
 #[derive(Default, Clone)]
 pub struct WorkspaceState {
     pub search: SearchState,
@@ -59,7 +79,8 @@ pub struct WorkspaceState {
     pub stop_recording_after_next_action: bool,
     pub replaying: bool,
     pub recorded_count: Option<usize>,
-    pub repeat_actions: Vec<ReplayableAction>,
+    pub recorded_actions: Vec<ReplayableAction>,
+    pub recorded_selection: RecordedSelection,
 }
 
 #[derive(Debug)]

crates/vim/src/vim.rs 🔗

@@ -18,13 +18,13 @@ use gpui::{
     actions, impl_actions, keymap_matcher::KeymapContext, keymap_matcher::MatchResult, AppContext,
     Subscription, ViewContext, ViewHandle, WeakViewHandle, WindowContext,
 };
-use language::{CursorShape, Selection, SelectionGoal};
+use language::{CursorShape, Point, Selection, SelectionGoal};
 pub use mode_indicator::ModeIndicator;
 use motion::Motion;
 use normal::normal_replace;
 use serde::Deserialize;
 use settings::{Setting, SettingsStore};
-use state::{EditorState, Mode, Operator, WorkspaceState};
+use state::{EditorState, Mode, Operator, RecordedSelection, WorkspaceState};
 use std::{ops::Range, sync::Arc};
 use visual::{visual_block_motion, visual_replace};
 use workspace::{self, Workspace};
@@ -107,7 +107,7 @@ pub fn observe_keystrokes(cx: &mut WindowContext) {
             Vim::update(cx, |vim, _| {
                 if vim.workspace_state.recording {
                     vim.workspace_state
-                        .repeat_actions
+                        .recorded_actions
                         .push(ReplayableAction::Action(handled_by.boxed_clone()));
 
                     if vim.workspace_state.stop_recording_after_next_action {
@@ -204,7 +204,7 @@ impl Vim {
         Vim::update(cx, |vim, _| {
             if vim.workspace_state.recording {
                 vim.workspace_state
-                    .repeat_actions
+                    .recorded_actions
                     .push(ReplayableAction::Insertion {
                         text: text.clone(),
                         utf16_range_to_replace: range_to_replace,
@@ -232,16 +232,51 @@ impl Vim {
 
     // TODO: shift-j?
     //
-    pub fn start_recording(&mut self) {
+    pub fn start_recording(&mut self, cx: &mut WindowContext) {
         if !self.workspace_state.replaying {
             self.workspace_state.recording = true;
-            self.workspace_state.repeat_actions = Default::default();
+            self.workspace_state.recorded_actions = Default::default();
             self.workspace_state.recorded_count =
                 if let Some(Operator::Number(number)) = self.active_operator() {
                     Some(number)
                 } else {
                     None
+                };
+
+            let selections = self
+                .active_editor
+                .and_then(|editor| editor.upgrade(cx))
+                .map(|editor| {
+                    let editor = editor.read(cx);
+                    (
+                        editor.selections.oldest::<Point>(cx),
+                        editor.selections.newest::<Point>(cx),
+                    )
+                });
+
+            if let Some((oldest, newest)) = selections {
+                self.workspace_state.recorded_selection = match self.state().mode {
+                    Mode::Visual if newest.end.row == newest.start.row => {
+                        RecordedSelection::SingleLine {
+                            cols: newest.end.column - newest.start.column,
+                        }
+                    }
+                    Mode::Visual => RecordedSelection::Visual {
+                        rows: newest.end.row - newest.start.row,
+                        cols: newest.end.column,
+                    },
+                    Mode::VisualLine => RecordedSelection::VisualLine {
+                        rows: newest.end.row - newest.start.row,
+                    },
+                    Mode::VisualBlock => RecordedSelection::VisualBlock {
+                        rows: newest.end.row.abs_diff(oldest.start.row),
+                        cols: newest.end.column.abs_diff(oldest.start.column),
+                    },
+                    _ => RecordedSelection::None,
                 }
+            } else {
+                self.workspace_state.recorded_selection = RecordedSelection::None;
+            }
         }
     }
 
@@ -251,8 +286,8 @@ impl Vim {
         }
     }
 
-    pub fn record_current_action(&mut self) {
-        self.start_recording();
+    pub fn record_current_action(&mut self, cx: &mut WindowContext) {
+        self.start_recording(cx);
         self.stop_recording();
     }
 
@@ -322,7 +357,7 @@ impl Vim {
             operator,
             Operator::Change | Operator::Delete | Operator::Replace
         ) {
-            self.start_recording()
+            self.start_recording(cx)
         };
         self.update_state(|state| state.operator_stack.push(operator));
         self.sync_vim_settings(cx);

crates/vim/src/visual.rs 🔗

@@ -277,7 +277,7 @@ pub fn other_end(_: &mut Workspace, _: &OtherEnd, cx: &mut ViewContext<Workspace
 
 pub fn delete(_: &mut Workspace, _: &VisualDelete, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
-        vim.record_current_action();
+        vim.record_current_action(cx);
         vim.update_active_editor(cx, |editor, cx| {
             let mut original_columns: HashMap<_, _> = Default::default();
             let line_mode = editor.selections.line_mode;

crates/vim/test_data/test_change_case.json 🔗

@@ -16,3 +16,8 @@
 {"Key":"shift-v"}
 {"Key":"~"}
 {"Get":{"state":"ˇABc\n","mode":"Normal"}}
+{"Put":{"state":"ˇaa\nbb\ncc"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"~"}
+{"Get":{"state":"ˇAa\nBb\ncc","mode":"Normal"}}

crates/vim/test_data/test_repeat_visual.json 🔗

@@ -0,0 +1,51 @@
+{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"s"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Get":{"state":"ˇo quick brown\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"w"}
+{"Key":"."}
+{"Get":{"state":"o quick brown\nfox ˇops over\nthe lazy dog","mode":"Normal"}}
+{"Key":"f"}
+{"Key":"r"}
+{"Key":"."}
+{"Get":{"state":"o quick brown\nfox ops oveˇothe lazy dog","mode":"Normal"}}
+{"Put":{"state":"the ˇquick brown\nfox jumps over\nfox jumps over\nfox jumps over\nthe lazy dog"}}
+{"Key":"v"}
+{"Key":"j"}
+{"Key":"x"}
+{"Get":{"state":"the ˇumps over\nfox jumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"the ˇumps over\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"w"}
+{"Key":"."}
+{"Get":{"state":"the umps ˇumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"."}
+{"Get":{"state":"the umps umps over\nthe ˇog","mode":"Normal"}}
+{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"j"}
+{"Key":"shift-i"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Get":{"state":"ˇothe quick brown\nofox jumps over\nothe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"4"}
+{"Key":"l"}
+{"Key":"."}
+{"Get":{"state":"othe quick brown\nofoxˇo jumps over\notheo lazy dog","mode":"Normal"}}
+{"Put":{"state":"ˇthe quick brown\nfox jumps over\nthe lazy dog"}}
+{"Key":"shift-v"}
+{"Key":"shift-r"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Get":{"state":"ˇo\nfox jumps over\nthe lazy dog","mode":"Normal"}}
+{"Key":"j"}
+{"Key":"."}
+{"Get":{"state":"o\nˇo\nthe lazy dog","mode":"Normal"}}