vim: Support gn command and remap gn to gl (#9982)

joaquin30 and Conrad Irwin created

Release Notes:

- Resolves #4273

@algora-pbc /claim #4273

This is a work-in-progress. The process for `gn` command is:

- maintain updated vim.workspace_state.search.initial_query
- modify editor.select_next_state with
vim.workspace_state.search.initial_query
- use editor.select_next()
- merge selections
- set editor.select_next_state to previous state

To make this possible, several private members and editor structures are
made public. `gN` is not yet implemented and the cursor still does not
jump to the next selection in the first use.

Maybe there is an better way to do this?

---------

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

Change summary

assets/keymaps/vim.json                   |   6 
crates/search/src/buffer_search.rs        |   2 
crates/vim/Cargo.toml                     |   1 
crates/vim/src/visual.rs                  | 305 ++++++++++++++++++------
crates/vim/test_data/test_cgn_repeat.json |  14 +
crates/vim/test_data/test_dgn_repeat.json |  14 +
crates/vim/test_data/test_gn.json         |  39 +++
crates/workspace/src/searchable.rs        |   9 
8 files changed, 306 insertions(+), 84 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -137,8 +137,10 @@
       "g d": "editor::GoToDefinition",
       "g shift-d": "editor::GoToTypeDefinition",
       "g x": "editor::OpenUrl",
-      "g n": "vim::SelectNext",
-      "g shift-n": "vim::SelectPrevious",
+      "g n": "vim::SelectNextMatch",
+      "g shift-n": "vim::SelectPreviousMatch",
+      "g l": "vim::SelectNext",
+      "g shift-l": "vim::SelectPrevious",
       "g >": [
         "editor::SelectNext",
         {

crates/search/src/buffer_search.rs 🔗

@@ -963,7 +963,7 @@ impl BufferSearchBar {
         done_rx
     }
 
-    fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
+    pub fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
         let new_index = self
             .active_searchable_item
             .as_ref()

crates/vim/Cargo.toml 🔗

@@ -39,6 +39,7 @@ ui.workspace = true
 workspace.workspace = true
 zed_actions.workspace = true
 schemars.workspace = true
+util.workspace = true
 
 [dev-dependencies]
 command_palette.workspace = true

crates/vim/src/visual.rs 🔗

@@ -1,4 +1,3 @@
-use anyhow::Result;
 use std::sync::Arc;
 
 use collections::HashMap;
@@ -10,10 +9,13 @@ use editor::{
 };
 use gpui::{actions, ViewContext, WindowContext};
 use language::{Point, Selection, SelectionGoal};
-use workspace::Workspace;
+use search::BufferSearchBar;
+use util::ResultExt;
+use workspace::{searchable::Direction, Workspace};
 
 use crate::{
     motion::{start_of_line, Motion},
+    normal::substitute::substitute,
     object::Object,
     state::{Mode, Operator},
     utils::{copy_selections_content, yank_selections_content},
@@ -31,6 +33,8 @@ actions!(
         OtherEnd,
         SelectNext,
         SelectPrevious,
+        SelectNextMatch,
+        SelectPreviousMatch,
     ]
 );
 
@@ -47,14 +51,29 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
         },
     );
     workspace.register_action(other_end);
-    workspace.register_action(delete);
-    workspace.register_action(yank);
+    workspace.register_action(|_, _: &VisualDelete, cx| {
+        Vim::update(cx, |vim, cx| {
+            vim.record_current_action(cx);
+            delete(vim, cx);
+        });
+    });
+    workspace.register_action(|_, _: &VisualYank, cx| {
+        Vim::update(cx, |vim, cx| {
+            yank(vim, cx);
+        });
+    });
 
-    workspace.register_action(|workspace, action, cx| {
-        select_next(workspace, action, cx).ok();
+    workspace.register_action(select_next);
+    workspace.register_action(select_previous);
+    workspace.register_action(|workspace, _: &SelectNextMatch, cx| {
+        Vim::update(cx, |vim, cx| {
+            select_match(workspace, vim, Direction::Next, cx);
+        });
     });
-    workspace.register_action(|workspace, action, cx| {
-        select_previous(workspace, action, cx).ok();
+    workspace.register_action(|workspace, _: &SelectPreviousMatch, cx| {
+        Vim::update(cx, |vim, cx| {
+            select_match(workspace, vim, Direction::Prev, cx);
+        });
     });
 }
 
@@ -333,70 +352,65 @@ 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(cx);
-        vim.update_active_editor(cx, |vim, editor, cx| {
-            let mut original_columns: HashMap<_, _> = Default::default();
-            let line_mode = editor.selections.line_mode;
+pub fn delete(vim: &mut Vim, cx: &mut WindowContext) {
+    vim.update_active_editor(cx, |vim, editor, cx| {
+        let mut original_columns: HashMap<_, _> = Default::default();
+        let line_mode = editor.selections.line_mode;
 
-            editor.transact(cx, |editor, cx| {
-                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                    s.move_with(|map, selection| {
-                        if line_mode {
-                            let mut position = selection.head();
-                            if !selection.reversed {
-                                position = movement::left(map, position);
-                            }
-                            original_columns.insert(selection.id, position.to_point(map).column);
-                        }
-                        selection.goal = SelectionGoal::None;
-                    });
-                });
-                copy_selections_content(vim, editor, line_mode, cx);
-                editor.insert("", cx);
-
-                // Fixup cursor position after the deletion
-                editor.set_clip_at_line_ends(true, cx);
-                editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
-                    s.move_with(|map, selection| {
-                        let mut cursor = selection.head().to_point(map);
-
-                        if let Some(column) = original_columns.get(&selection.id) {
-                            cursor.column = *column
+        editor.transact(cx, |editor, cx| {
+            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                s.move_with(|map, selection| {
+                    if line_mode {
+                        let mut position = selection.head();
+                        if !selection.reversed {
+                            position = movement::left(map, position);
                         }
-                        let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
-                        selection.collapse_to(cursor, selection.goal)
-                    });
-                    if vim.state().mode == Mode::VisualBlock {
-                        s.select_anchors(vec![s.first_anchor()])
+                        original_columns.insert(selection.id, position.to_point(map).column);
                     }
+                    selection.goal = SelectionGoal::None;
                 });
-            })
-        });
-        vim.switch_mode(Mode::Normal, true, cx);
-    });
-}
+            });
+            copy_selections_content(vim, editor, line_mode, cx);
+            editor.insert("", cx);
 
-pub fn yank(_: &mut Workspace, _: &VisualYank, cx: &mut ViewContext<Workspace>) {
-    Vim::update(cx, |vim, cx| {
-        vim.update_active_editor(cx, |vim, editor, cx| {
-            let line_mode = editor.selections.line_mode;
-            yank_selections_content(vim, editor, line_mode, cx);
-            editor.change_selections(None, cx, |s| {
+            // Fixup cursor position after the deletion
+            editor.set_clip_at_line_ends(true, cx);
+            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
                 s.move_with(|map, selection| {
-                    if line_mode {
-                        selection.start = start_of_line(map, false, selection.start);
-                    };
-                    selection.collapse_to(selection.start, SelectionGoal::None)
+                    let mut cursor = selection.head().to_point(map);
+
+                    if let Some(column) = original_columns.get(&selection.id) {
+                        cursor.column = *column
+                    }
+                    let cursor = map.clip_point(cursor.to_display_point(map), Bias::Left);
+                    selection.collapse_to(cursor, selection.goal)
                 });
                 if vim.state().mode == Mode::VisualBlock {
                     s.select_anchors(vec![s.first_anchor()])
                 }
             });
+        })
+    });
+    vim.switch_mode(Mode::Normal, true, cx);
+}
+
+pub fn yank(vim: &mut Vim, cx: &mut WindowContext) {
+    vim.update_active_editor(cx, |vim, editor, cx| {
+        let line_mode = editor.selections.line_mode;
+        yank_selections_content(vim, editor, line_mode, cx);
+        editor.change_selections(None, cx, |s| {
+            s.move_with(|map, selection| {
+                if line_mode {
+                    selection.start = start_of_line(map, false, selection.start);
+                };
+                selection.collapse_to(selection.start, SelectionGoal::None)
+            });
+            if vim.state().mode == Mode::VisualBlock {
+                s.select_anchors(vec![s.first_anchor()])
+            }
         });
-        vim.switch_mode(Mode::Normal, true, cx);
     });
+    vim.switch_mode(Mode::Normal, true, cx);
 }
 
 pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
@@ -442,48 +456,112 @@ pub(crate) fn visual_replace(text: Arc<str>, cx: &mut WindowContext) {
     });
 }
 
-pub fn select_next(
-    _: &mut Workspace,
-    _: &SelectNext,
-    cx: &mut ViewContext<Workspace>,
-) -> Result<()> {
+pub fn select_next(_: &mut Workspace, _: &SelectNext, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         let count =
             vim.take_count(cx)
                 .unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
         vim.update_active_editor(cx, |_, editor, cx| {
             for _ in 0..count {
-                match editor.select_next(&Default::default(), cx) {
-                    Err(a) => return Err(a),
-                    _ => {}
+                if editor
+                    .select_next(&Default::default(), cx)
+                    .log_err()
+                    .is_none()
+                {
+                    break;
                 }
             }
-            Ok(())
         })
-    })
-    .unwrap_or(Ok(()))
+    });
 }
 
