vim: Allow search with operators & visual mode (#10226)

Conrad Irwin created

Fixes: #4346

Release Notes:

- vim: Add search motions (`/,?,n,N,*,#`) in visual modes and as targets
for operators like `d`,`c`,`y`
([#4346](https://github.com/zed-industries/zed/issues/4346)).

Change summary

assets/keymaps/vim.json                            |  22 +-
crates/vim/src/editor_events.rs                    |   1 
crates/vim/src/motion.rs                           |  89 ++++++++
crates/vim/src/normal/search.rs                    | 155 +++++++++++++++
crates/vim/src/state.rs                            |  14 -
crates/vim/src/surrounds.rs                        |  12 +
crates/vim/src/vim.rs                              |  14 +
crates/vim/test_data/test_d_search.json            |   7 
crates/vim/test_data/test_v_search.json            |  28 ++
crates/vim/test_data/test_visual_block_search.json |   7 
crates/workspace/src/searchable.rs                 |   3 
11 files changed, 316 insertions(+), 36 deletions(-)

Detailed changes

assets/keymaps/vim.json 🔗

@@ -73,8 +73,17 @@
       ],
       "g shift-e": ["vim::PreviousWordEnd", { "ignorePunctuation": true }],
 
-      "n": "search::SelectNextMatch",
-      "shift-n": "search::SelectPrevMatch",
+      "/": "vim::Search",
+      "?": [
+        "vim::Search",
+        {
+          "backwards": true
+        }
+      ],
+      "*": "vim::MoveToNext",
+      "#": "vim::MoveToPrev",
+      "n": "vim::MoveToNextMatch",
+      "shift-n": "vim::MoveToPrevMatch",
       "%": "vim::Matching",
       "f": [
         "vim::PushOperator",
@@ -351,15 +360,6 @@
       ],
       "u": "editor::Undo",
       "ctrl-r": "editor::Redo",
-      "/": "vim::Search",
-      "?": [
-        "vim::Search",
-        {
-          "backwards": true
-        }
-      ],
-      "*": "vim::MoveToNext",
-      "#": "vim::MoveToPrev",
       "r": ["vim::PushOperator", "Replace"],
       "s": "vim::Substitute",
       "shift-s": "vim::SubstituteLine",

crates/vim/src/editor_events.rs 🔗

@@ -48,7 +48,6 @@ fn blurred(editor: View<Editor>, cx: &mut WindowContext) {
                 .upgrade()
                 .is_some_and(|previous| previous == editor.clone())
             {
-                vim.sync_vim_settings(cx);
                 vim.clear_operator(cx);
             }
         }

crates/vim/src/motion.rs 🔗

@@ -3,7 +3,8 @@ use editor::{
     movement::{
         self, find_boundary, find_preceding_boundary_display_point, FindRange, TextLayoutDetails,
     },
-    Bias, DisplayPoint, ToOffset,
+    scroll::Autoscroll,
+    Anchor, Bias, DisplayPoint, ToOffset,
 };
 use gpui::{actions, impl_actions, px, ViewContext, WindowContext};
 use language::{char_kind, CharKind, Point, Selection, SelectionGoal};
@@ -20,7 +21,7 @@ use crate::{
     Vim,
 };
 
-#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum Motion {
     Left,
     Backspace,
@@ -96,6 +97,14 @@ pub enum Motion {
     WindowTop,
     WindowMiddle,
     WindowBottom,
+
+    // we don't have a good way to run a search syncronously, so
+    // we handle search motions by running the search async and then
+    // calling back into motion with this
+    ZedSearchResult {
+        prior_selections: Vec<Range<Anchor>>,
+        new_selections: Vec<Range<Anchor>>,
+    },
 }
 
 #[derive(Clone, Deserialize, PartialEq)]
@@ -379,6 +388,34 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     });
 }
 
