Z 1200/replace in buffer (#2922)

Piotr Osiewicz created

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.

Change summary

assets/icons/select-all.svg               |   2 
crates/editor/src/items.rs                |  24 +
crates/feedback/src/feedback_editor.rs    |   9 
crates/language_tools/src/lsp_log.rs      |  16 +
crates/project/src/search.rs              |  37 ++
crates/search/src/buffer_search.rs        | 314 +++++++++++++++++++++---
crates/search/src/search.rs               |  38 ++
crates/terminal_view/src/terminal_view.rs |  17 
crates/theme/src/theme.rs                 |  10 
crates/workspace/src/searchable.rs        |  18 +
styles/src/style_tree/search.ts           |  79 ++++-
11 files changed, 468 insertions(+), 96 deletions(-)

Detailed changes

assets/icons/select-all.svg 🔗

@@ -0,0 +1,5 @@
+<svg width="14" height="14" viewBox="0 0 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M9.5 7V9.5M9.5 12V9.5M12 9.5H9.5M7 9.5H9.5M9.5 9.5L11.1667 7.83333M9.5 9.5L7.83333 11.1667M9.5 9.5L11.1667 11.1667M9.5 9.5L7.83333 7.83333" stroke="#11181C" stroke-width="1.25" stroke-linecap="round"/>

crates/editor/src/items.rs 🔗

@@ -16,7 +16,7 @@ use language::{
     proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point,
     SelectionGoal,
 };
-use project::{FormatTrigger, Item as _, Project, ProjectPath};
+use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
 use rpc::proto::{self, update_view};
 use smallvec::SmallVec;
 use std::{
@@ -26,6 +26,7 @@ use std::{
     iter,
     ops::Range,
     path::{Path, PathBuf},
+    sync::Arc,
 };
 use text::Selection;
 use util::{
@@ -978,7 +979,26 @@ impl SearchableItem for Editor {
         }
         self.change_selections(None, cx, |s| s.select_ranges(ranges));
     }
+    fn replace(
+        &mut self,
+        identifier: &Self::Match,
+        query: &SearchQuery,
+        cx: &mut ViewContext<Self>,
+    ) {
+        let text = self.buffer.read(cx);
+        let text = text.snapshot(cx);
+        let text = text.text_for_range(identifier.clone()).collect::<Vec<_>>();
+        let text: Cow<_> = if text.len() == 1 {
+            text.first().cloned().unwrap().into()
+        } else {
+            let joined_chunks = text.join("");
+            joined_chunks.into()
+        };
 
+        if let Some(replacement) = query.replacement(&text) {
+            self.edit([(identifier.clone(), Arc::from(&*replacement))], cx);
+        }
+    }
     fn match_index_for_direction(
         &mut self,
         matches: &Vec<Range<Anchor>>,
@@ -1030,7 +1050,7 @@ impl SearchableItem for Editor {
 
     fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        query: Arc<project::search::SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Range<Anchor>>> {
         let buffer = self.buffer().read(cx).snapshot(cx);

crates/feedback/src/feedback_editor.rs 🔗

@@ -13,7 +13,7 @@ use gpui::{
 use isahc::Request;
 use language::Buffer;
 use postage::prelude::Stream;
-use project::Project;
+use project::{search::SearchQuery, Project};
 use regex::Regex;
 use serde::Serialize;
 use smallvec::SmallVec;
@@ -418,10 +418,13 @@ impl SearchableItem for FeedbackEditor {
         self.editor
             .update(cx, |e, cx| e.select_matches(matches, cx))
     }
-
+    fn replace(&mut self, matches: &Self::Match, query: &SearchQuery, cx: &mut ViewContext<Self>) {
+        self.editor
+            .update(cx, |e, cx| e.replace(matches, query, cx));
+    }
     fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        query: Arc<project::search::SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Self::Match>> {
         self.editor

crates/language_tools/src/lsp_log.rs 🔗

@@ -13,7 +13,7 @@ use gpui::{
 };
 use language::{Buffer, LanguageServerId, LanguageServerName};
 use lsp::IoKind;
-use project::{Project, Worktree};
+use project::{search::SearchQuery, Project, Worktree};
 use std::{borrow::Cow, sync::Arc};
 use theme::{ui, Theme};
 use workspace::{
@@ -524,12 +524,24 @@ impl SearchableItem for LspLogView {
 
     fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        query: Arc<project::search::SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> gpui::Task<Vec<Self::Match>> {
         self.editor.update(cx, |e, cx| e.find_matches(query, cx))
     }
 
+    fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>) {
+        // Since LSP Log is read-only, it doesn't make sense to support replace operation.
+    }
+    fn supported_options() -> workspace::searchable::SearchOptions {
+        workspace::searchable::SearchOptions {
+            case: true,
+            word: true,
+            regex: true,
+            // LSP log is read-only.
+            replacement: false,
+        }
+    }
     fn active_match_index(
         &mut self,
         matches: Vec<Self::Match>,

crates/project/src/search.rs 🔗

@@ -7,6 +7,7 @@ use language::{char_kind, BufferSnapshot};
 use regex::{Regex, RegexBuilder};
 use smol::future::yield_now;
 use std::{
+    borrow::Cow,
     io::{BufRead, BufReader, Read},
     ops::Range,
     path::{Path, PathBuf},
@@ -35,6 +36,7 @@ impl SearchInputs {
 pub enum SearchQuery {
     Text {
         search: Arc<AhoCorasick<usize>>,
+        replacement: Option<String>,
         whole_word: bool,
         case_sensitive: bool,
         inner: SearchInputs,
@@ -42,7 +44,7 @@ pub enum SearchQuery {
 
     Regex {
         regex: Regex,
-
+        replacement: Option<String>,
         multiline: bool,
         whole_word: bool,
         case_sensitive: bool,
@@ -95,6 +97,7 @@ impl SearchQuery {
         };
         Self::Text {
             search: Arc::new(search),
+            replacement: None,
             whole_word,
             case_sensitive,
             inner,
@@ -130,6 +133,7 @@ impl SearchQuery {
         };
         Ok(Self::Regex {
             regex,
+            replacement: None,
             multiline,
             whole_word,
             case_sensitive,
@@ -156,7 +160,21 @@ impl SearchQuery {
             ))
         }
     }
-
+    pub fn with_replacement(mut self, new_replacement: Option<String>) -> Self {
+        match self {
+            Self::Text {
+                ref mut replacement,
+                ..
+            }
+            | Self::Regex {
+                ref mut replacement,
+                ..
+            } => {
+                *replacement = new_replacement;
+                self
+            }
+        }
+    }
     pub fn to_proto(&self, project_id: u64) -> proto::SearchProject {
         proto::SearchProject {
             project_id,
@@ -214,7 +232,20 @@ impl SearchQuery {
             }
         }
     }
-
+    pub fn replacement<'a>(&self, text: &'a str) -> Option<Cow<'a, str>> {
+        match self {
+            SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from),
+            SearchQuery::Regex {
+                regex, replacement, ..
+            } => {
+                if let Some(replacement) = replacement {
+                    Some(regex.replace(text, replacement))
+                } else {
+                    None
+                }
+            }
+        }
+    }
     pub async fn search(
         &self,
         buffer: &BufferSnapshot,

crates/search/src/buffer_search.rs 🔗

@@ -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()
+        );
+    }
 }

crates/search/src/search.rs 🔗

@@ -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()
+}

crates/terminal_view/src/terminal_view.rs 🔗

@@ -18,7 +18,7 @@ use gpui::{
     ViewHandle, WeakViewHandle,
 };
 use language::Bias;
-use project::{LocalWorktree, Project};
+use project::{search::SearchQuery, LocalWorktree, Project};
 use serde::Deserialize;
 use smallvec::{smallvec, SmallVec};
 use smol::Timer;
@@ -26,6 +26,7 @@ use std::{
     borrow::Cow,
     ops::RangeInclusive,
     path::{Path, PathBuf},
+    sync::Arc,
     time::Duration,
 };
 use terminal::{
@@ -380,10 +381,10 @@ impl TerminalView {
 
     pub fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        query: Arc<project::search::SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<RangeInclusive<Point>>> {
-        let searcher = regex_search_for_query(query);
+        let searcher = regex_search_for_query(&query);
 
         if let Some(searcher) = searcher {
             self.terminal
@@ -486,7 +487,7 @@ fn possible_open_targets(
         .collect()
 }
 
-pub fn regex_search_for_query(query: project::search::SearchQuery) -> Option<RegexSearch> {
+pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option<RegexSearch> {
     let query = query.as_str();
     let searcher = RegexSearch::new(&query);
     searcher.ok()
@@ -798,6 +799,7 @@ impl SearchableItem for TerminalView {
             case: false,
             word: false,
             regex: false,
+            replacement: false,
         }
     }
 
@@ -851,10 +853,10 @@ impl SearchableItem for TerminalView {
     /// Get all of the matches for this query, should be done on the background
     fn find_matches(
         &mut self,
-        query: project::search::SearchQuery,
+        query: Arc<project::search::SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Self::Match>> {
-        if let Some(searcher) = regex_search_for_query(query) {
+        if let Some(searcher) = regex_search_for_query(&query) {
             self.terminal()
                 .update(cx, |term, cx| term.find_matches(searcher, cx))
         } else {
@@ -898,6 +900,9 @@ impl SearchableItem for TerminalView {
 
         res
     }
+    fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>) {
+        // Replacement is not supported in terminal view, so this is a no-op.
+    }
 }
 
 ///Get's the working directory for the given workspace, respecting the user's settings.

crates/theme/src/theme.rs 🔗

@@ -3,7 +3,9 @@ mod theme_registry;
 mod theme_settings;
 pub mod ui;
 
-use components::{action_button::ButtonStyle, disclosure::DisclosureStyle, ToggleIconButtonStyle};
+use components::{
+    action_button::ButtonStyle, disclosure::DisclosureStyle, IconButtonStyle, ToggleIconButtonStyle,
+};
 use gpui::{
     color::Color,
     elements::{Border, ContainerStyle, ImageStyle, LabelStyle, Shadow, SvgStyle, TooltipStyle},
@@ -439,9 +441,7 @@ pub struct Search {
     pub include_exclude_editor: FindEditor,
     pub invalid_include_exclude_editor: ContainerStyle,
     pub include_exclude_inputs: ContainedText,
-    pub option_button: Toggleable<Interactive<IconButton>>,
     pub option_button_component: ToggleIconButtonStyle,
-    pub action_button: Toggleable<Interactive<ContainedText>>,
     pub match_background: Color,
     pub match_index: ContainedText,
     pub major_results_status: TextStyle,
@@ -453,6 +453,10 @@ pub struct Search {
     pub search_row_spacing: f32,
     pub option_button_height: f32,
     pub modes_container: ContainerStyle,
+    pub replace_icon: IconStyle,
+    // Used for filters and replace
+    pub option_button: Toggleable<Interactive<IconButton>>,
+    pub action_button: IconButtonStyle,
 }
 
 #[derive(Clone, Deserialize, Default, JsonSchema)]

crates/workspace/src/searchable.rs 🔗

@@ -1,4 +1,4 @@
-use std::any::Any;
+use std::{any::Any, sync::Arc};
 
 use gpui::{
     AnyViewHandle, AnyWeakViewHandle, AppContext, Subscription, Task, ViewContext, ViewHandle,
@@ -25,6 +25,8 @@ pub struct SearchOptions {
     pub case: bool,
     pub word: bool,
     pub regex: bool,
+    /// Specifies whether the item supports search & replace.
+    pub replacement: bool,
 }
 
 pub trait SearchableItem: Item {
@@ -35,6 +37,7 @@ pub trait SearchableItem: Item {
             case: true,
             word: true,
             regex: true,
+            replacement: true,
         }
     }
     fn to_search_event(
@@ -52,6 +55,7 @@ pub trait SearchableItem: Item {
         cx: &mut ViewContext<Self>,
     );
     fn select_matches(&mut self, matches: Vec<Self::Match>, cx: &mut ViewContext<Self>);
+    fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>);
     fn match_index_for_direction(
         &mut self,
         matches: &Vec<Self::Match>,
@@ -74,7 +78,7 @@ pub trait SearchableItem: Item {
     }
     fn find_matches(
         &mut self,
-        query: SearchQuery,
+        query: Arc<SearchQuery>,
         cx: &mut ViewContext<Self>,
     ) -> Task<Vec<Self::Match>>;
     fn active_match_index(
@@ -103,6 +107,7 @@ pub trait SearchableItemHandle: ItemHandle {
         cx: &mut WindowContext,
     );
     fn select_matches(&self, matches: &Vec<Box<dyn Any + Send>>, cx: &mut WindowContext);
+    fn replace(&self, _: &Box<dyn Any + Send>, _: &SearchQuery, _: &mut WindowContext);
     fn match_index_for_direction(
         &self,
         matches: &Vec<Box<dyn Any + Send>>,
@@ -113,7 +118,7 @@ pub trait SearchableItemHandle: ItemHandle {
     ) -> usize;
     fn find_matches(
         &self,
-        query: SearchQuery,
+        query: Arc<SearchQuery>,
         cx: &mut WindowContext,
     ) -> Task<Vec<Box<dyn Any + Send>>>;
     fn active_match_index(
@@ -189,7 +194,7 @@ impl<T: SearchableItem> SearchableItemHandle for ViewHandle<T> {
     }
     fn find_matches(
         &self,
-        query: SearchQuery,
+        query: Arc<SearchQuery>,
         cx: &mut WindowContext,
     ) -> Task<Vec<Box<dyn Any + Send>>> {
         let matches = self.update(cx, |this, cx| this.find_matches(query, cx));
@@ -209,6 +214,11 @@ impl<T: SearchableItem> SearchableItemHandle for ViewHandle<T> {
         let matches = downcast_matches(matches);
         self.update(cx, |this, cx| this.active_match_index(matches, cx))
     }
+
+    fn replace(&self, matches: &Box<dyn Any + Send>, query: &SearchQuery, cx: &mut WindowContext) {
+        let matches = matches.downcast_ref().unwrap();
+        self.update(cx, |this, cx| this.replace(matches, query, cx))
+    }
 }
 
 fn downcast_matches<T: Any + Clone>(matches: &Vec<Box<dyn Any + Send>>) -> Vec<T> {

styles/src/style_tree/search.ts 🔗

@@ -30,9 +30,6 @@ export default function search(): any {
         selection: theme.players[0],
         text: text(theme.highest, "mono", "default"),
         border: border(theme.highest),
-        margin: {
-            right: SEARCH_ROW_SPACING,
-        },
         padding: {
             top: 4,
             bottom: 4,
@@ -125,7 +122,7 @@ export default function search(): any {
 
                     button_width: 32,
                     background: background(theme.highest, "on"),
-                    corner_radius: 2,
+                    corner_radius: 6,
                     margin: { right: 2 },
                     border: {
                         width: 1,
@@ -185,26 +182,6 @@ export default function search(): any {
                 },
             },
         }),
-        // Search tool buttons
-        // HACK: This is not how disabled elements should be created
-        // Disabled elements should use a disabled state of an interactive element, not a toggleable element with the inactive state being disabled
-        action_button: toggleable({
-            state: {
-                inactive: text_button({
-                    variant: "ghost",
-                    layer: theme.highest,
-                    disabled: true,
-                    margin: { right: SEARCH_ROW_SPACING },
-                    text_properties: { size: "sm" },
-                }),
-                active: text_button({
-                    variant: "ghost",
-                    layer: theme.highest,
-                    margin: { right: SEARCH_ROW_SPACING },
-                    text_properties: { size: "sm" },
-                }),
-            },
-        }),
         editor,
         invalid_editor: {
             ...editor,
@@ -218,6 +195,7 @@ export default function search(): any {
         match_index: {
             ...text(theme.highest, "mono", { size: "sm" }),
             padding: {
+                left: SEARCH_ROW_SPACING,
                 right: SEARCH_ROW_SPACING,
             },
         },
@@ -398,6 +376,59 @@ export default function search(): any {
         search_row_spacing: 8,
         option_button_height: 22,
         modes_container: {},
+        replace_icon: {
+            icon: {
+                color: foreground(theme.highest, "disabled"),
+                asset: "icons/replace.svg",
+                dimensions: {
+                    width: 14,
+                    height: 14,
+                },
+            },
+            container: {
+                margin: { right: 4 },
+                padding: { left: 1, right: 1 },
+            },
+        },
+        action_button: interactive({
+            base: {
+                icon_size: 14,
+                color: foreground(theme.highest, "variant"),
+
+                button_width: 32,
+                background: background(theme.highest, "on"),
+                corner_radius: 6,
+                margin: { right: 2 },
+                border: {
+                    width: 1,
+                    color: background(theme.highest, "on"),
+                },
+                padding: {
+                    left: 4,
+                    right: 4,
+                    top: 4,
+                    bottom: 4,
+                },
+            },
+            state: {
+                hovered: {
+                    ...text(theme.highest, "mono", "variant", "hovered"),
+                    background: background(theme.highest, "on", "hovered"),
+                    border: {
+                        width: 1,
+                        color: background(theme.highest, "on", "hovered"),
+                    },
+                },
+                clicked: {
+                    ...text(theme.highest, "mono", "variant", "pressed"),
+                    background: background(theme.highest, "on", "pressed"),
+                    border: {
+                        width: 1,
+                        color: background(theme.highest, "on", "pressed"),
+                    },
+                },
+            },
+        }),
         ...search_results(),
     }
 }