vim: Add Multi Replace mode in Vim (#8469)

Hans and Conrad Irwin created

For #4440, I've only added support for normal, if it's visual mode,
would we like this to delete the current selection row and enter insert
mode?

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

assets/keymaps/vim.json                          |  10 
crates/editor/src/editor.rs                      |   4 
crates/vim/src/motion.rs                         |   8 
crates/vim/src/normal/case.rs                    |   2 
crates/vim/src/object.rs                         |   2 
crates/vim/src/replace.rs                        | 352 ++++++++++++++++++
crates/vim/src/state.rs                          |  30 +
crates/vim/src/test.rs                           |   5 
crates/vim/src/test/neovim_connection.rs         |   2 
crates/vim/src/vim.rs                            |  13 
crates/vim/test_data/test_replace_mode.json      |  48 ++
crates/vim/test_data/test_replace_mode_undo.json | 124 ++++++
12 files changed, 584 insertions(+), 16 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -119,6 +119,7 @@
       "shift-v": "vim::ToggleVisualLine",
       "ctrl-v": "vim::ToggleVisualBlock",
       "ctrl-q": "vim::ToggleVisualBlock",
+      "shift-r": "vim::ToggleReplace",
       "0": "vim::StartOfLine", // When no number operator present, use start of line motion
       "ctrl-f": "vim::PageDown",
       "pagedown": "vim::PageDown",
@@ -520,6 +521,15 @@
       "ctrl-r +": "editor::Paste"
     }
   },
