mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-26 20:22:30 +00:00
vim: Add support for :g/ and :v/ (#22177)
Closes #ISSUE Still TODO to make this feature good is better command history Release Notes: - vim: Add support for `:g/<pattern>/<cmd>` and `:v/<pattern>/<cmd>`
This commit is contained in:
parent
2ecbd97fe8
commit
4a6f071fde
5 changed files with 300 additions and 8 deletions
|
@ -10413,7 +10413,7 @@ impl Editor {
|
|||
self.end_transaction_at(Instant::now(), cx)
|
||||
}
|
||||
|
||||
fn start_transaction_at(&mut self, now: Instant, cx: &mut ViewContext<Self>) {
|
||||
pub fn start_transaction_at(&mut self, now: Instant, cx: &mut ViewContext<Self>) {
|
||||
self.end_selection(cx);
|
||||
if let Some(tx_id) = self
|
||||
.buffer
|
||||
|
@ -10427,7 +10427,7 @@ impl Editor {
|
|||
}
|
||||
}
|
||||
|
||||
fn end_transaction_at(
|
||||
pub fn end_transaction_at(
|
||||
&mut self,
|
||||
now: Instant,
|
||||
cx: &mut ViewContext<Self>,
|
||||
|
|
|
@ -391,7 +391,7 @@ impl SelectionsCollection {
|
|||
}
|
||||
}
|
||||
|
||||
pub(crate) fn change_with<R>(
|
||||
pub fn change_with<R>(
|
||||
&mut self,
|
||||
cx: &mut AppContext,
|
||||
change: impl FnOnce(&mut MutableSelectionsCollection) -> R,
|
||||
|
@ -764,7 +764,7 @@ impl<'a> MutableSelectionsCollection<'a> {
|
|||
|
||||
pub fn replace_cursors_with(
|
||||
&mut self,
|
||||
mut find_replacement_cursors: impl FnMut(&DisplaySnapshot) -> Vec<DisplayPoint>,
|
||||
find_replacement_cursors: impl FnOnce(&DisplaySnapshot) -> Vec<DisplayPoint>,
|
||||
) {
|
||||
let display_map = self.display_map();
|
||||
let new_selections = find_replacement_cursors(&display_map)
|
||||
|
|
|
@ -3,17 +3,21 @@ use std::{
|
|||
ops::{Deref, Range},
|
||||
str::Chars,
|
||||
sync::OnceLock,
|
||||
time::Instant,
|
||||
};
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
use command_palette_hooks::CommandInterceptResult;
|
||||
use editor::{
|
||||
actions::{SortLinesCaseInsensitive, SortLinesCaseSensitive},
|
||||
Editor, ToPoint,
|
||||
display_map::ToDisplayPoint,
|
||||
Bias, Editor, ToPoint,
|
||||
};
|
||||
use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext};
|
||||
use language::Point;
|
||||
use multi_buffer::MultiBufferRow;
|
||||
use regex::Regex;
|
||||
use search::{BufferSearchBar, SearchOptions};
|
||||
use serde::Deserialize;
|
||||
use ui::WindowContext;
|
||||
use util::ResultExt;
|
||||
|
@ -57,7 +61,10 @@ pub struct WithCount {
|
|||
struct WrappedAction(Box<dyn Action>);
|
||||
|
||||
actions!(vim, [VisualCommand, CountCommand]);
|
||||
impl_actions!(vim, [GoToLine, YankCommand, WithRange, WithCount]);
|
||||
impl_actions!(
|
||||
vim,
|
||||
[GoToLine, YankCommand, WithRange, WithCount, OnMatchingLines]
|
||||
);
|
||||
|
||||
impl<'de> Deserialize<'de> for WrappedAction {
|
||||
fn deserialize<D>(_: D) -> Result<Self, D::Error>
|
||||
|
@ -204,6 +211,10 @@ pub fn register(editor: &mut Editor, cx: &mut ViewContext<Vim>) {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
Vim::action(editor, cx, |vim, action: &OnMatchingLines, cx| {
|
||||
action.run(vim, cx)
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
|
@ -786,6 +797,31 @@ pub fn command_interceptor(mut input: &str, cx: &AppContext) -> Option<CommandIn
|
|||
} else {
|
||||
None
|
||||
}
|
||||
} else if query.starts_with('g') || query.starts_with('v') {
|
||||
let mut global = "global".chars().peekable();
|
||||
let mut query = query.chars().peekable();
|
||||
let mut invert = false;
|
||||
if query.peek() == Some(&'v') {
|
||||
invert = true;
|
||||
query.next();
|
||||
}
|
||||
while global.peek().is_some_and(|char| Some(char) == query.peek()) {
|
||||
global.next();
|
||||
query.next();
|
||||
}
|
||||
if !invert && query.peek() == Some(&'!') {
|
||||
invert = true;
|
||||
query.next();
|
||||
}
|
||||
let range = range.clone().unwrap_or(CommandRange {
|
||||
start: Position::Line { row: 0, offset: 0 },
|
||||
end: Some(Position::LastLine { offset: 0 }),
|
||||
});
|
||||
if let Some(action) = OnMatchingLines::parse(query, invert, range, cx) {
|
||||
Some(action.boxed_clone())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
@ -839,6 +875,193 @@ fn generate_positions(string: &str, query: &str) -> Vec<usize> {
|
|||
positions
|
||||
}
|
||||
|
||||
#[derive(Debug, PartialEq, Deserialize, Clone)]
|
||||
pub(crate) struct OnMatchingLines {
|
||||
range: CommandRange,
|
||||
search: String,
|
||||
action: WrappedAction,
|
||||
invert: bool,
|
||||
}
|
||||
|
||||
impl OnMatchingLines {
|
||||
// convert a vim query into something more usable by zed.
|
||||
// we don't attempt to fully convert between the two regex syntaxes,
|
||||
// but we do flip \( and \) to ( and ) (and vice-versa) in the pattern,
|
||||
// and convert \0..\9 to $0..$9 in the replacement so that common idioms work.
|
||||
pub(crate) fn parse(
|
||||
mut chars: Peekable<Chars>,
|
||||
invert: bool,
|
||||
range: CommandRange,
|
||||
cx: &AppContext,
|
||||
) -> Option<Self> {
|
||||
let delimiter = chars.next().filter(|c| {
|
||||
!c.is_alphanumeric() && *c != '"' && *c != '|' && *c != '\'' && *c != '!'
|
||||
})?;
|
||||
|
||||
let mut search = String::new();
|
||||
let mut escaped = false;
|
||||
|
||||
while let Some(c) = chars.next() {
|
||||
if escaped {
|
||||
escaped = false;
|
||||
// unescape escaped parens
|
||||
if c != '(' && c != ')' && c != delimiter {
|
||||
search.push('\\')
|
||||
}
|
||||
search.push(c)
|
||||
} else if c == '\\' {
|
||||
escaped = true;
|
||||
} else if c == delimiter {
|
||||
break;
|
||||
} else {
|
||||
// escape unescaped parens
|
||||
if c == '(' || c == ')' {
|
||||
search.push('\\')
|
||||
}
|
||||
search.push(c)
|
||||
}
|
||||
}
|
||||
|
||||
let command: String = chars.collect();
|
||||
|
||||
let action = WrappedAction(command_interceptor(&command, cx)?.action);
|
||||
|
||||
Some(Self {
|
||||
range,
|
||||
search,
|
||||
invert,
|
||||
action,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn run(&self, vim: &mut Vim, cx: &mut ViewContext<Vim>) {
|
||||
let result = vim.update_editor(cx, |vim, editor, cx| {
|
||||
self.range.buffer_range(vim, editor, cx)
|
||||
});
|
||||
|
||||
let range = match result {
|
||||
None => return,
|
||||
Some(e @ Err(_)) => {
|
||||
let Some(workspace) = vim.workspace(cx) else {
|
||||
return;
|
||||
};
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
e.notify_err(workspace, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
Some(Ok(result)) => result,
|
||||
};
|
||||
|
||||
let mut action = self.action.boxed_clone();
|
||||
let mut last_pattern = self.search.clone();
|
||||
|
||||
let mut regexes = match Regex::new(&self.search) {
|
||||
Ok(regex) => vec![(regex, !self.invert)],
|
||||
e @ Err(_) => {
|
||||
let Some(workspace) = vim.workspace(cx) else {
|
||||
return;
|
||||
};
|
||||
workspace.update(cx, |workspace, cx| {
|
||||
e.notify_err(workspace, cx);
|
||||
});
|
||||
return;
|
||||
}
|
||||
};
|
||||
while let Some(inner) = action
|
||||
.boxed_clone()
|
||||
.as_any()
|
||||
.downcast_ref::<OnMatchingLines>()
|
||||
{
|
||||
let Some(regex) = Regex::new(&inner.search).ok() else {
|
||||
break;
|
||||
};
|
||||
last_pattern = inner.search.clone();
|
||||
action = inner.action.boxed_clone();
|
||||
regexes.push((regex, !inner.invert))
|
||||
}
|
||||
|
||||
if let Some(pane) = vim.pane(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| {
|
||||
if search_bar.show(cx) {
|
||||
let _ = search_bar.search(
|
||||
&last_pattern,
|
||||
Some(SearchOptions::REGEX | SearchOptions::CASE_SENSITIVE),
|
||||
cx,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
vim.update_editor(cx, |_, editor, cx| {
|
||||
let snapshot = editor.snapshot(cx);
|
||||
let mut row = range.start.0;
|
||||
|
||||
let point_range = Point::new(range.start.0, 0)
|
||||
..snapshot
|
||||
.buffer_snapshot
|
||||
.clip_point(Point::new(range.end.0 + 1, 0), Bias::Left);
|
||||
cx.spawn(|editor, mut cx| async move {
|
||||
let new_selections = cx
|
||||
.background_executor()
|
||||
.spawn(async move {
|
||||
let mut line = String::new();
|
||||
let mut new_selections = Vec::new();
|
||||
let chunks = snapshot
|
||||
.buffer_snapshot
|
||||
.text_for_range(point_range)
|
||||
.chain(["\n"]);
|
||||
|
||||
for chunk in chunks {
|
||||
for (newline_ix, text) in chunk.split('\n').enumerate() {
|
||||
if newline_ix > 0 {
|
||||
if regexes.iter().all(|(regex, should_match)| {
|
||||
regex.is_match(&line) == *should_match
|
||||
}) {
|
||||
new_selections
|
||||
.push(Point::new(row, 0).to_display_point(&snapshot))
|
||||
}
|
||||
row += 1;
|
||||
line.clear();
|
||||
}
|
||||
line.push_str(text)
|
||||
}
|
||||
}
|
||||
|
||||
new_selections
|
||||
})
|
||||
.await;
|
||||
|
||||
if new_selections.is_empty() {
|
||||
return;
|
||||
}
|
||||
editor
|
||||
.update(&mut cx, |editor, cx| {
|
||||
editor.start_transaction_at(Instant::now(), cx);
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.replace_cursors_with(|_| new_selections);
|
||||
});
|
||||
cx.dispatch_action(action);
|
||||
cx.defer(move |editor, cx| {
|
||||
let newest = editor.selections.newest::<Point>(cx).clone();
|
||||
editor.change_selections(None, cx, |s| {
|
||||
s.select(vec![newest]);
|
||||
});
|
||||
editor.end_transaction_at(Instant::now(), cx);
|
||||
})
|
||||
})
|
||||
.ok();
|
||||
})
|
||||
.detach();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::path::Path;
|
||||
|
@ -1109,4 +1332,46 @@ mod test {
|
|||
assert_active_item(workspace, "/root/dir/file3.rs", "go to file3", cx);
|
||||
});
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
async fn test_command_matching_lines(cx: &mut TestAppContext) {
|
||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||
|
||||
cx.set_shared_state(indoc! {"
|
||||
ˇa
|
||||
b
|
||||
a
|
||||
b
|
||||
a
|
||||
"})
|
||||
.await;
|
||||
|
||||
cx.simulate_shared_keystrokes(":").await;
|
||||
cx.simulate_shared_keystrokes("g / a / d").await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
b
|
||||
b
|
||||
ˇ"});
|
||||
|
||||
cx.simulate_shared_keystrokes("u").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
ˇa
|
||||
b
|
||||
a
|
||||
b
|
||||
a
|
||||
"});
|
||||
|
||||
cx.simulate_shared_keystrokes(":").await;
|
||||
cx.simulate_shared_keystrokes("v / a / d").await;
|
||||
cx.simulate_shared_keystrokes("enter").await;
|
||||
|
||||
cx.shared_state().await.assert_eq(indoc! {"
|
||||
a
|
||||
a
|
||||
ˇa"});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,7 @@ use std::sync::Arc;
|
|||
|
||||
use collections::HashMap;
|
||||
use editor::{
|
||||
display_map::{DisplaySnapshot, ToDisplayPoint},
|
||||
display_map::{DisplayRow, DisplaySnapshot, ToDisplayPoint},
|
||||
movement,
|
||||
scroll::Autoscroll,
|
||||
Bias, DisplayPoint, Editor, ToOffset,
|
||||
|
@ -463,8 +463,16 @@ impl Vim {
|
|||
*selection.end.column_mut() = map.line_len(selection.end.row())
|
||||
} else if vim.mode != Mode::VisualLine {
|
||||
selection.start = DisplayPoint::new(selection.start.row(), 0);
|
||||
selection.end =
|
||||
map.next_line_boundary(selection.end.to_point(map)).1;
|
||||
if selection.end.row() == map.max_point().row() {
|
||||
selection.end = map.max_point()
|
||||
selection.end = map.max_point();
|
||||
if selection.start == selection.end {
|
||||
let prev_row =
|
||||
DisplayRow(selection.start.row().0.saturating_sub(1));
|
||||
selection.start =
|
||||
DisplayPoint::new(prev_row, map.line_len(prev_row));
|
||||
}
|
||||
} else {
|
||||
*selection.end.row_mut() += 1;
|
||||
*selection.end.column_mut() = 0;
|
||||
|
|
19
crates/vim/test_data/test_command_matching_lines.json
Normal file
19
crates/vim/test_data/test_command_matching_lines.json
Normal file
|
@ -0,0 +1,19 @@
|
|||
{"Put":{"state":"ˇa\nb\na\nb\na\n"}}
|
||||
{"Key":":"}
|
||||
{"Key":"g"}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"/"}
|
||||
{"Key":"d"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"b\nb\nˇ","mode":"Normal"}}
|
||||
{"Key":"u"}
|
||||
{"Get":{"state":"ˇa\nb\na\nb\na\n","mode":"Normal"}}
|
||||
{"Key":":"}
|
||||
{"Key":"v"}
|
||||
{"Key":"/"}
|
||||
{"Key":"a"}
|
||||
{"Key":"/"}
|
||||
{"Key":"d"}
|
||||
{"Key":"enter"}
|
||||
{"Get":{"state":"a\na\nˇa","mode":"Normal"}}
|
Loading…
Reference in a new issue