+pub(crate) fn search_motion(m: Motion, cx: &mut WindowContext) {
+    if let Motion::ZedSearchResult {
+        prior_selections, ..
+    } = &m
+    {
+        match Vim::read(cx).state().mode {
+            Mode::Visual | Mode::VisualLine | Mode::VisualBlock => {
+                if !prior_selections.is_empty() {
+                    Vim::update(cx, |vim, cx| {
+                        vim.update_active_editor(cx, |_, editor, cx| {
+                            editor.change_selections(Some(Autoscroll::fit()), cx, |s| {
+                                s.select_ranges(prior_selections.iter().cloned())
+                            })
+                        });
+                    });
+                }
+            }
+            Mode::Normal | Mode::Replace | Mode::Insert => {
+                if Vim::read(cx).active_operator().is_none() {
+                    return;
+                }
+            }
+        }
+    }
+
+    motion(m, cx)
+}
+
 pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
     if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) =
         Vim::read(cx).active_operator()
@@ -453,7 +490,8 @@ impl Motion {
             | FirstNonWhitespace { .. }
             | FindBackward { .. }
             | RepeatFind { .. }
-            | RepeatFindReversed { .. } => false,
+            | RepeatFindReversed { .. }
+            | ZedSearchResult { .. } => false,
         }
     }
 
@@ -491,7 +529,8 @@ impl Motion {
             | WindowTop
             | WindowMiddle
             | WindowBottom
-            | NextLineStart => false,
+            | NextLineStart
+            | ZedSearchResult { .. } => false,
         }
     }
 