+  {
+    "context": "Editor && vim_mode == replace",
+    "bindings": {
+      "escape": "vim::NormalBefore",
+      "ctrl-c": "vim::NormalBefore",
+      "ctrl-[": "vim::NormalBefore",
+      "backspace": "vim::UndoReplace"
+    }
+  },
   {
     "context": "Editor && VimWaiting",
     "bindings": {

crates/editor/src/editor.rs 🔗

@@ -363,7 +363,7 @@ pub struct Editor {
     buffer: Model<MultiBuffer>,
     /// Map of how text in the buffer should be displayed.
     /// Handles soft wraps, folds, fake inlay text insertions, etc.
-    display_map: Model<DisplayMap>,
+    pub display_map: Model<DisplayMap>,
     pub selections: SelectionsCollection,
     pub scroll_manager: ScrollManager,
     columnar_selection_tail: Option<Anchor>,
@@ -423,6 +423,7 @@ pub struct Editor {
     _subscriptions: Vec<Subscription>,
     pixel_position_of_newest_cursor: Option<gpui::Point<Pixels>>,
     gutter_width: Pixels,
+    pub vim_replace_map: HashMap<Range<usize>, String>,
     style: Option<EditorStyle>,
     editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
     show_copilot_suggestions: bool,
@@ -1568,6 +1569,7 @@ impl Editor {
             show_cursor_names: false,
             hovered_cursors: Default::default(),
             editor_actions: Default::default(),
+            vim_replace_map: Default::default(),
             show_copilot_suggestions: mode == EditorMode::Full,
             custom_context_menu: None,
             _subscriptions: vec![

crates/vim/src/motion.rs 🔗

@@ -387,7 +387,7 @@ pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
     let count = Vim::update(cx, |vim, cx| vim.take_count(cx));
     let operator = Vim::read(cx).active_operator();
     match Vim::read(cx).state().mode {
-        Mode::Normal => normal_motion(motion, operator, count, cx),
+        Mode::Normal | Mode::Replace => normal_motion(motion, operator, count, cx),
         Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_motion(motion, count, cx),
         Mode::Insert => {
             // Shouldn't execute a motion in insert mode. Ignoring
@@ -800,7 +800,11 @@ fn left(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> Display
     point
 }
 
-fn backspace(map: &DisplaySnapshot, mut point: DisplayPoint, times: usize) -> DisplayPoint {
+pub(crate) fn backspace(
+    map: &DisplaySnapshot,
+    mut point: DisplayPoint,
+    times: usize,
+) -> DisplayPoint {
     for _ in 0..times {
         point = movement::left(map, point);
         if point.is_zero() {

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

@@ -63,7 +63,7 @@ where
                             cursor_positions.push(selection.start..selection.start);
                         }
                     }
-                    Mode::Insert | Mode::Normal => {
+                    Mode::Insert | Mode::Normal | Mode::Replace => {
                         let start = selection.start;
                         let mut end = start;
                         for _ in 0..count {

crates/vim/src/object.rs 🔗

@@ -98,7 +98,7 @@ fn object(object: Object, cx: &mut WindowContext) {
     match Vim::read(cx).state().mode {
         Mode::Normal => normal_object(object, cx),
         Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_object(object, cx),
-        Mode::Insert => {
+        Mode::Insert | Mode::Replace => {
             // Shouldn't execute a text object in insert mode. Ignoring
         }
     }

crates/vim/src/replace.rs 🔗

@@ -0,0 +1,352 @@
+use crate::{
+    motion::{self},
+    state::Mode,
+    Vim,
+};
+use editor::{display_map::ToDisplayPoint, Bias, ToPoint};
+use gpui::{actions, ViewContext, WindowContext};
+use language::{AutoindentMode, Point};
+use std::ops::Range;
+use std::sync::Arc;
+use workspace::Workspace;
+
+actions!(vim, [ToggleReplace, UndoReplace]);
+
+pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+    workspace.register_action(|_, _: &ToggleReplace, cx: &mut ViewContext<Workspace>| {
+        Vim::update(cx, |vim, cx| {
+            vim.update_state(|state| state.replacements = vec![]);
+            vim.switch_mode(Mode::Replace, false, cx);
+        });
+    });
+
+    workspace.register_action(|_, _: &UndoReplace, cx: &mut ViewContext<Workspace>| {
+        Vim::update(cx, |vim, cx| {
+            if vim.state().mode != Mode::Replace {
+                return;
+            }
+            let count = vim.take_count(cx);
+            undo_replace(vim, count, cx)
+        });
+    });
+}
+
+pub(crate) fn multi_replace(text: Arc<str>, cx: &mut WindowContext) {
+    Vim::update(cx, |vim, cx| {
+        vim.update_active_editor(cx, |vim, editor, cx| {
+            editor.transact(cx, |editor, cx| {
+                editor.set_clip_at_line_ends(false, cx);
+                let map = editor.snapshot(cx);
+                let display_selections = editor.selections.all::<Point>(cx);
+
+                // Handles all string that require manipulation, including inserts and replaces
+                let edits = display_selections
+                    .into_iter()
+                    .map(|selection| {
+                        let is_new_line = text.as_ref() == "\n";
+                        let mut range = selection.range();
+                        // "\n" need to be handled separately, because when a "\n" is typing,
+                        // we don't do a replace, we need insert a "\n"
+                        if !is_new_line {
+                            range.end.column += 1;
+                            range.end = map.buffer_snapshot.clip_point(range.end, Bias::Right);
+                        }
+                        let replace_range = map.buffer_snapshot.anchor_before(range.start)
+                            ..map.buffer_snapshot.anchor_after(range.end);
+                        let current_text = map
+                            .buffer_snapshot
+                            .text_for_range(replace_range.clone())
+                            .collect();
+                        vim.update_state(|state| {
+                            state
+                                .replacements
+                                .push((replace_range.clone(), current_text))
+                        });
+                        (replace_range, text.clone())
+                    })
+                    .collect::<Vec<_>>();
+
+                editor.buffer().update(cx, |buffer, cx| {
+                    buffer.edit(
+                        edits.clone(),
+                        Some(AutoindentMode::Block {
+                            original_indent_columns: Vec::new(),
+                        }),
+                        cx,
+                    );
+                });
+
+                editor.change_selections(None, cx, |s| {
+                    s.select_anchor_ranges(edits.iter().map(|(range, _)| range.end..range.end));
+                });
+                editor.set_clip_at_line_ends(true, cx);
+            });
+        });
+    });
+}
+
+fn undo_replace(vim: &mut Vim, maybe_times: Option<usize>, cx: &mut WindowContext) {
+    vim.update_active_editor(cx, |vim, editor, cx| {
+        editor.transact(cx, |editor, cx| {
+            editor.set_clip_at_line_ends(false, cx);
+            let map = editor.snapshot(cx);
+            let selections = editor.selections.all::<Point>(cx);
+            let mut new_selections = vec![];
+            let edits: Vec<(Range<Point>, String)> = selections
+                .into_iter()
+                .filter_map(|selection| {
+                    let end = selection.head();
+                    let start = motion::backspace(
+                        &map,
+                        end.to_display_point(&map),
+                        maybe_times.unwrap_or(1),
+                    )
+                    .to_point(&map);
+                    new_selections.push(
+                        map.buffer_snapshot.anchor_before(start)
+                            ..map.buffer_snapshot.anchor_before(start),
+                    );
+
+                    let mut undo = None;
+                    let edit_range = start..end;
+                    for (i, (range, inverse)) in vim.state().replacements.iter().rev().enumerate() {
+                        if range.start.to_point(&map.buffer_snapshot) <= edit_range.start
+                            && range.end.to_point(&map.buffer_snapshot) >= edit_range.end
+                        {
+                            undo = Some(inverse.clone());
+                            vim.update_state(|state| {
+                                state.replacements.remove(state.replacements.len() - i - 1);
+                            });
+                            break;
+                        }
+                    }
+                    Some((edit_range, undo?))
+                })
+                .collect::<Vec<_>>();
+
+            editor.buffer().update(cx, |buffer, cx| {
+                buffer.edit(edits, None, cx);
+            });
+
+            editor.change_selections(None, cx, |s| {
+                s.select_ranges(new_selections);
+            });
+            editor.set_clip_at_line_ends(true, cx);
+        });
+    });
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{
+        state::Mode,
+        test::{NeovimBackedTestContext, VimTestContext},
+    };
+
+    #[gpui::test]
+    async fn test_enter_and_exit_replace_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.simulate_keystroke("shift-r");
+        assert_eq!(cx.mode(), Mode::Replace);
+        cx.simulate_keystroke("escape");
+        assert_eq!(cx.mode(), Mode::Normal);
+    }
+
+    #[gpui::test]
+    async fn test_replace_mode(cx: &mut gpui::TestAppContext) {
+        let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await;
+
+        // test normal replace
+        cx.set_shared_state(indoc! {"
+            ˇThe quick brown
+            fox jumps over
+            the lazy dog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            Oneˇ quick brown
+            fox jumps over
+            the lazy dog."})
+            .await;
+        assert_eq!(Mode::Replace, cx.neovim_mode().await);
+
+        // test replace with line ending
+        cx.set_shared_state(indoc! {"
+            The quick browˇn
+            fox jumps over
+            the lazy dog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The quick browOneˇ
+            fox jumps over
+            the lazy dog."})
+            .await;
+
+        // test replace with blank line
+        cx.set_shared_state(indoc! {"
+        The quick brown
+        ˇ
+        fox jumps over
+        the lazy dog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The quick brown
+            Oneˇ
+            fox jumps over
+            the lazy dog."})
+            .await;
+
+        // test replace with multi cursor
+        cx.set_shared_state(indoc! {"
+            ˇThe quick brown
+            fox jumps over
+            the lazy ˇdog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            Oneˇ quick brown
+            fox jumps over
+            the lazy Oneˇ."})
+            .await;
+
+        // test replace with newline
+        cx.set_shared_state(indoc! {"
+            The quˇick brown
+            fox jumps over
+            the lazy dog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "enter", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            The qu
+            Oneˇ brown
+            fox jumps over
+            the lazy dog."})
+            .await;
+
+        // test replace with multi cursor and newline
+        cx.set_shared_state(indoc! {"
+            ˇThe quick brown
+            fox jumps over
+            the lazy ˇdog."})
+            .await;
+        cx.simulate_shared_keystrokes(["shift-r", "O", "n", "e"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            Oneˇ quick brown
+            fox jumps over
+            the lazy Oneˇ."})
+            .await;
+        cx.simulate_shared_keystrokes(["enter", "T", "w", "o"])
+            .await;
+        cx.assert_shared_state(indoc! {"
+            One
+            Twoˇck brown
+            fox jumps over
+            the lazy One
+            Twoˇ"})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_replace_mode_undo(cx: &mut gpui::TestAppContext) {
+        let mut cx: NeovimBackedTestContext = NeovimBackedTestContext::new(cx).await;
+
+        const UNDO_REPLACE_EXAMPLES: &[&'static str] = &[
+            // replace undo with single line
+            "ˇThe quick brown fox jumps over the lazy dog.",
+            // replace undo with ending line
+            indoc! {"
+                The quick browˇn
+                fox jumps over
+                the lazy dog."
+            },
+            // replace undo with empty line
+            indoc! {"
+                The quick brown
+                ˇ
+                fox jumps over
+                the lazy dog."
+            },
+            // replace undo with multi cursor
+            indoc! {"
+                The quick browˇn
+                fox jumps over
+                the lazy ˇdog."
+            },
+        ];
+
+        for example in UNDO_REPLACE_EXAMPLES {
+            // normal undo
+            cx.assert_binding_matches(
+                [
+                    "shift-r",
+                    "O",
+                    "n",
+                    "e",
+                    "backspace",
+                    "backspace",
+                    "backspace",
+                ],
+                example,
+            )
+            .await;
+            // undo with new line
+            cx.assert_binding_matches(
+                [
+                    "shift-r",
+                    "O",
+                    "enter",
+                    "e",
+                    "backspace",
+                    "backspace",
+                    "backspace",
+                ],
+                example,
+            )
+            .await;
+            cx.assert_binding_matches(
+                [
+                    "shift-r",
+                    "O",
+                    "enter",
+                    "n",
+                    "enter",
+                    "e",
+                    "backspace",
+                    "backspace",
+                    "backspace",
+                    "backspace",
+                    "backspace",
+                ],
+                example,
+            )
+            .await;
+        }
+    }
+
+    #[gpui::test]
+    async fn test_replace_multicursor(cx: &mut gpui::TestAppContext) {
+        let mut cx = VimTestContext::new(cx, true).await;
+        cx.set_state("ˇabcˇabcabc", Mode::Normal);
+        cx.simulate_keystrokes(["shift-r", "1", "2", "3", "4"]);
+        cx.assert_state("1234ˇ234ˇbc", Mode::Replace);
+        assert_eq!(cx.mode(), Mode::Replace);
+        cx.simulate_keystrokes([
+            "backspace",
+            "backspace",
+            "backspace",
+            "backspace",
+            "backspace",
+        ]);
+        cx.assert_state("ˇabˇcabcabc", Mode::Replace);
+    }
+}

crates/vim/src/state.rs 🔗

@@ -1,5 +1,6 @@
 use std::{fmt::Display, ops::Range, sync::Arc};
 
+use crate::motion::Motion;
 use collections::HashMap;
 use editor::Anchor;
 use gpui::{Action, KeyContext};
@@ -7,12 +8,11 @@ use language::{CursorShape, Selection, TransactionId};
 use serde::{Deserialize, Serialize};
 use workspace::searchable::Direction;
 
-use crate::motion::Motion;
-
 #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)]
 pub enum Mode {
     Normal,
     Insert,
+    Replace,
     Visual,
     VisualLine,
     VisualBlock,
@@ -23,6 +23,7 @@ impl Display for Mode {
         match self {
             Mode::Normal => write!(f, "NORMAL"),
             Mode::Insert => write!(f, "INSERT"),
+            Mode::Replace => write!(f, "REPLACE"),
             Mode::Visual => write!(f, "VISUAL"),
             Mode::VisualLine => write!(f, "VISUAL LINE"),
             Mode::VisualBlock => write!(f, "VISUAL BLOCK"),
@@ -33,7 +34,7 @@ impl Display for Mode {
 impl Mode {
     pub fn is_visual(&self) -> bool {
         match self {
-            Mode::Normal | Mode::Insert => false,
+            Mode::Normal | Mode::Insert | Mode::Replace => false,
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => true,
         }
     }
@@ -67,6 +68,7 @@ pub struct EditorState {
     pub post_count: Option<usize>,
 
     pub operator_stack: Vec<Operator>,
+    pub replacements: Vec<(Range<editor::Anchor>, String)>,
 
     pub current_tx: Option<TransactionId>,
     pub current_anchor: Option<Selection<Anchor>>,
@@ -159,17 +161,21 @@ impl EditorState {
                     CursorShape::Underscore
                 }
             }
+            Mode::Replace => CursorShape::Underscore,
             Mode::Visual | Mode::VisualLine | Mode::VisualBlock => CursorShape::Block,
             Mode::Insert => CursorShape::Bar,
         }
     }
 
     pub fn vim_controlled(&self) -> bool {
-        !matches!(self.mode, Mode::Insert)
-            || matches!(
-                self.operator_stack.last(),
-                Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. })
-            )
+        let is_insert_mode = matches!(self.mode, Mode::Insert);
+        if !is_insert_mode {
+            return true;
+        }
+        matches!(
+            self.operator_stack.last(),
+            Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. })
+        )
     }
 
     pub fn should_autoindent(&self) -> bool {
@@ -178,7 +184,9 @@ impl EditorState {
 
     pub fn clip_at_line_ends(&self) -> bool {
         match self.mode {
-            Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock => false,
+            Mode::Insert | Mode::Visual | Mode::VisualLine | Mode::VisualBlock | Mode::Replace => {
+                false
+            }
             Mode::Normal => true,
         }
     }
@@ -195,6 +203,7 @@ impl EditorState {
                 Mode::Normal => "normal",
                 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => "visual",
                 Mode::Insert => "insert",
+                Mode::Replace => "replace",
             },
         );
 
@@ -221,6 +230,9 @@ impl EditorState {
             active_operator.map(|op| op.id()).unwrap_or_else(|| "none"),
         );
 
+        if self.mode == Mode::Replace {
+            context.add("VimWaiting");
+        }
         context
     }
 }

crates/vim/src/test.rs 🔗

@@ -270,6 +270,11 @@ async fn test_status_indicator(cx: &mut gpui::TestAppContext) {
         cx.workspace(|_, cx| mode_indicator.read(cx).mode),
         Some(Mode::Insert)
     );
+    cx.simulate_keystrokes(["escape", "shift-r"]);
+    assert_eq!(
+        cx.workspace(|_, cx| mode_indicator.read(cx).mode),
+        Some(Mode::Replace)
+    );
 
     // shows even in search
     cx.simulate_keystrokes(["escape", "v", "/"]);

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

@@ -439,7 +439,7 @@ impl NeovimConnection {
                     Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col),
                 )
             }
-            Some(Mode::Insert) | Some(Mode::Normal) | None => selections
+            Some(Mode::Insert) | Some(Mode::Normal) | Some(Mode::Replace) | None => selections
                 .push(Point::new(selection_row, selection_col)..Point::new(cursor_row, cursor_col)),
         }
 

crates/vim/src/vim.rs 🔗

@@ -10,6 +10,7 @@ mod mode_indicator;
 mod motion;
 mod normal;
 mod object;
+mod replace;
 mod state;
 mod utils;
 mod visual;
@@ -29,6 +30,7 @@ use language::{CursorShape, Point, Selection, SelectionGoal, TransactionId};
 pub use mode_indicator::ModeIndicator;
 use motion::Motion;
 use normal::normal_replace;
+use replace::multi_replace;
 use schemars::JsonSchema;
 use serde::Deserialize;
 use serde_derive::Serialize;
@@ -132,6 +134,7 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
     insert::register(workspace, cx);
     motion::register(workspace, cx);
     command::register(workspace, cx);
+    replace::register(workspace, cx);
     object::register(workspace, cx);
     visual::register(workspace, cx);
 }
@@ -418,6 +421,11 @@ impl Vim {
                         if selection.is_empty() {
                             selection.end = movement::right(map, selection.start);
                         }
+                    } else if last_mode == Mode::Replace {
+                        if selection.head().column() != 0 {
+                            let point = movement::left(map, selection.head());
+                            selection.collapse_to(point, selection.goal)
+                        }
                     }
                 });
             })
@@ -608,7 +616,10 @@ impl Vim {
                 Mode::Visual | Mode::VisualLine | Mode::VisualBlock => visual_replace(text, cx),
                 _ => Vim::update(cx, |vim, cx| vim.clear_operator(cx)),
             },
-            _ => {}
+            _ => match Vim::read(cx).state().mode {
+                Mode::Replace => multi_replace(text, cx),
+                _ => {}
+            },
         }
     }
 

crates/vim/test_data/test_replace_mode.json 🔗

@@ -0,0 +1,48 @@
+{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"The quick browOneˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"The quick brown\nOneˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}}
+{"Put":{"state":"The quˇick brown\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"enter"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"The qu\nOneˇ brown\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}}
+{"Key":"enter"}
+{"Key":"T"}
+{"Key":"w"}
+{"Key":"o"}
+{"Get":{"state":"One\nTwoˇck brown\nfox jumps over\nthe lazy One\nTwoˇ","mode":"Replace"}}
+{"Put":{"state":"ˇThe quick brown\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Get":{"state":"Oneˇ quick brown\nfox jumps over\nthe lazy Oneˇ.","mode":"Replace"}}

crates/vim/test_data/test_replace_mode_undo.json 🔗

@@ -0,0 +1,124 @@
+{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}}
+{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}}
+{"Put":{"state":"ˇThe quick brown fox jumps over the lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"ˇThe quick brown fox jumps over the lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick brown\nˇ\nfox jumps over\nthe lazy dog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"n"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}}
+{"Put":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog."}}
+{"Key":"shift-r"}
+{"Key":"O"}
+{"Key":"enter"}
+{"Key":"n"}
+{"Key":"enter"}
+{"Key":"e"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Key":"backspace"}
+{"Get":{"state":"The quick browˇn\nfox jumps over\nthe lazy ˇdog.","mode":"Replace"}}