vim cl (#11641)

Conrad Irwin created

Release Notes:

- vim: Added support for the changelist. `g;` and `g,` to the
previous/next change
- vim: Added support for the `'.` mark
- vim: Added support for `gi` to resume the previous insert

Change summary

assets/keymaps/vim.json                           |   3 
crates/editor/src/display_map.rs                  |   5 
crates/vim/src/change_list.rs                     | 233 +++++++++++++++++
crates/vim/src/normal/mark.rs                     |  19 
crates/vim/src/state.rs                           |   2 
crates/vim/src/vim.rs                             |   8 
crates/vim/test_data/test_change_list_delete.json |  16 +
crates/vim/test_data/test_change_list_insert.json |  32 ++
crates/vim/test_data/test_dot_mark.json           |   8 
crates/vim/test_data/test_gi.json                 |  10 
10 files changed, 326 insertions(+), 10 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -240,6 +240,9 @@
       ],
       "g ]": "editor::GoToDiagnostic",
       "g [": "editor::GoToPrevDiagnostic",
+      "g i": ["workspace::SendKeystrokes", "` ^ i"],
+      "g ,": "vim::ChangeListNewer",
+      "g ;": "vim::ChangeListOlder",
       "shift-h": "vim::WindowTop",
       "shift-m": "vim::WindowMiddle",
       "shift-l": "vim::WindowBottom",

crates/editor/src/display_map.rs 🔗

@@ -477,6 +477,11 @@ impl DisplaySnapshot {
             .to_inlay_offset(anchor.to_offset(&self.buffer_snapshot))
     }
 
