vim: Implement /n and /c in :s (#34102)
Closes #23345 Release Notes: - vim: Support /n and /c in :s//
This commit is contained in:
parent
6daf888fdb
commit
4ed206b37c
6 changed files with 263 additions and 81 deletions
|
@ -1607,24 +1607,10 @@ impl SearchableItem for Editor {
|
||||||
let text = self.buffer.read(cx);
|
let text = self.buffer.read(cx);
|
||||||
let text = text.snapshot(cx);
|
let text = text.snapshot(cx);
|
||||||
let mut edits = vec![];
|
let mut edits = vec![];
|
||||||
let mut last_point: Option<Point> = None;
|
|
||||||
|
|
||||||
for m in matches {
|
for m in matches {
|
||||||
let point = m.start.to_point(&text);
|
|
||||||
let text = text.text_for_range(m.clone()).collect::<Vec<_>>();
|
let text = text.text_for_range(m.clone()).collect::<Vec<_>>();
|
||||||
|
|
||||||
// Check if the row for the current match is different from the last
|
|
||||||
// match. If that's not the case and we're still replacing matches
|
|
||||||
// in the same row/line, skip this match if the `one_match_per_line`
|
|
||||||
// option is enabled.
|
|
||||||
if last_point.is_none() {
|
|
||||||
last_point = Some(point);
|
|
||||||
} else if last_point.is_some() && point.row != last_point.unwrap().row {
|
|
||||||
last_point = Some(point);
|
|
||||||
} else if query.one_match_per_line().is_some_and(|enabled| enabled) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let text: Cow<_> = if text.len() == 1 {
|
let text: Cow<_> = if text.len() == 1 {
|
||||||
text.first().cloned().unwrap().into()
|
text.first().cloned().unwrap().into()
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -404,6 +404,9 @@ impl SearchQuery {
|
||||||
let start = line_offset + mat.start();
|
let start = line_offset + mat.start();
|
||||||
let end = line_offset + mat.end();
|
let end = line_offset + mat.end();
|
||||||
matches.push(start..end);
|
matches.push(start..end);
|
||||||
|
if self.one_match_per_line() == Some(true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
line_offset += line.len() + 1;
|
line_offset += line.len() + 1;
|
||||||
|
|
|
@ -939,6 +939,11 @@ impl BufferSearchBar {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
pub fn search(
|
pub fn search(
|
||||||
&mut self,
|
&mut self,
|
||||||
query: &str,
|
query: &str,
|
||||||
|
@ -1092,6 +1097,21 @@ impl BufferSearchBar {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn select_first_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
|
||||||
|
if let Some(matches) = self
|
||||||
|
.searchable_items_with_matches
|
||||||
|
.get(&searchable_item.downgrade())
|
||||||
|
{
|
||||||
|
if matches.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchable_item.update_matches(matches, window, cx);
|
||||||
|
searchable_item.activate_match(0, matches, window, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
pub fn select_last_match(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
|
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
|
||||||
if let Some(matches) = self
|
if let Some(matches) = self
|
||||||
|
|
|
@ -71,11 +71,13 @@ pub struct ReplaceCommand {
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, PartialEq)]
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
pub(crate) struct Replacement {
|
pub struct Replacement {
|
||||||
search: String,
|
search: String,
|
||||||
replacement: String,
|
replacement: String,
|
||||||
should_replace_all: bool,
|
case_sensitive: Option<bool>,
|
||||||
is_case_sensitive: bool,
|
flag_n: bool,
|
||||||
|
flag_g: bool,
|
||||||
|
flag_c: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
|
@ -468,71 +470,89 @@ impl Vim {
|
||||||
result.notify_err(workspace, cx);
|
result.notify_err(workspace, cx);
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
let vim = cx.entity().clone();
|
let Some(search_bar) = pane.update(cx, |pane, cx| {
|
||||||
pane.update(cx, |pane, cx| {
|
pane.toolbar().read(cx).item_of_type::<BufferSearchBar>()
|
||||||
let mut options = SearchOptions::REGEX;
|
}) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let mut options = SearchOptions::REGEX;
|
||||||
|
let search = search_bar.update(cx, |search_bar, cx| {
|
||||||
|
if !search_bar.show(window, cx) {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() else {
|
let search = if replacement.search.is_empty() {
|
||||||
return;
|
search_bar.query(cx)
|
||||||
|
} else {
|
||||||
|
replacement.search
|
||||||
};
|
};
|
||||||
let search = search_bar.update(cx, |search_bar, cx| {
|
|
||||||
if !search_bar.show(window, cx) {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
|
|
||||||
if replacement.is_case_sensitive {
|
if let Some(case) = replacement.case_sensitive {
|
||||||
options.set(SearchOptions::CASE_SENSITIVE, true)
|
options.set(SearchOptions::CASE_SENSITIVE, case)
|
||||||
}
|
} else if search_bar.should_use_smartcase_search(cx) {
|
||||||
let search = if replacement.search.is_empty() {
|
options.set(
|
||||||
search_bar.query(cx)
|
SearchOptions::CASE_SENSITIVE,
|
||||||
} else {
|
search_bar.is_contains_uppercase(&search),
|
||||||
replacement.search
|
);
|
||||||
};
|
} else {
|
||||||
if search_bar.should_use_smartcase_search(cx) {
|
options.set(SearchOptions::CASE_SENSITIVE, false)
|
||||||
options.set(
|
}
|
||||||
SearchOptions::CASE_SENSITIVE,
|
|
||||||
search_bar.is_contains_uppercase(&search),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if !replacement.should_replace_all {
|
if !replacement.flag_g {
|
||||||
options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
|
options.set(SearchOptions::ONE_MATCH_PER_LINE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
search_bar.set_replacement(Some(&replacement.replacement), cx);
|
||||||
|
if replacement.flag_c {
|
||||||
|
search_bar.focus_replace(window, cx);
|
||||||
|
}
|
||||||
|
Some(search_bar.search(&search, Some(options), window, cx))
|
||||||
|
});
|
||||||
|
if replacement.flag_n {
|
||||||
|
self.move_cursor(
|
||||||
|
Motion::StartOfLine {
|
||||||
|
display_lines: false,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let Some(search) = search else { return };
|
||||||
|
let search_bar = search_bar.downgrade();
|
||||||
|
cx.spawn_in(window, async move |vim, cx| {
|
||||||
|
search.await?;
|
||||||
|
search_bar.update_in(cx, |search_bar, window, cx| {
|
||||||
|
if replacement.flag_c {
|
||||||
|
search_bar.select_first_match(window, cx);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
search_bar.select_last_match(window, cx);
|
||||||
|
search_bar.replace_all(&Default::default(), window, cx);
|
||||||
|
editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
|
||||||
|
let _ = search_bar.search(&search_bar.query(cx), None, window, cx);
|
||||||
|
vim.update(cx, |vim, cx| {
|
||||||
|
vim.move_cursor(
|
||||||
|
Motion::StartOfLine {
|
||||||
|
display_lines: false,
|
||||||
|
},
|
||||||
|
None,
|
||||||
|
window,
|
||||||
|
cx,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
|
||||||
search_bar.set_replacement(Some(&replacement.replacement), cx);
|
// Disable the `ONE_MATCH_PER_LINE` search option when finished, as
|
||||||
Some(search_bar.search(&search, Some(options), window, cx))
|
// this is not properly supported outside of vim mode, and
|
||||||
});
|
// not disabling it makes the "Replace All Matches" button
|
||||||
let Some(search) = search else { return };
|
// actually replace only the first match on each line.
|
||||||
let search_bar = search_bar.downgrade();
|
options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
|
||||||
cx.spawn_in(window, async move |_, cx| {
|
search_bar.set_search_options(options, cx);
|
||||||
search.await?;
|
|
||||||
search_bar.update_in(cx, |search_bar, window, cx| {
|
|
||||||
search_bar.select_last_match(window, cx);
|
|
||||||
search_bar.replace_all(&Default::default(), window, cx);
|
|
||||||
editor.update(cx, |editor, cx| editor.clear_search_within_ranges(cx));
|
|
||||||
let _ = search_bar.search(&search_bar.query(cx), None, window, cx);
|
|
||||||
vim.update(cx, |vim, cx| {
|
|
||||||
vim.move_cursor(
|
|
||||||
Motion::StartOfLine {
|
|
||||||
display_lines: false,
|
|
||||||
},
|
|
||||||
None,
|
|
||||||
window,
|
|
||||||
cx,
|
|
||||||
)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Disable the `ONE_MATCH_PER_LINE` search option when finished, as
|
|
||||||
// this is not properly supported outside of vim mode, and
|
|
||||||
// not disabling it makes the "Replace All Matches" button
|
|
||||||
// actually replace only the first match on each line.
|
|
||||||
options.set(SearchOptions::ONE_MATCH_PER_LINE, false);
|
|
||||||
search_bar.set_search_options(options, cx);
|
|
||||||
})?;
|
|
||||||
anyhow::Ok(())
|
|
||||||
})
|
})
|
||||||
.detach_and_log_err(cx);
|
|
||||||
})
|
})
|
||||||
|
.detach_and_log_err(cx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -593,16 +613,19 @@ impl Replacement {
|
||||||
let mut replacement = Replacement {
|
let mut replacement = Replacement {
|
||||||
search,
|
search,
|
||||||
replacement,
|
replacement,
|
||||||
should_replace_all: false,
|
case_sensitive: None,
|
||||||
is_case_sensitive: true,
|
flag_g: false,
|
||||||
|
flag_n: false,
|
||||||
|
flag_c: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
for c in flags.chars() {
|
for c in flags.chars() {
|
||||||
match c {
|
match c {
|
||||||
'g' => replacement.should_replace_all = true,
|
'g' => replacement.flag_g = true,
|
||||||
'c' | 'n' => replacement.should_replace_all = false,
|
'n' => replacement.flag_n = true,
|
||||||
'i' => replacement.is_case_sensitive = false,
|
'c' => replacement.flag_c = true,
|
||||||
'I' => replacement.is_case_sensitive = true,
|
'i' => replacement.case_sensitive = Some(false),
|
||||||
|
'I' => replacement.case_sensitive = Some(true),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -913,7 +936,6 @@ mod test {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// cargo test -p vim --features neovim test_replace_with_range_at_start
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_replace_with_range_at_start(cx: &mut gpui::TestAppContext) {
|
async fn test_replace_with_range_at_start(cx: &mut gpui::TestAppContext) {
|
||||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||||
|
@ -979,6 +1001,121 @@ mod test {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_replace_n(cx: &mut gpui::TestAppContext) {
|
||||||
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||||
|
cx.set_shared_state(indoc! {
|
||||||
|
"ˇaa
|
||||||
|
bb
|
||||||
|
aa"
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
cx.simulate_shared_keystrokes(": s / b b / d d / n").await;
|
||||||
|
cx.simulate_shared_keystrokes("enter").await;
|
||||||
|
|
||||||
|
cx.shared_state().await.assert_eq(indoc! {
|
||||||
|
"ˇaa
|
||||||
|
bb
|
||||||
|
aa"
|
||||||
|
});
|
||||||
|
|
||||||
|
let search_bar = cx.update_workspace(|workspace, _, cx| {
|
||||||
|
workspace.active_pane().update(cx, |pane, cx| {
|
||||||
|
pane.toolbar()
|
||||||
|
.read(cx)
|
||||||
|
.item_of_type::<BufferSearchBar>()
|
||||||
|
.unwrap()
|
||||||
|
})
|
||||||
|
});
|
||||||
|
cx.update_entity(search_bar, |search_bar, _, cx| {
|
||||||
|
assert!(!search_bar.is_dismissed());
|
||||||
|
assert_eq!(search_bar.query(cx), "bb".to_string());
|
||||||
|
assert_eq!(search_bar.replacement(cx), "dd".to_string());
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_replace_g(cx: &mut gpui::TestAppContext) {
|
||||||
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||||
|
cx.set_shared_state(indoc! {
|
||||||
|
"ˇaa aa aa aa
|
||||||
|
aa
|
||||||
|
aa"
|
||||||
|
})
|
||||||
|
.await;
|
||||||
|
|
||||||
|
cx.simulate_shared_keystrokes(": s / a a / b b").await;
|
||||||
|
cx.simulate_shared_keystrokes("enter").await;
|
||||||
|
cx.shared_state().await.assert_eq(indoc! {
|
||||||
|
"ˇbb aa aa aa
|
||||||
|
aa
|
||||||
|
aa"
|
||||||
|
});
|
||||||
|
cx.simulate_shared_keystrokes(": s / a a / b b / g").await;
|
||||||
|
cx.simulate_shared_keystrokes("enter").await;
|
||||||
|
cx.shared_state().await.assert_eq(indoc! {
|
||||||
|
"ˇbb bb bb bb
|
||||||
|
aa
|
||||||
|
aa"
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
#[gpui::test]
|
||||||
|
async fn test_replace_c(cx: &mut gpui::TestAppContext) {
|
||||||
|
let mut cx = VimTestContext::new(cx, true).await;
|
||||||
|
cx.set_state(
|
||||||
|
indoc! {
|
||||||
|
"ˇaa
|
||||||
|
aa
|
||||||
|
aa"
|
||||||
|
},
|
||||||
|
Mode::Normal,
|
||||||
|
);
|
||||||
|
|
||||||
|
cx.simulate_keystrokes("v j : s / a a / d d / c");
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
|
||||||
|
cx.assert_state(
|
||||||
|
indoc! {
|
||||||
|
"ˇaa
|
||||||
|
aa
|
||||||
|
aa"
|
||||||
|
},
|
||||||
|
Mode::Normal,
|
||||||
|
);
|
||||||
|
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
|
||||||
|
cx.assert_state(
|
||||||
|
indoc! {
|
||||||
|
"dd
|
||||||
|
ˇaa
|
||||||
|
aa"
|
||||||
|
},
|
||||||
|
Mode::Normal,
|
||||||
|
);
|
||||||
|
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.assert_state(
|
||||||
|
indoc! {
|
||||||
|
"dd
|
||||||
|
ddˇ
|
||||||
|
aa"
|
||||||
|
},
|
||||||
|
Mode::Normal,
|
||||||
|
);
|
||||||
|
cx.simulate_keystrokes("enter");
|
||||||
|
cx.assert_state(
|
||||||
|
indoc! {
|
||||||
|
"dd
|
||||||
|
ddˇ
|
||||||
|
aa"
|
||||||
|
},
|
||||||
|
Mode::Normal,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[gpui::test]
|
#[gpui::test]
|
||||||
async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
|
async fn test_replace_with_range(cx: &mut gpui::TestAppContext) {
|
||||||
let mut cx = NeovimBackedTestContext::new(cx).await;
|
let mut cx = NeovimBackedTestContext::new(cx).await;
|
||||||
|
|
23
crates/vim/test_data/test_replace_g.json
Normal file
23
crates/vim/test_data/test_replace_g.json
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
{"Put":{"state":"ˇaa aa aa aa\naa\naa"}}
|
||||||
|
{"Key":":"}
|
||||||
|
{"Key":"s"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"a"}
|
||||||
|
{"Key":"a"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"enter"}
|
||||||
|
{"Get":{"state":"ˇbb aa aa aa\naa\naa","mode":"Normal"}}
|
||||||
|
{"Key":":"}
|
||||||
|
{"Key":"s"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"a"}
|
||||||
|
{"Key":"a"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"g"}
|
||||||
|
{"Key":"enter"}
|
||||||
|
{"Get":{"state":"ˇbb bb bb bb\naa\naa","mode":"Normal"}}
|
13
crates/vim/test_data/test_replace_n.json
Normal file
13
crates/vim/test_data/test_replace_n.json
Normal file
|
@ -0,0 +1,13 @@
|
||||||
|
{"Put":{"state":"ˇaa\nbb\naa"}}
|
||||||
|
{"Key":":"}
|
||||||
|
{"Key":"s"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"b"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"d"}
|
||||||
|
{"Key":"d"}
|
||||||
|
{"Key":"/"}
|
||||||
|
{"Key":"n"}
|
||||||
|
{"Key":"enter"}
|
||||||
|
{"Get":{"state":"ˇaa\nbb\naa","mode":"Normal"}}
|
Loading…
Add table
Add a link
Reference in a new issue