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)).
This commit is contained in:
Conrad Irwin 2024-04-08 15:20:14 -06:00 committed by GitHub
parent f9bf60f017
commit f327118e06
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 316 additions and 36 deletions

View File

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

View File

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

View File

@ -3,7 +3,8 @@ use editor::{
movement::{ movement::{
self, find_boundary, find_preceding_boundary_display_point, FindRange, TextLayoutDetails, 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 gpui::{actions, impl_actions, px, ViewContext, WindowContext};
use language::{char_kind, CharKind, Point, Selection, SelectionGoal}; use language::{char_kind, CharKind, Point, Selection, SelectionGoal};
@ -20,7 +21,7 @@ use crate::{
Vim, Vim,
}; };
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum Motion { pub enum Motion {
Left, Left,
Backspace, Backspace,
@ -96,6 +97,14 @@ pub enum Motion {
WindowTop, WindowTop,
WindowMiddle, WindowMiddle,
WindowBottom, 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)] #[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) { pub(crate) fn motion(motion: Motion, cx: &mut WindowContext) {
if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) = if let Some(Operator::FindForward { .. }) | Some(Operator::FindBackward { .. }) =
Vim::read(cx).active_operator() Vim::read(cx).active_operator()
@ -453,7 +490,8 @@ impl Motion {
| FirstNonWhitespace { .. } | FirstNonWhitespace { .. }
| FindBackward { .. } | FindBackward { .. }
| RepeatFind { .. } | RepeatFind { .. }
| RepeatFindReversed { .. } => false, | RepeatFindReversed { .. }
| ZedSearchResult { .. } => false,
} }
} }
@ -491,7 +529,8 @@ impl Motion {
| WindowTop | WindowTop
| WindowMiddle | WindowMiddle
| WindowBottom | WindowBottom
| NextLineStart => false, | NextLineStart
| ZedSearchResult { .. } => false,
} }
} }
@ -529,7 +568,8 @@ impl Motion {
| NextSubwordStart { .. } | NextSubwordStart { .. }
| PreviousSubwordStart { .. } | PreviousSubwordStart { .. }
| FirstNonWhitespace { .. } | FirstNonWhitespace { .. }
| FindBackward { .. } => false, | FindBackward { .. }
| ZedSearchResult { .. } => false,
RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => { RepeatFind { last_find: motion } | RepeatFindReversed { last_find: motion } => {
motion.inclusive() motion.inclusive()
} }
@ -720,6 +760,18 @@ impl Motion {
WindowTop => window_top(map, point, &text_layout_details, times - 1), WindowTop => window_top(map, point, &text_layout_details, times - 1),
WindowMiddle => window_middle(map, point, &text_layout_details), WindowMiddle => window_middle(map, point, &text_layout_details),
WindowBottom => window_bottom(map, point, &text_layout_details, times - 1), 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)) (new_point != point || infallible).then_some((new_point, goal))
@ -734,6 +786,33 @@ impl Motion {
expand_to_surrounding_newline: bool, expand_to_surrounding_newline: bool,
text_layout_details: &TextLayoutDetails, text_layout_details: &TextLayoutDetails,
) -> Option<Range<DisplayPoint>> { ) -> 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( if let Some((new_head, goal)) = self.move_point(
map, map,
selection.head(), selection.head(),

View File

@ -4,7 +4,7 @@ use serde_derive::Deserialize;
use workspace::{searchable::Direction, Workspace}; use workspace::{searchable::Direction, Workspace};
use crate::{ use crate::{
motion::Motion, motion::{search_motion, Motion},
normal::move_cursor, normal::move_cursor,
state::{Mode, SearchState}, state::{Mode, SearchState},
Vim, Vim,
@ -49,7 +49,7 @@ struct Replacement {
is_case_sensitive: bool, is_case_sensitive: bool,
} }
actions!(vim, [SearchSubmit]); actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]);
impl_actions!( impl_actions!(
vim, vim,
[FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext]
@ -58,6 +58,8 @@ impl_actions!(
pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) { pub(crate) fn register(workspace: &mut Workspace, _: &mut ViewContext<Workspace>) {
workspace.register_action(move_to_next); workspace.register_action(move_to_next);
workspace.register_action(move_to_prev); 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);
workspace.register_action(search_submit); workspace.register_action(search_submit);
workspace.register_action(search_deploy); 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) 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>) { fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Workspace>) {
let pane = workspace.active_pane().clone(); let pane = workspace.active_pane().clone();
let direction = if action.backwards { 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| { Vim::update(cx, |vim, cx| {
let count = vim.take_count(cx).unwrap_or(1); let count = vim.take_count(cx).unwrap_or(1);
let prior_selections = vim.editor_selections(cx);
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |search_bar, cx| { search_bar.update(cx, |search_bar, cx| {
@ -102,6 +121,9 @@ fn search(workspace: &mut Workspace, action: &Search, cx: &mut ViewContext<Works
direction, direction,
count, count,
initial_query: query.clone(), 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>) { fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewContext<Workspace>) {
let mut motion = None;
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
let pane = workspace.active_pane().clone(); let pane = workspace.active_pane().clone();
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
@ -135,10 +158,60 @@ fn search_submit(workspace: &mut Workspace, _: &SearchSubmit, cx: &mut ViewConte
state.count = 1; state.count = 1;
search_bar.select_match(direction, count, cx); search_bar.select_match(direction, count, cx);
search_bar.focus_editor(&Default::default(), 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( pub fn move_to_internal(
@ -150,6 +223,7 @@ pub fn move_to_internal(
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
let pane = workspace.active_pane().clone(); let pane = workspace.active_pane().clone();
let count = vim.take_count(cx).unwrap_or(1); let count = vim.take_count(cx).unwrap_or(1);
let prior_selections = vim.editor_selections(cx);
pane.update(cx, |pane, cx| { pane.update(cx, |pane, cx| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() { if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
@ -159,6 +233,8 @@ pub fn move_to_internal(
return None; return None;
} }
let Some(query) = search_bar.query_suggestion(cx) else { let Some(query) = search_bar.query_suggestion(cx) else {
vim.clear_operator(cx);
let _ = search_bar.search("", None, cx);
return None; return None;
}; };
let mut query = regex::escape(&query); let mut query = regex::escape(&query);
@ -174,7 +250,17 @@ pub fn move_to_internal(
cx.spawn(|_, mut cx| async move { cx.spawn(|_, mut cx| async move {
search.await?; search.await?;
search_bar.update(&mut cx, |search_bar, cx| { 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(()) anyhow::Ok(())
}) })
@ -186,8 +272,6 @@ pub fn move_to_internal(
if vim.state().mode.is_visual() { if vim.state().mode.is_visual() {
vim.switch_mode(Mode::Normal, false, cx) vim.switch_mode(Mode::Normal, false, cx)
} }
vim.clear_operator(cx);
}); });
} }
@ -362,6 +446,7 @@ fn parse_replace_all(query: &str) -> Replacement {
#[cfg(test)] #[cfg(test)]
mod test { mod test {
use editor::DisplayPoint; use editor::DisplayPoint;
use indoc::indoc;
use search::BufferSearchBar; use search::BufferSearchBar;
use crate::{ use crate::{
@ -508,4 +593,62 @@ mod test {
cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await; cx.assert_shared_state("a.c. abcd ˇa.c. abcd").await;
cx.assert_shared_mode(Mode::Normal).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 »our
five six
"
})
.await;
}
} }

View File

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

View File

@ -4,12 +4,22 @@ use gpui::WindowContext;
use language::BracketPair; use language::BracketPair;
use serde::Deserialize; use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[derive(Clone, Debug, PartialEq, Eq)]
pub enum SurroundsType { pub enum SurroundsType {
Motion(Motion), Motion(Motion),
Object(Object), 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) { pub fn add_surrounds(text: Arc<str>, target: SurroundsType, cx: &mut WindowContext) {
Vim::update(cx, |vim, cx| { Vim::update(cx, |vim, cx| {
vim.stop_recording(); vim.stop_recording();

View File

@ -21,7 +21,7 @@ use collections::HashMap;
use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor}; use command_palette_hooks::{CommandPaletteFilter, CommandPaletteInterceptor};
use editor::{ use editor::{
movement::{self, FindRange}, movement::{self, FindRange},
Editor, EditorEvent, EditorMode, Anchor, Editor, EditorEvent, EditorMode,
}; };
use gpui::{ use gpui::{
actions, impl_actions, Action, AppContext, EntityId, FocusableView, Global, KeystrokeEvent, 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))) 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 `.` /// When doing an action that modifies the buffer, we start recording so that `.`
/// will replay the action. /// will replay the action.
pub fn start_recording(&mut self, cx: &mut WindowContext) { pub fn start_recording(&mut self, cx: &mut WindowContext) {

View File

@ -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"}}

View File

@ -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"}}

View File

@ -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"}}

View File

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