+    pub fn display_point_to_anchor(&self, point: DisplayPoint, bias: Bias) -> Anchor {
+        self.buffer_snapshot
+            .anchor_at(point.to_offset(&self, bias), bias)
+    }
+
     fn display_point_to_inlay_point(&self, point: DisplayPoint, bias: Bias) -> InlayPoint {
         let block_point = point.0;
         let wrap_point = self.block_snapshot.to_wrap_point(block_point);

crates/vim/src/change_list.rs 🔗

@@ -0,0 +1,233 @@
+use editor::{display_map::ToDisplayPoint, movement, scroll::Autoscroll, Bias, Direction, Editor};
+use gpui::{actions, View};
+use ui::{ViewContext, WindowContext};
+use workspace::Workspace;
+
+use crate::{state::Mode, Vim};
+
+actions!(vim, [ChangeListOlder, ChangeListNewer]);
+
+pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
+    workspace.register_action(|_, _: &ChangeListOlder, cx| {
+        Vim::update(cx, |vim, cx| {
+            move_to_change(vim, Direction::Prev, cx);
+        })
+    });
+    workspace.register_action(|_, _: &ChangeListNewer, cx| {
+        Vim::update(cx, |vim, cx| {
+            move_to_change(vim, Direction::Next, cx);
+        })
+    });
+}
+
+fn move_to_change(vim: &mut Vim, direction: Direction, cx: &mut WindowContext) {
+    let count = vim.take_count(cx).unwrap_or(1);
+    let selections = vim.update_state(|state| {
+        if state.change_list.is_empty() {
+            return None;
+        }
+
+        let prev = state
+            .change_list_position
+            .unwrap_or(state.change_list.len());
+        let next = if direction == Direction::Prev {
+            prev.saturating_sub(count)
+        } else {
+            (prev + count).min(state.change_list.len() - 1)
+        };
+        state.change_list_position = Some(next);
+        state.change_list.get(next).cloned()
+    });
+
+    let Some(selections) = selections else {
+        return;
+    };
+    vim.update_active_editor(cx, |_, editor, cx| {
+        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            let map = s.display_map();
+            s.select_display_ranges(selections.into_iter().map(|a| {
+                let point = a.to_display_point(&map);
+                point..point
+            }))
+        })
+    });
+}
+
+pub(crate) fn push_to_change_list(vim: &mut Vim, editor: View<Editor>, cx: &mut WindowContext) {
+    let (map, selections) =
+        editor.update(cx, |editor, cx| editor.selections.all_adjusted_display(cx));
+
+    let pop_state =
+        vim.state()
+            .change_list
+            .last()
+            .map(|previous| {
+                previous.len() == selections.len()
+                    && previous.iter().enumerate().all(|(ix, p)| {
+                        p.to_display_point(&map).row() == selections[ix].head().row()
+                    })
+            })
+            .unwrap_or(false);
+
+    let new_positions = selections
+        .into_iter()
+        .map(|s| {
+            let point = if vim.state().mode == Mode::Insert {
+                movement::saturating_left(&map, s.head())
+            } else {
+                s.head()
+            };
+            map.display_point_to_anchor(point, Bias::Left)
+        })
+        .collect();
+
+    vim.update_state(|state| {
+        state.change_list_position.take();
+        if pop_state {
+            state.change_list.pop();
+        }
+        state.change_list.push(new_positions);
+    })
+}
+
+#[cfg(test)]
+mod test {
+    use indoc::indoc;
+
+    use crate::{state::Mode, test::NeovimBackedTestContext};
+
+    #[gpui::test]
+    async fn test_change_list_insert(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇ").await;
+
+        cx.simulate_shared_keystrokes([
+            "i", "1", "1", "escape", "shift-o", "2", "2", "escape", "shift-g", "o", "3", "3",
+            "escape",
+        ])
+        .await;
+
+        cx.assert_shared_state(indoc! {
+            "22
+             11
+             3ˇ3"
+        })
+        .await;
+
+        cx.simulate_shared_keystrokes(["g", ";"]).await;
+        // NOTE: this matches nvim when I type it into it
+        // but in tests, nvim always reports the column as 0...
+        cx.assert_state(
+            indoc! {
+            "22
+             11
+             3ˇ3"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_shared_keystrokes(["g", ";"]).await;
+        cx.assert_state(
+            indoc! {
+            "2ˇ2
+             11
+             33"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_shared_keystrokes(["g", ";"]).await;
+        cx.assert_state(
+            indoc! {
+            "22
+             1ˇ1
+             33"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_shared_keystrokes(["g", ","]).await;
+        cx.assert_state(
+            indoc! {
+            "2ˇ2
+             11
+             33"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_shared_keystrokes(["shift-g", "i", "4", "4", "escape"])
+            .await;
+        cx.simulate_shared_keystrokes(["g", ";"]).await;
+        cx.assert_state(
+            indoc! {
+            "22
+             11
+             34ˇ43"
+            },
+            Mode::Normal,
+        );
+        cx.simulate_shared_keystrokes(["g", ";"]).await;
+        cx.assert_state(
+            indoc! {
+            "2ˇ2
+             11
+             3443"
+            },
+            Mode::Normal,
+        );
+    }
+
+    #[gpui::test]
+    async fn test_change_list_delete(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {
+        "one two
+        three fˇour"})
+            .await;
+        cx.simulate_shared_keystrokes(["x", "k", "d", "i", "w", "^", "x"])
+            .await;
+        cx.assert_shared_state(indoc! {
+        "ˇne•
+        three fur"})
+            .await;
+        cx.simulate_shared_keystrokes(["2", "g", ";"]).await;
+        cx.assert_shared_state(indoc! {
+        "ne•
+        three fˇur"})
+            .await;
+        cx.simulate_shared_keystrokes(["g", ","]).await;
+        cx.assert_shared_state(indoc! {
+        "ˇne•
+        three fur"})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_gi(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {
+        "one two
+        three fˇr"})
+            .await;
+        cx.simulate_shared_keystrokes(["i", "o", "escape", "k", "g", "i"])
+            .await;
+        cx.simulate_shared_keystrokes(["u", "escape"]).await;
+        cx.assert_shared_state(indoc! {
+        "one two
+        three foˇur"})
+            .await;
+    }
+
+    #[gpui::test]
+    async fn test_dot_mark(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+        cx.set_shared_state(indoc! {
+        "one two
+        three fˇr"})
+            .await;
+        cx.simulate_shared_keystrokes(["i", "o", "escape", "k", "`", "."])
+            .await;
+        cx.assert_shared_state(indoc! {
+        "one two
+        three fˇor"})
+            .await;
+    }
+}

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

@@ -68,9 +68,11 @@ pub fn create_mark_before(vim: &mut Vim, text: Arc<str>, cx: &mut WindowContext)
 }
 
 pub fn jump(text: Arc<str>, line: bool, cx: &mut WindowContext) {
-    let anchors = match &*text {
-        "{" | "}" => Vim::update(cx, |vim, cx| {
-            vim.update_active_editor(cx, |_, editor, cx| {
+    let anchors = Vim::update(cx, |vim, cx| {
+        vim.pop_operator(cx);
+
+        match &*text {
+            "{" | "}" => vim.update_active_editor(cx, |_, editor, cx| {
                 let (map, selections) = editor.selections.all_display(cx);
                 selections
                     .into_iter()
@@ -84,13 +86,10 @@ pub fn jump(text: Arc<str>, line: bool, cx: &mut WindowContext) {
                             .anchor_before(point.to_offset(&map, Bias::Left))
                     })
                     .collect::<Vec<Anchor>>()
-            })
-        }),
-        _ => Vim::read(cx).state().marks.get(&*text).cloned(),
-    };
-
-    Vim::update(cx, |vim, cx| {
-        vim.pop_operator(cx);
+            }),
+            "." => vim.state().change_list.last().cloned(),
+            _ => vim.state().marks.get(&*text).cloned(),
+        }
     });
 
     let Some(anchors) = anchors else { return };

crates/vim/src/state.rs 🔗

@@ -77,6 +77,8 @@ pub struct EditorState {
     pub replacements: Vec<(Range<editor::Anchor>, String)>,
 
     pub marks: HashMap<String, Vec<Anchor>>,
+    pub change_list: Vec<Vec<Anchor>>,
+    pub change_list_position: Option<usize>,
 
     pub current_tx: Option<TransactionId>,
     pub current_anchor: Option<Selection<Anchor>>,

crates/vim/src/vim.rs 🔗

@@ -3,6 +3,7 @@
 #[cfg(test)]
 mod test;
 
+mod change_list;
 mod command;
 mod editor_events;
 mod insert;
@@ -17,6 +18,7 @@ mod utils;
 mod visual;
 
 use anyhow::Result;
+use change_list::push_to_change_list;
 use collections::HashMap;
 use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
 use editor::{
@@ -159,6 +161,7 @@ fn register(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) {
     replace::register(workspace, cx);
     object::register(workspace, cx);
     visual::register(workspace, cx);
+    change_list::register(workspace, cx);
 }
 
 /// Called whenever an keystroke is typed so vim can observe all actions
@@ -264,6 +267,7 @@ impl Vim {
             EditorEvent::TransactionUndone { transaction_id } => Vim::update(cx, |vim, cx| {
                 vim.transaction_undone(transaction_id, cx);
             }),
+            EditorEvent::Edited => Vim::update(cx, |vim, cx| vim.transaction_ended(editor, cx)),
             _ => {}
         }));
 
@@ -618,6 +622,10 @@ impl Vim {
         self.switch_mode(Mode::Normal, true, cx)
     }
 
+    fn transaction_ended(&mut self, editor: View<Editor>, cx: &mut WindowContext) {
+        push_to_change_list(self, editor, cx)
+    }
+
     fn local_selections_changed(&mut self, editor: View<Editor>, cx: &mut WindowContext) {
         let newest = editor.read(cx).selections.newest_anchor().clone();
         let is_multicursor = editor.read(cx).selections.count() > 1;

crates/vim/test_data/test_change_list_delete.json 🔗

@@ -0,0 +1,16 @@
+{"Put":{"state":"one two\nthree fˇour"}}
+{"Key":"x"}
+{"Key":"k"}
+{"Key":"d"}
+{"Key":"i"}
+{"Key":"w"}
+{"Key":"^"}
+{"Key":"x"}
+{"Get":{"state":"ˇne \nthree fur","mode":"Normal"}}
+{"Key":"2"}
+{"Key":"g"}
+{"Key":";"}
+{"Get":{"state":"ne \nthree fˇur","mode":"Normal"}}
+{"Key":"g"}
+{"Key":","}
+{"Get":{"state":"ˇne \nthree fur","mode":"Normal"}}

crates/vim/test_data/test_change_list_insert.json 🔗

@@ -0,0 +1,32 @@
+{"Put":{"state":"ˇ"}}
+{"Key":"i"}
+{"Key":"1"}
+{"Key":"1"}
+{"Key":"escape"}
+{"Key":"shift-o"}
+{"Key":"2"}
+{"Key":"2"}
+{"Key":"escape"}
+{"Key":"shift-g"}
+{"Key":"o"}
+{"Key":"3"}
+{"Key":"3"}
+{"Key":"escape"}
+{"Get":{"state":"22\n11\n3ˇ3","mode":"Normal"}}
+{"Key":"g"}
+{"Key":";"}
+{"Key":"g"}
+{"Key":";"}
+{"Key":"g"}
+{"Key":";"}
+{"Key":"g"}
+{"Key":","}
+{"Key":"shift-g"}
+{"Key":"i"}
+{"Key":"4"}
+{"Key":"4"}
+{"Key":"escape"}
+{"Key":"g"}
+{"Key":";"}
+{"Key":"g"}
+{"Key":";"}

crates/vim/test_data/test_dot_mark.json 🔗

@@ -0,0 +1,8 @@
+{"Put":{"state":"one two\nthree fˇr"}}
+{"Key":"i"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Key":"k"}
+{"Key":"`"}
+{"Key":"."}
+{"Get":{"state":"one two\nthree fˇor","mode":"Normal"}}

crates/vim/test_data/test_gi.json 🔗

@@ -0,0 +1,10 @@
+{"Put":{"state":"one two\nthree fˇr"}}
+{"Key":"i"}
+{"Key":"o"}
+{"Key":"escape"}
+{"Key":"k"}
+{"Key":"g"}
+{"Key":"i"}
+{"Key":"u"}
+{"Key":"escape"}
+{"Get":{"state":"one two\nthree foˇur","mode":"Normal"}}