From f327118e06c2517d73f665a4be901cafec887cc7 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Mon, 8 Apr 2024 15:20:14 -0600 Subject: [PATCH] vim: Allow search with operators & visual mode (#10226) 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)). --- 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 ++++ .../test_data/test_visual_block_search.json | 7 + crates/workspace/src/searchable.rs | 3 +- 11 files changed, 316 insertions(+), 36 deletions(-) create mode 100644 crates/vim/test_data/test_d_search.json create mode 100644 crates/vim/test_data/test_v_search.json create mode 100644 crates/vim/test_data/test_visual_block_search.json diff --git a/assets/keymaps/vim.json b/assets/keymaps/vim.json index cc66bc13f7..020bfe578d 100644 --- a/assets/keymaps/vim.json +++ b/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", diff --git a/crates/vim/src/editor_events.rs b/crates/vim/src/editor_events.rs index 18afbfae14..d4ba0e5dbc 100644 --- a/crates/vim/src/editor_events.rs +++ b/crates/vim/src/editor_events.rs @@ -48,7 +48,6 @@ fn blurred(editor: View, cx: &mut WindowContext) { .upgrade() .is_some_and(|previous| previous == editor.clone()) { - vim.sync_vim_settings(cx); vim.clear_operator(cx); } } diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index c566baaa10..12b9b70a42 100644 --- a/crates/vim/src/motion.rs +++ b/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>, + new_selections: Vec>, + }, } #[derive(Clone, Deserialize, PartialEq)] @@ -379,6 +388,34 @@ pub fn register(workspace: &mut Workspace, _: &mut ViewContext) { }); } +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> { + 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(), diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 68a8ada984..10c3a01811 100644 --- a/crates/vim/src/normal/search.rs +++ b/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.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, +) { + move_to_match_internal(workspace, Direction::Next, cx) +} + +fn move_to_prev_match( + workspace: &mut Workspace, + _: &MoveToPrevMatch, + cx: &mut ViewContext, +) { + move_to_match_internal(workspace, Direction::Prev, cx) +} + fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { 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() { search_bar.update(cx, |search_bar, cx| { @@ -102,6 +121,9 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext) { + 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, +) { + 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::() { + 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::() { @@ -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; + } } diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index c78304610e..82d4fc9af3 100644 --- a/crates/vim/src/state.rs +++ b/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>, + pub prior_operator: Option, + pub prior_mode: Mode, } impl EditorState { diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index c6251b6b9b..4ff911e706 100644 --- a/crates/vim/src/surrounds.rs +++ b/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) -> Result + where + D: serde::Deserializer<'de>, + { + Err(serde::de::Error::custom("Cannot deserialize SurroundsType")) + } +} + pub fn add_surrounds(text: Arc, target: SurroundsType, cx: &mut WindowContext) { Vim::update(cx, |vim, cx| { vim.stop_recording(); diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index e9491b1a8e..73bfd5fd3f 100644 --- a/crates/vim/src/vim.rs +++ b/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> { + 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) { diff --git a/crates/vim/test_data/test_d_search.json b/crates/vim/test_data/test_d_search.json new file mode 100644 index 0000000000..9cdc855dbf --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_v_search.json b/crates/vim/test_data/test_v_search.json new file mode 100644 index 0000000000..b8af915519 --- /dev/null +++ b/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"}} diff --git a/crates/vim/test_data/test_visual_block_search.json b/crates/vim/test_data/test_visual_block_search.json new file mode 100644 index 0000000000..1943ce906f --- /dev/null +++ b/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"}} diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index ad3190961c..04413c1b9f 100644 --- a/crates/workspace/src/searchable.rs +++ b/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, }