Z 1200/replace in buffer (#2922)

This is still WIP, mostly pending styling. I added a pretty rudimentary
text field and no buttons whatsoever other than that. I am targeting a
Preview of 09.13, as I am gonna be on PTO for the next week.

I dislike the current implementation slightly because of `regex`'s crate
syntax and lack of support of backreferences. What strikes me as odd wrt
to syntax is that it will just replace a capture name with empty string
if that capture is missing from the regex. While this is perfectly fine
behaviour for conditionally-matched capture groups (e.g. `(foo)?`), I
think it should still error out if there's no group with a given name
(conditional or not).
Release Notes:

- Added "Replace" functionality to buffer search.
This commit is contained in:
Piotr Osiewicz 2023-09-12 18:46:54 +02:00 committed by GitHub
parent c545788168
commit 4cb8647702
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 471 additions and 96 deletions

View file

@ -2,19 +2,16 @@ use crate::{
history::SearchHistory,
mode::{next_mode, SearchMode, Side},
search_bar::{render_nav_button, render_search_mode_button},
CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectAllMatches,
SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleReplace,
ToggleWholeWord,
};
use collections::HashMap;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{
actions,
elements::*,
impl_actions,
platform::{CursorStyle, MouseButton},
Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
WindowContext,
actions, elements::*, impl_actions, Action, AnyViewHandle, AppContext, Entity, Subscription,
Task, View, ViewContext, ViewHandle, WindowContext,
};
use project::search::SearchQuery;
use serde::Deserialize;
@ -54,6 +51,11 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::previous_history_query);
cx.add_action(BufferSearchBar::cycle_mode);
cx.add_action(BufferSearchBar::cycle_mode_on_pane);
cx.add_action(BufferSearchBar::replace_all);
cx.add_action(BufferSearchBar::replace_next);
cx.add_action(BufferSearchBar::replace_all_on_pane);
cx.add_action(BufferSearchBar::replace_next_on_pane);
cx.add_action(BufferSearchBar::toggle_replace);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
}
@ -73,9 +75,11 @@ fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContex
pub struct BufferSearchBar {
query_editor: ViewHandle<Editor>,
replacement_editor: ViewHandle<Editor>,
active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>,
active_searchable_item_subscription: Option<Subscription>,
active_search: Option<Arc<SearchQuery>>,
searchable_items_with_matches:
HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
pending_search: Option<Task<()>>,
@ -85,6 +89,7 @@ pub struct BufferSearchBar {
dismissed: bool,
search_history: SearchHistory,
current_mode: SearchMode,
replace_is_active: bool,
}
impl Entity for BufferSearchBar {
@ -156,6 +161,9 @@ impl View for BufferSearchBar {
self.query_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(new_placeholder_text, cx);
});
self.replacement_editor.update(cx, |editor, cx| {
editor.set_placeholder_text("Replace with...", cx);
});
let search_button_for_mode = |mode, side, cx: &mut ViewContext<BufferSearchBar>| {
let is_active = self.current_mode == mode;
@ -212,7 +220,6 @@ impl View for BufferSearchBar {
cx,
)
};
let query_column = Flex::row()
.with_child(
Svg::for_style(theme.search.editor_icon.clone().icon)
@ -243,7 +250,57 @@ impl View for BufferSearchBar {
.with_max_width(theme.search.editor.max_width)
.with_height(theme.search.search_bar_row_height)
.flex(1., false);
let should_show_replace_input = self.replace_is_active && supported_options.replacement;
let replacement = should_show_replace_input.then(|| {
Flex::row()
.with_child(
Svg::for_style(theme.search.replace_icon.clone().icon)
.contained()
.with_style(theme.search.replace_icon.clone().container),
)
.with_child(ChildView::new(&self.replacement_editor, cx).flex(1., true))
.align_children_center()
.flex(1., true)
.contained()
.with_style(query_container_style)
.constrained()
.with_min_width(theme.search.editor.min_width)
.with_max_width(theme.search.editor.max_width)
.with_height(theme.search.search_bar_row_height)
.flex(1., false)
});
let replace_all = should_show_replace_input.then(|| {
super::replace_action(
ReplaceAll,
"Replace all",
"icons/replace_all.svg",
theme.tooltip.clone(),
theme.search.action_button.clone(),
)
});
let replace_next = should_show_replace_input.then(|| {
super::replace_action(
ReplaceNext,
"Replace next",
"icons/replace_next.svg",
theme.tooltip.clone(),
theme.search.action_button.clone(),
)
});
let switches_column = supported_options.replacement.then(|| {
Flex::row()
.align_children_center()
.with_child(super::toggle_replace_button(
self.replace_is_active,
theme.tooltip.clone(),
theme.search.option_button_component.clone(),
))
.constrained()
.with_height(theme.search.search_bar_row_height)
.contained()
.with_style(theme.search.option_button_group)
});
let mode_column = Flex::row()
.with_child(search_button_for_mode(
SearchMode::Text,
@ -261,7 +318,10 @@ impl View for BufferSearchBar {
.with_height(theme.search.search_bar_row_height);
let nav_column = Flex::row()
.with_child(self.render_action_button("all", cx))
.align_children_center()
.with_children(replace_next)
.with_children(replace_all)
.with_child(self.render_action_button("icons/select-all.svg", cx))
.with_child(Flex::row().with_children(match_count))
.with_child(nav_button_for_direction("<", Direction::Prev, cx))
.with_child(nav_button_for_direction(">", Direction::Next, cx))
@ -271,6 +331,8 @@ impl View for BufferSearchBar {
Flex::row()
.with_child(query_column)
.with_children(switches_column)
.with_children(replacement)
.with_child(mode_column)
.with_child(nav_column)
.contained()
@ -345,9 +407,18 @@ impl BufferSearchBar {
});
cx.subscribe(&query_editor, Self::on_query_editor_event)
.detach();
let replacement_editor = cx.add_view(|cx| {
Editor::auto_height(
2,
Some(Arc::new(|theme| theme.search.editor.input.clone())),
cx,
)
});
// cx.subscribe(&replacement_editor, Self::on_query_editor_event)
// .detach();
Self {
query_editor,
replacement_editor,
active_searchable_item: None,
active_searchable_item_subscription: None,
active_match_index: None,
@ -359,6 +430,8 @@ impl BufferSearchBar {
dismissed: true,
search_history: SearchHistory::default(),
current_mode: SearchMode::default(),
active_search: None,
replace_is_active: false,
}
}
@ -441,7 +514,9 @@ impl BufferSearchBar {
pub fn query(&self, cx: &WindowContext) -> String {
self.query_editor.read(cx).text(cx)
}
pub fn replacement(&self, cx: &WindowContext) -> String {
self.replacement_editor.read(cx).text(cx)
}
pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
self.active_searchable_item
.as_ref()
@ -477,37 +552,16 @@ impl BufferSearchBar {
) -> AnyElement<Self> {
let tooltip = "Select All Matches";
let tooltip_style = theme::current(cx).tooltip.clone();
let action_type_id = 0_usize;
let has_matches = self.active_match_index.is_some();
let cursor_style = if has_matches {
CursorStyle::PointingHand
} else {
CursorStyle::default()
};
enum ActionButton {}
MouseEventHandler::new::<ActionButton, _>(action_type_id, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.search
.action_button
.in_state(has_matches)
.style_for(state);
Label::new(icon, style.text.clone())
.aligned()
.contained()
.with_style(style.container)
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.select_all_matches(&SelectAllMatches, cx)
})
.with_cursor_style(cursor_style)
.with_tooltip::<ActionButton>(
action_type_id,
tooltip.to_string(),
Some(Box::new(SelectAllMatches)),
tooltip_style,
cx,
)
let theme = theme::current(cx);
let style = theme.search.action_button.clone();
gpui::elements::Component::element(SafeStylable::with_style(
theme::components::action_button::Button::action(SelectAllMatches)
.with_tooltip(tooltip, tooltip_style)
.with_contents(theme::components::svg::Svg::new(icon)),
style,
))
.into_any()
}
@ -688,6 +742,7 @@ impl BufferSearchBar {
let (done_tx, done_rx) = oneshot::channel();
let query = self.query(cx);
self.pending_search.take();
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
if query.is_empty() {
self.active_match_index.take();
@ -695,7 +750,7 @@ impl BufferSearchBar {
let _ = done_tx.send(());
cx.notify();
} else {
let query = if self.current_mode == SearchMode::Regex {
let query: Arc<_> = if self.current_mode == SearchMode::Regex {
match SearchQuery::regex(
query,
self.search_options.contains(SearchOptions::WHOLE_WORD),
@ -703,7 +758,8 @@ impl BufferSearchBar {
Vec::new(),
Vec::new(),
) {
Ok(query) => query,
Ok(query) => query
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty())),
Err(_) => {
self.query_contains_error = true;
cx.notify();
@ -718,8 +774,10 @@ impl BufferSearchBar {
Vec::new(),
Vec::new(),
)
};
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty()))
}
.into();
self.active_search = Some(query.clone());
let query_text = query.as_str().to_string();
let matches = active_searchable_item.find_matches(query, cx);
@ -810,6 +868,63 @@ impl BufferSearchBar {
cx.propagate_action();
}
}
fn toggle_replace(&mut self, _: &ToggleReplace, _: &mut ViewContext<Self>) {
if let Some(_) = &self.active_searchable_item {
self.replace_is_active = !self.replace_is_active;
}
}
fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
if !self.dismissed && self.active_search.is_some() {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(query) = self.active_search.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&searchable_item.downgrade())
{
if let Some(active_index) = self.active_match_index {
let query = query.as_ref().clone().with_replacement(
Some(self.replacement(cx)).filter(|rep| !rep.is_empty()),
);
searchable_item.replace(&matches[active_index], &query, cx);
}
self.focus_editor(&FocusEditor, cx);
}
}
}
}
}
fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
if !self.dismissed && self.active_search.is_some() {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(query) = self.active_search.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&searchable_item.downgrade())
{
let query = query.as_ref().clone().with_replacement(
Some(self.replacement(cx)).filter(|rep| !rep.is_empty()),
);
for m in matches {
searchable_item.replace(m, &query, cx);
}
self.focus_editor(&FocusEditor, cx);
}
}
}
}
}
fn replace_next_on_pane(pane: &mut Pane, action: &ReplaceNext, cx: &mut ViewContext<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |bar, cx| bar.replace_next(action, cx));
}
}
fn replace_all_on_pane(pane: &mut Pane, action: &ReplaceAll, cx: &mut ViewContext<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |bar, cx| bar.replace_all(action, cx));
}
}
}
#[cfg(test)]
@ -1539,4 +1654,109 @@ mod tests {
assert_eq!(search_bar.search_options, SearchOptions::NONE);
});
}
#[gpui::test]
async fn test_replace_simple(cx: &mut TestAppContext) {
let (editor, search_bar) = init_test(cx);
search_bar
.update(cx, |search_bar, cx| {
search_bar.search("expression", None, cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
// We use $1 here as initially we should be in Text mode, where `$1` should be treated literally.
editor.set_text("expr$1", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex or regexp;[1] also referred to as
rational expr$1[2][3]) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Search for word boundaries and replace just a single one.
search_bar
.update(cx, |search_bar, cx| {
search_bar.search("or", Some(SearchOptions::WHOLE_WORD), cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("banana", cx);
});
search_bar.replace_next(&ReplaceNext, cx)
});
// Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text.
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;[1] also referred to as
rational expr$1[2][3]) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Let's turn on regex mode.
search_bar
.update(cx, |search_bar, cx| {
search_bar.activate_search_mode(SearchMode::Regex, cx);
search_bar.search("\\[([^\\]]+)\\]", None, cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("${1}number", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;1number also referred to as
rational expr$12number3number) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Now with a whole-word twist.
search_bar
.update(cx, |search_bar, cx| {
search_bar.activate_search_mode(SearchMode::Regex, cx);
search_bar.search("a\\w+s", Some(SearchOptions::WHOLE_WORD), cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("things", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
// The only word affected by this edit should be `algorithms`, even though there's a bunch
// of words in this text that would match this regex if not for WHOLE_WORD.
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;1number also referred to as
rational expr$12number3number) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching things
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
}
}

View file

@ -8,7 +8,9 @@ use gpui::{
pub use mode::SearchMode;
use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView};
use theme::components::{action_button::Button, svg::Svg, ComponentExt, ToggleIconButtonStyle};
use theme::components::{
action_button::Button, svg::Svg, ComponentExt, IconButtonStyle, ToggleIconButtonStyle,
};
pub mod buffer_search;
mod history;
@ -27,6 +29,7 @@ actions!(
CycleMode,
ToggleWholeWord,
ToggleCaseSensitive,
ToggleReplace,
SelectNextMatch,
SelectPrevMatch,
SelectAllMatches,
@ -34,7 +37,9 @@ actions!(
PreviousHistoryQuery,
ActivateTextMode,
ActivateSemanticMode,
ActivateRegexMode
ActivateRegexMode,
ReplaceAll,
ReplaceNext
]
);
@ -98,3 +103,32 @@ impl SearchOptions {
.into_any()
}
}
fn toggle_replace_button<V: View>(
active: bool,
tooltip_style: TooltipStyle,
button_style: ToggleIconButtonStyle,
) -> AnyElement<V> {
Button::dynamic_action(Box::new(ToggleReplace))
.with_tooltip("Toggle replace", tooltip_style)
.with_contents(theme::components::svg::Svg::new("icons/replace.svg"))
.toggleable(active)
.with_style(button_style)
.element()
.into_any()
}
fn replace_action<V: View>(
action: impl Action,
name: &'static str,
icon_path: &'static str,
tooltip_style: TooltipStyle,
button_style: IconButtonStyle,
) -> AnyElement<V> {
Button::dynamic_action(Box::new(action))
.with_tooltip(name, tooltip_style)
.with_contents(theme::components::svg::Svg::new(icon_path))
.with_style(button_style)
.element()
.into_any()
}