-pub fn select_previous(
-    _: &mut Workspace,
-    _: &SelectPrevious,
-    cx: &mut ViewContext<Workspace>,
-) -> Result<()> {
+pub fn select_previous(_: &mut Workspace, _: &SelectPrevious, cx: &mut ViewContext<Workspace>) {
     Vim::update(cx, |vim, cx| {
         let count =
             vim.take_count(cx)
                 .unwrap_or_else(|| if vim.state().mode.is_visual() { 1 } else { 2 });
         vim.update_active_editor(cx, |_, editor, cx| {
             for _ in 0..count {
-                match editor.select_previous(&Default::default(), cx) {
-                    Err(a) => return Err(a),
-                    _ => {}
+                if editor
+                    .select_previous(&Default::default(), cx)
+                    .log_err()
+                    .is_none()
+                {
+                    break;
                 }
             }
-            Ok(())
         })
-    })
-    .unwrap_or(Ok(()))
+    });
+}
+
+pub fn select_match(
+    workspace: &mut Workspace,
+    vim: &mut Vim,
+    direction: Direction,
+    cx: &mut WindowContext,
+) {
+    let count = vim.take_count(cx).unwrap_or(1);
+    let pane = workspace.active_pane().clone();
+    let vim_is_normal = vim.state().mode == Mode::Normal;
+    let mut start_selection = 0usize;
+    let mut end_selection = 0usize;
+
+    vim.update_active_editor(cx, |_, editor, _| {
+        editor.set_collapse_matches(false);
+    });
+
+    if vim_is_normal {
+        pane.update(cx, |pane, cx| {
+            if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+                search_bar.update(cx, |search_bar, cx| {
+                    // without update_match_index there is a bug when the cursor is before the first match
+                    search_bar.update_match_index(cx);
+                    search_bar.select_match(direction.opposite(), 1, cx);
+                });
+            }
+        });
+    }
+
+    vim.update_active_editor(cx, |_, editor, cx| {
+        let latest = editor.selections.newest::<usize>(cx);
+        start_selection = latest.start;
+        end_selection = latest.end;
+    });
+
+    pane.update(cx, |pane, cx| {
+        if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
+            search_bar.update(cx, |search_bar, cx| {
+                search_bar.update_match_index(cx);
+                search_bar.select_match(direction, count, cx);
+            });
+        }
+    });
+    vim.update_active_editor(cx, |_, editor, cx| {
+        let latest = editor.selections.newest::<usize>(cx);
+        if vim_is_normal {
+            start_selection = latest.start;
+            end_selection = latest.end;
+        } else {
+            start_selection = start_selection.min(latest.start);
+            end_selection = end_selection.max(latest.end);
+        }
+        if direction == Direction::Prev {
+            std::mem::swap(&mut start_selection, &mut end_selection);
+        }
+        editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+            s.select_ranges([start_selection..end_selection]);
+        });
+        editor.set_collapse_matches(true);
+    });
+    match vim.maybe_pop_operator() {
+        Some(Operator::Change) => substitute(vim, None, false, cx),
+        Some(Operator::Delete) => {
+            vim.stop_recording();
+            delete(vim, cx)
+        }
+        Some(Operator::Yank) => yank(vim, cx),
+        _ => {} // Ignoring other operators
+    };
 }
 
 #[cfg(test)]