@@ -529,7 +568,8 @@ impl Motion {
             | NextSubwordStart { .. }
             | PreviousSubwordStart { .. }
             | FirstNonWhitespace { .. }
-            | FindBackward { .. } => false,
+            | FindBackward { .. }
+            | ZedSearchResult { .. } => false,
             RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => {
                 motion.inclusive()
             }
@@ -720,6 +760,18 @@ impl Motion {
             WindowTop => window_top(map, point, &text_layout_details, times - 1),
             WindowMiddle => window_middle(map, point, &text_layout_details),
             WindowBottom => window_bottom(map, point, &text_layout_details, times - 1),
+            ZedSearchResult { new_selections, .. } => {
+                // There will be only one selection, as
+                // Search::SelectNextMatch selects a single match.
+                if let Some(new_selection) = new_selections.first() {
+                    (
+                        new_selection.start.to_display_point(map),
+                        SelectionGoal::None,
+                    )
+                } else {
+                    return None;
+                }
+            }
         };
 
         (new_point != point || infallible).then_some((new_point, goal))
@@ -734,6 +786,33 @@ impl Motion {
         expand_to_surrounding_newline: bool,
         text_layout_details: &TextLayoutDetails,
     ) -> Option<Range<DisplayPoint>> {
+        if let Motion::ZedSearchResult {
+            prior_selections,
+            new_selections,
+        } = self
+        {
+            if let Some((prior_selection, new_selection)) =
+                prior_selections.first().zip(new_selections.first())
+            {
+                let start = prior_selection
+                    .start
+                    .to_display_point(map)
+                    .min(new_selection.start.to_display_point(map));
+                let end = new_selection
+                    .end
+                    .to_display_point(map)
+                    .max(prior_selection.end.to_display_point(map));
+
+                if start < end {
+                    return Some(start..end);
+                } else {
+                    return Some(end..start);
+                }
+            } else {
+                return None;
+            }
+        }
+
         if let Some((new_head, goal)) = self.move_point(
             map,
             selection.head(),

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

@@ -4,7 +4,7 @@ use serde_derive::Deserialize;
 use workspace::{searchable::Direction, Workspace};
 
 use crate::{
-    motion::Motion,
+    motion::{search_motion, Motion},
     normal::move_cursor,
     state::{Mode, SearchState},
     Vim,
@@ -49,7 +49,7 @@ struct Replacement {
     is_case_sensitive: bool,
 }
 
-actions!(vim, [SearchSubmit]);
+actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
 impl_actions!(
     vim,
     [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext]
@@ -58,6 +58,8 @@ impl_actions!(
 pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
     workspace.register_action(move_to_next);
     workspace.register_action(move_to_prev);
+    workspace.register_action(move_to_next_match);
+    workspace.register_action(move_to_prev_match);
     workspace.register_action(search);
     workspace.register_action(search_submit);
     workspace.register_action(search_deploy);
@@ -74,6 +76,22 @@ fn move_to_prev(workspace: &mut Workspace, action: &MoveToPrev, cx: &mut ViewCon
     move_to_internal(workspace, Direction::Prev, !action.partial_word, cx)
 }
 
+fn move_to_next_match(
+    workspace: &mut Workspace,
+    _: &MoveToNextMatch,
+    cx: &mut ViewContext<Workspace>,
+) {
+    move_to_match_internal(workspace, Direction::Next, cx)
+}
+
+fn move_to_prev_match(
+    workspace: &mut Workspace,
+    _: &MoveToPrevMatch,
+    cx: &mut ViewContext<Workspace>,
+) {
+    move_to_match_internal(workspace, Direction::Prev, cx)
+}
+
 fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
     let pane = workspace.active_pane().clone();
     let direction = if action.backwards {
@@ -83,6 +101,7 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
     };
     Vim::update(cx, |vim, cx| {
         let count = vim.take_count(cx).unwrap_or(1);
+        let prior_selections = vim.editor_selections(cx);
         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| {
@@ -102,6 +121,9 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
                         direction,
                         count,
                         initial_query: query.clone(),
+                        prior_selections,
+                        prior_operator: vim.active_operator(),
+                        prior_mode: vim.state().mode,
                     };
                 });
             }
@@ -116,6 +138,7 @@ fn search_deploy(_: &mut Workspace, _: &buffer_search::Deploy, cx: &mut ViewCont
 }
 
 fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
+    let mut motion = None;
     Vim::update(cx, |vim, cx| {
         let pane = workspace.active_pane().clone();
         pane.update(cx, |pane, cx| {
@@ -135,10 +158,60 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
                     state.count = 1;
                     search_bar.select_match(direction, count, cx);
                     search_bar.focus_editor(&Default::default(), cx);
+
+                    let prior_selections = state.prior_selections.drain(..).collect();
+                    let prior_mode = state.prior_mode;
+                    let prior_operator = state.prior_operator.take();
+                    let new_selections = vim.editor_selections(cx);
+
+                    if prior_mode != vim.state().mode {
+                        vim.switch_mode(prior_mode, true, cx);
+                    }
+                    if let Some(operator) = prior_operator {
+                        vim.push_operator(operator, cx);
+                    };
+                    motion = Some(Motion::ZedSearchResult {
+                        prior_selections,
+                        new_selections,
+                    });
                 });
             }
         });
-    })
+    });
+
+    if let Some(motion) = motion {
+        search_motion(motion, cx)
+    }
+}
+
+pub fn move_to_match_internal(
+    workspace: &mut Workspace,
+    direction: Direction,
+    cx: &mut ViewContext<Workspace>,
+) {
+    let mut motion = None;
+    Vim::update(cx, |vim, cx| {
+        let pane = workspace.active_pane().clone();
+        let count = vim.take_count(cx).unwrap_or(1);
+        let prior_selections = vim.editor_selections(cx);
+
+        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.select_match(direction, count, cx);
+
+                    let new_selections = vim.editor_selections(cx);
+                    motion = Some(Motion::ZedSearchResult {
+                        prior_selections,
+                        new_selections,
+                    });
+                })
+            }
+        })
+    });
+    if let Some(motion) = motion {
+        search_motion(motion, cx);
+    }
 }
 
 pub fn move_to_internal(
@@ -150,6 +223,7 @@ pub fn move_to_internal(
     Vim::update(cx, |vim, cx| {
         let pane = workspace.active_pane().clone();
         let count = vim.take_count(cx).unwrap_or(1);
+        let prior_selections = vim.editor_selections(cx);
 
         pane.update(cx, |pane, cx| {
             if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
@@ -159,6 +233,8 @@ pub fn move_to_internal(
                         return None;
                     }
                     let Some(query) = search_bar.query_suggestion(cx) else {
+                        vim.clear_operator(cx);
+                        let _ = search_bar.search("", None, cx);
                         return None;
                     };
                     let mut query = regex::escape(&query);
@@ -174,7 +250,17 @@ pub fn move_to_internal(
                     cx.spawn(|_, mut cx| async move {
                         search.await?;
                         search_bar.update(&mut cx, |search_bar, cx| {
-                            search_bar.select_match(direction, count, cx)
+                            search_bar.select_match(direction, count, cx);
+
+                            let new_selections =
+                                Vim::update(cx, |vim, cx| vim.editor_selections(cx));
+                            search_motion(
+                                Motion::ZedSearchResult {
+                                    prior_selections,
+                                    new_selections,
+                                },
+                                cx,
+                            )
                         })?;
                         anyhow::Ok(())
                     })
@@ -186,8 +272,6 @@ pub fn move_to_internal(
         if vim.state().mode.is_visual() {
             vim.switch_mode(Mode::Normal, false, cx)
         }
-
-        vim.clear_operator(cx);
     });
 }
 
@@ -362,6 +446,7 @@ fn parse_replace_all(query: &str) -> Replacement {
 #[cfg(test)]
 mod test {
     use editor::DisplayPoint;
+    use indoc::indoc;
     use search::BufferSearchBar;
 
     use crate::{
@@ -508,4 +593,62 @@ mod test {
         cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
         cx.assert_shared_mode(Mode::Normal).await;
     }
+
+    #[gpui::test]
+    async fn test_d_search(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
+        cx.simulate_shared_keystrokes(["d", "/", "c", "d"]).await;
+        cx.simulate_shared_keystrokes(["enter"]).await;
+        cx.assert_shared_state("ˇcd a.c. abcd").await;
+    }
+
+    #[gpui::test]
+    async fn test_v_search(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state("ˇa.c. abcd a.c. abcd").await;
+        cx.simulate_shared_keystrokes(["v", "/", "c", "d"]).await;
+        cx.simulate_shared_keystrokes(["enter"]).await;
+        cx.assert_shared_state("«a.c. abcˇ»d a.c. abcd").await;
+
+        cx.set_shared_state("a a aˇ a a a").await;
+        cx.simulate_shared_keystrokes(["v", "/", "a"]).await;
+        cx.simulate_shared_keystrokes(["enter"]).await;
+        cx.assert_shared_state("a a a« aˇ» a a").await;
+        cx.simulate_shared_keystrokes(["/", "enter"]).await;
+        cx.assert_shared_state("a a a« a aˇ» a").await;
+        cx.simulate_shared_keystrokes(["?", "enter"]).await;
+        cx.assert_shared_state("a a a« aˇ» a a").await;
+        cx.simulate_shared_keystrokes(["?", "enter"]).await;
+        cx.assert_shared_state("a a «ˇa »a a a").await;
+        cx.simulate_shared_keystrokes(["/", "enter"]).await;
+        cx.assert_shared_state("a a a« aˇ» a a").await;
+        cx.simulate_shared_keystrokes(["/", "enter"]).await;
+        cx.assert_shared_state("a a a« a aˇ» a").await;
+    }
+
+    #[gpui::test]
+    async fn test_visual_block_search(cx: &mut gpui::TestAppContext) {
+        let mut cx = NeovimBackedTestContext::new(cx).await;
+
+        cx.set_shared_state(indoc! {
+            "ˇone two
+             three four
+             five six
+             "
+        })
+        .await;
+        cx.simulate_shared_keystrokes(["ctrl-v", "j", "/", "f"])
+            .await;
+        cx.simulate_shared_keystrokes(["enter"]).await;
+        cx.assert_shared_state(indoc! {
+            "«one twoˇ»
+             «three fˇ»our
+             five six
+             "
+        })
+        .await;
+    }
 }

crates/vim/src/state.rs 🔗

@@ -138,21 +138,15 @@ impl Clone for ReplayableAction {
     }
 }
 
-#[derive(Clone)]
+#[derive(Clone, Default, Debug)]
 pub struct SearchState {
     pub direction: Direction,
     pub count: usize,
     pub initial_query: String,
-}
 
-impl Default for SearchState {
-    fn default() -> Self {
-        Self {
-            direction: Direction::Next,
-            count: 1,
-            initial_query: "".to_string(),
-        }
-    }
+    pub prior_selections: Vec<Range<Anchor>>,
+    pub prior_operator: Option<Operator>,
+    pub prior_mode: Mode,
 }
 
 impl EditorState {

crates/vim/src/surrounds.rs 🔗

@@ -4,12 +4,22 @@ use gpui::WindowContext;
 use language::BracketPair;
 use serde::Deserialize;
 use std::sync::Arc;
-#[derive(Clone, Debug, PartialEq, Eq, Deserialize)]
+#[derive(Clone, Debug, PartialEq, Eq)]
 pub enum SurroundsType {
     Motion(Motion),
     Object(Object),
 }
 
+// This exists so that we can have Deserialize on Operators, but not on Motions.
+impl<'de> Deserialize<'de> for SurroundsType {
+    fn deserialize<D>(_: D) -> Result<Self, D::Error>
+    where
+        D: serde::Deserializer<'de>,
+    {
+        Err(serde::de::Error::custom("Cannot deserialize SurroundsType"))
+    }
+}
+
 pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowContext) {
     Vim::update(cx, |vim, cx| {
         vim.stop_recording();

crates/vim/src/vim.rs 🔗

@@ -21,7 +21,7 @@ use collections::HashMap;
 use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
 use editor::{
     movement::{self, FindRange},
-    Editor, EditorEvent, EditorMode,
+    Anchor, Editor, EditorEvent, EditorMode,
 };
 use gpui::{
     actions, impl_actions, Action, AppContext, EntityId, FocusableView, Global, KeystrokeEvent,
@@ -295,6 +295,18 @@ impl Vim {
         Some(editor.update(cx, |editor, cx| update(self, editor, cx)))
     }
 
+    fn editor_selections(&mut self, cx: &mut WindowContext) -> Vec<Range<Anchor>> {
+        self.update_active_editor(cx, |_, editor, _| {
+            editor
+                .selections
+                .disjoint_anchors()
+                .iter()
+                .map(|selection| selection.tail()..selection.head())
+                .collect()
+        })
+        .unwrap_or_default()
+    }
+
     /// When doing an action that modifies the buffer, we start recording so that `.`
     /// will replay the action.
     pub fn start_recording(&mut self, cx: &mut WindowContext) {

crates/vim/test_data/test_d_search.json 🔗

@@ -0,0 +1,7 @@
+{"Put":{"state":"ˇa.c. abcd a.c. abcd"}}
+{"Key":"d"}
+{"Key":"/"}
+{"Key":"c"}
+{"Key":"d"}
+{"Key":"enter"}
+{"Get":{"state":"ˇcd a.c. abcd","mode":"Normal"}}

crates/vim/test_data/test_v_search.json 🔗

@@ -0,0 +1,28 @@
+{"Put":{"state":"ˇa.c. abcd a.c. abcd"}}
+{"Key":"v"}
+{"Key":"/"}
+{"Key":"c"}
+{"Key":"d"}
+{"Key":"enter"}
+{"Get":{"state":"«a.c. abcˇ»d a.c. abcd","mode":"Visual"}}
+{"Put":{"state":"a a aˇ a a a"}}
+{"Key":"v"}
+{"Key":"/"}
+{"Key":"a"}
+{"Key":"enter"}
+{"Get":{"state":"a a a« aˇ» a a","mode":"Visual"}}
+{"Key":"/"}
+{"Key":"enter"}
+{"Get":{"state":"a a a« a aˇ» a","mode":"Visual"}}
+{"Key":"?"}
+{"Key":"enter"}
+{"Get":{"state":"a a a« aˇ» a a","mode":"Visual"}}
+{"Key":"?"}
+{"Key":"enter"}
+{"Get":{"state":"a a «ˇa »a a a","mode":"Visual"}}
+{"Key":"/"}
+{"Key":"enter"}
+{"Get":{"state":"a a a« aˇ» a a","mode":"Visual"}}
+{"Key":"/"}
+{"Key":"enter"}
+{"Get":{"state":"a a a« a aˇ» a","mode":"Visual"}}

crates/vim/test_data/test_visual_block_search.json 🔗

@@ -0,0 +1,7 @@
+{"Put":{"state":"ˇone two\nthree four\nfive six\n"}}
+{"Key":"ctrl-v"}
+{"Key":"j"}
+{"Key":"/"}
+{"Key":"f"}
+{"Key":"enter"}
+{"Get":{"state":"«one twoˇ»\n«three fˇ»our\nfive six\n","mode":"VisualBlock"}}

crates/workspace/src/searchable.rs 🔗

@@ -18,9 +18,10 @@ pub enum SearchEvent {
     ActiveMatchChanged,
 }
 
-#[derive(Clone, Copy, PartialEq, Eq, Debug)]
+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
 pub enum Direction {
     Prev,
+    #[default]
     Next,
 }