@@ -1052,4 +1130,69 @@ mod test {
         cx.simulate_keystrokes(["cmd-shift-p", "escape"]);
         assert_eq!(cx.mode(), Mode::VisualBlock);
     }
+
+    #[gpui::test]
+    async fn test_gn(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["g", "n"]).await;
+        cx.assert_shared_state("aa «aaˇ» aa aa aa").await;
+        cx.simulate_shared_keystrokes(["g", "n"]).await;
+        cx.assert_shared_state("aa «aa aaˇ» aa aa").await;
+        cx.simulate_shared_keystrokes(["escape", "d", "g", "n"])
+            .await;
+        cx.assert_shared_state("aa aa ˇ aa aa").await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["3", "g", "n"]).await;
+        cx.assert_shared_state("aa aa aa «aaˇ» aa").await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["g", "shift-n"]).await;
+        cx.assert_shared_state("aa «ˇaa» aa aa aa").await;
+        cx.simulate_shared_keystrokes(["g", "shift-n"]).await;
+        cx.assert_shared_state("«ˇaa aa» aa aa aa").await;
+    }
+
+    #[gpui::test]
+    async fn test_dgn_repeat(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["d", "g", "n"]).await;
+
+        cx.assert_shared_state("aa ˇ aa aa aa").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        cx.assert_shared_state("aa  ˇ aa aa").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        cx.assert_shared_state("aa   ˇ aa").await;
+    }
+
+    #[gpui::test]
+    async fn test_cgn_repeat(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("aaˇ aa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["/", "a", "a", "enter"])
+            .await;
+        cx.assert_shared_state("aa ˇaa aa aa aa").await;
+        cx.simulate_shared_keystrokes(["c", "g", "n", "x", "escape"])
+            .await;
+        cx.assert_shared_state("aa ˇx aa aa aa").await;
+        cx.simulate_shared_keystrokes(["."]).await;
+        cx.assert_shared_state("aa x ˇx aa aa").await;
+    }
 }

crates/vim/test_data/test_cgn_repeat.json 🔗

@@ -0,0 +1,14 @@
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
+{"Key":"c"}
+{"Key":"g"}
+{"Key":"n"}
+{"Key":"x"}
+{"Key":"escape"}
+{"Get":{"state":"aa ˇx aa aa aa","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"aa x ˇx aa aa","mode":"Normal"}}

crates/vim/test_data/test_dgn_repeat.json 🔗

@@ -0,0 +1,14 @@
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
+{"Key":"d"}
+{"Key":"g"}
+{"Key":"n"}
+{"Get":{"state":"aa ˇ aa aa aa","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"aa  ˇ aa aa","mode":"Normal"}}
+{"Key":"."}
+{"Get":{"state":"aa   ˇ aa","mode":"Normal"}}

crates/vim/test_data/test_gn.json 🔗

@@ -0,0 +1,39 @@
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"n"}
+{"Get":{"state":"aa «aaˇ» aa aa aa","mode":"Visual"}}
+{"Key":"g"}
+{"Key":"n"}
+{"Get":{"state":"aa «aa aaˇ» aa aa","mode":"Visual"}}
+{"Key":"escape"}
+{"Key":"d"}
+{"Key":"g"}
+{"Key":"n"}
+{"Get":{"state":"aa aa ˇ aa aa","mode":"Normal"}}
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
+{"Key":"3"}
+{"Key":"g"}
+{"Key":"n"}
+{"Get":{"state":"aa aa aa «aaˇ» aa","mode":"Visual"}}
+{"Put":{"state":"aaˇ aa aa aa aa"}}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"aa ˇaa aa aa aa","mode":"Normal"}}
+{"Key":"g"}
+{"Key":"shift-n"}
+{"Get":{"state":"aa «ˇaa» aa aa aa","mode":"Visual"}}
+{"Key":"g"}
+{"Key":"shift-n"}
+{"Get":{"state":"«ˇaa aa» aa aa aa","mode":"Visual"}}

crates/workspace/src/searchable.rs 🔗

@@ -24,6 +24,15 @@ pub enum Direction {
     Next,
 }
 
+impl Direction {
+    pub fn opposite(&self) -> Self {
+        match self {
+            Direction::Prev => Direction::Next,
+            Direction::Next => Direction::Prev,
+        }
+    }
+}
+
 #[derive(Clone, Copy, Debug, Default)]
 pub struct SearchOptions {
     pub case: bool,