Merge branch 'main' into chat-again

Max Brunsfeld created

Change summary

README.md                                      |  10 
assets/icons/select-all.svg                    |   2 
crates/editor/src/items.rs                     |  24 +
crates/feedback/src/feedback_editor.rs         |   9 
crates/gpui2_macros/src/styleable_helpers.rs   | 163 +++++-----
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/storybook/src/components.rs             |   6 
crates/storybook/src/components/icon_button.rs |  50 +++
crates/storybook/src/components/tab.rs         |  55 +++
crates/storybook/src/modules.rs                |   3 
crates/storybook/src/modules/tab_bar.rs        |  82 +++++
crates/storybook/src/storybook.rs              |   5 
crates/storybook/src/workspace.rs              |  17 
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 +++-
20 files changed, 766 insertions(+), 189 deletions(-)

Detailed changes

README.md 🔗

@@ -12,16 +12,12 @@ Welcome to Zed, a lightning-fast, collaborative code editor that makes your drea
   ```
   sudo xcodebuild -license
   ```
-
-* Install rustup (rust, cargo, etc.)
-  ```
-  curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
-  ```
   
-* Install homebrew and node
+* Install homebrew, node and rustup-init (rutup, rust, cargo, etc.)
   ```
   /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
-  brew install node
+  brew install node rustup-init
+  rustup-init # follow the installation steps
   ```
   
 * Install postgres and configure the database

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/gpui2_macros/src/styleable_helpers.rs 🔗

@@ -28,24 +28,24 @@ fn generate_methods() -> Vec<TokenStream2> {
     let mut methods = Vec::new();
 
     for (prefix, auto_allowed, fields) in box_prefixes() {
-        for (suffix, length_tokens) in box_suffixes() {
+        for (suffix, length_tokens, doc_string) in box_suffixes() {
             if auto_allowed || suffix != "auto" {
-                let method = generate_method(prefix, suffix, &fields, length_tokens);
+                let method = generate_method(prefix, suffix, &fields, length_tokens, doc_string);
                 methods.push(method);
             }
         }
     }
 
     for (prefix, fields) in corner_prefixes() {
-        for (suffix, radius_tokens) in corner_suffixes() {
-            let method = generate_method(prefix, suffix, &fields, radius_tokens);
+        for (suffix, radius_tokens, doc_string) in corner_suffixes() {
+            let method = generate_method(prefix, suffix, &fields, radius_tokens, doc_string);
             methods.push(method);
         }
     }
 
     for (prefix, fields) in border_prefixes() {
-        for (suffix, width_tokens) in border_suffixes() {
-            let method = generate_method(prefix, suffix, &fields, width_tokens);
+        for (suffix, width_tokens, doc_string) in border_suffixes() {
+            let method = generate_method(prefix, suffix, &fields, width_tokens, doc_string);
             methods.push(method);
         }
     }
@@ -58,6 +58,7 @@ fn generate_method(
     suffix: &'static str,
     fields: &Vec<TokenStream2>,
     length_tokens: TokenStream2,
+    doc_string: &'static str,
 ) -> TokenStream2 {
     let method_name = if suffix.is_empty() {
         format_ident!("{}", prefix)
@@ -75,6 +76,7 @@ fn generate_method(
         .collect::<Vec<_>>();
 
     let method = quote! {
+        #[doc = #doc_string]
         fn #method_name(mut self) -> Self where Self: std::marker::Sized {
             let mut style = self.declared_style();
             #(#field_assignments)*
@@ -160,55 +162,52 @@ fn box_prefixes() -> Vec<(&'static str, bool, Vec<TokenStream2>)> {
     ]
 }
 
-fn box_suffixes() -> Vec<(&'static str, TokenStream2)> {
+fn box_suffixes() -> Vec<(&'static str, TokenStream2, &'static str)> {
     vec![
-        ("0", quote! { pixels(0.) }),
-        ("0p5", quote! { rems(0.125) }),
-        ("1", quote! { rems(0.25) }),
-        ("1p5", quote! { rems(0.375) }),
-        ("2", quote! { rems(0.5) }),
-        ("2p5", quote! { rems(0.625) }),
-        ("3", quote! { rems(0.75) }),
-        ("3p5", quote! { rems(0.875) }),
-        ("4", quote! { rems(1.) }),
-        ("5", quote! { rems(1.25) }),
-        ("6", quote! { rems(1.5) }),
-        ("7", quote! { rems(1.75) }),
-        ("8", quote! { rems(2.0) }),
-        ("9", quote! { rems(2.25) }),
-        ("10", quote! { rems(2.5) }),
-        ("11", quote! { rems(2.75) }),
-        ("12", quote! { rems(3.) }),
-        ("16", quote! { rems(4.) }),
-        ("20", quote! { rems(5.) }),
-        ("24", quote! { rems(6.) }),
-        ("32", quote! { rems(8.) }),
-        ("40", quote! { rems(10.) }),
-        ("48", quote! { rems(12.) }),
-        ("56", quote! { rems(14.) }),
-        ("64", quote! { rems(16.) }),
-        ("72", quote! { rems(18.) }),
-        ("80", quote! { rems(20.) }),
-        ("96", quote! { rems(24.) }),
-        ("auto", quote! { auto() }),
-        ("px", quote! { pixels(1.) }),
-        ("full", quote! { relative(1.) }),
-        ("1_2", quote! { relative(0.5) }),
-        ("1_3", quote! { relative(1./3.) }),
-        ("2_3", quote! { relative(2./3.) }),
-        ("1_4", quote! { relative(0.25) }),
-        ("2_4", quote! { relative(0.5) }),
-        ("3_4", quote! { relative(0.75) }),
-        ("1_5", quote! { relative(0.2) }),
-        ("2_5", quote! { relative(0.4) }),
-        ("3_5", quote! { relative(0.6) }),
-        ("4_5", quote! { relative(0.8) }),
-        ("1_6", quote! { relative(1./6.) }),
-        ("5_6", quote! { relative(5./6.) }),
-        ("1_12", quote! { relative(1./12.) }),
-        // ("screen_50", quote! { DefiniteLength::Vh(50.0) }),
-        // ("screen_75", quote! { DefiniteLength::Vh(75.0) }),
-        // ("screen", quote! { DefiniteLength::Vh(100.0) }),
+        ("0", quote! { pixels(0.) }, "0px"),
+        ("0p5", quote! { rems(0.125) }, "2px (0.125rem)"),
+        ("1", quote! { rems(0.25) }, "4px (0.25rem)"),
+        ("1p5", quote! { rems(0.375) }, "6px (0.375rem)"),
+        ("2", quote! { rems(0.5) }, "8px (0.5rem)"),
+        ("2p5", quote! { rems(0.625) }, "10px (0.625rem)"),
+        ("3", quote! { rems(0.75) }, "12px (0.75rem)"),
+        ("3p5", quote! { rems(0.875) }, "14px (0.875rem)"),
+        ("4", quote! { rems(1.) }, "16px (1rem)"),
+        ("5", quote! { rems(1.25) }, "20px (1.25rem)"),
+        ("6", quote! { rems(1.5) }, "24px (1.5rem)"),
+        ("7", quote! { rems(1.75) }, "28px (1.75rem)"),
+        ("8", quote! { rems(2.0) }, "32px (2rem)"),
+        ("9", quote! { rems(2.25) }, "36px (2.25rem)"),
+        ("10", quote! { rems(2.5) }, "40px (2.5rem)"),
+        ("11", quote! { rems(2.75) }, "44px (2.75rem)"),
+        ("12", quote! { rems(3.) }, "48px (3rem)"),
+        ("16", quote! { rems(4.) }, "64px (4rem)"),
+        ("20", quote! { rems(5.) }, "80px (5rem)"),
+        ("24", quote! { rems(6.) }, "96px (6rem)"),
+        ("32", quote! { rems(8.) }, "128px (8rem)"),
+        ("40", quote! { rems(10.) }, "160px (10rem)"),
+        ("48", quote! { rems(12.) }, "192px (12rem)"),
+        ("56", quote! { rems(14.) }, "224px (14rem)"),
+        ("64", quote! { rems(16.) }, "256px (16rem)"),
+        ("72", quote! { rems(18.) }, "288px (18rem)"),
+        ("80", quote! { rems(20.) }, "320px (20rem)"),
+        ("96", quote! { rems(24.) }, "384px (24rem)"),
+        ("auto", quote! { auto() }, "Auto"),
+        ("px", quote! { pixels(1.) }, "1px"),
+        ("full", quote! { relative(1.) }, "100%"),
+        ("1_2", quote! { relative(0.5) }, "50% (1/2)"),
+        ("1_3", quote! { relative(1./3.) }, "33% (1/3)"),
+        ("2_3", quote! { relative(2./3.) }, "66% (2/3)"),
+        ("1_4", quote! { relative(0.25) }, "25% (1/4)"),
+        ("2_4", quote! { relative(0.5) }, "50% (2/4)"),
+        ("3_4", quote! { relative(0.75) }, "75% (3/4)"),
+        ("1_5", quote! { relative(0.2) }, "20% (1/5)"),
+        ("2_5", quote! { relative(0.4) }, "40% (2/5)"),
+        ("3_5", quote! { relative(0.6) }, "60% (3/5)"),
+        ("4_5", quote! { relative(0.8) }, "80% (4/5)"),
+        ("1_6", quote! { relative(1./6.) }, "16% (1/6)"),
+        ("5_6", quote! { relative(5./6.) }, "80% (5/6)"),
+        ("1_12", quote! { relative(1./12.) }, "8% (1/12)"),
     ]
 }
 
@@ -258,16 +257,16 @@ fn corner_prefixes() -> Vec<(&'static str, Vec<TokenStream2>)> {
     ]
 }
 
-fn corner_suffixes() -> Vec<(&'static str, TokenStream2)> {
+fn corner_suffixes() -> Vec<(&'static str, TokenStream2, &'static str)> {
     vec![
-        ("none", quote! { pixels(0.) }),
-        ("sm", quote! { rems(0.125) }),
-        ("md", quote! { rems(0.25) }),
-        ("lg", quote! { rems(0.5) }),
-        ("xl", quote! { rems(0.75) }),
-        ("2xl", quote! { rems(1.) }),
-        ("3xl", quote! { rems(1.5) }),
-        ("full", quote! {  pixels(9999.) }),
+        ("none", quote! { pixels(0.) }, "0px"),
+        ("sm", quote! { rems(0.125) }, "2px (0.125rem)"),
+        ("md", quote! { rems(0.25) }, "4px (0.25rem)"),
+        ("lg", quote! { rems(0.5) }, "8px (0.5rem)"),
+        ("xl", quote! { rems(0.75) }, "12px (0.75rem)"),
+        ("2xl", quote! { rems(1.) }, "16px (1rem)"),
+        ("3xl", quote! { rems(1.5) }, "24px (1.5rem)"),
+        ("full", quote! {  pixels(9999.) }, "9999px"),
     ]
 }
 
@@ -303,25 +302,25 @@ fn border_prefixes() -> Vec<(&'static str, Vec<TokenStream2>)> {
     ]
 }
 
-fn border_suffixes() -> Vec<(&'static str, TokenStream2)> {
+fn border_suffixes() -> Vec<(&'static str, TokenStream2, &'static str)> {
     vec![
-        ("", quote! { pixels(1.) }),
-        ("0", quote! { pixels(0.) }),
-        ("1", quote! { pixels(1.) }),
-        ("2", quote! { pixels(2.) }),
-        ("3", quote! { pixels(3.) }),
-        ("4", quote! { pixels(4.) }),
-        ("5", quote! { pixels(5.) }),
-        ("6", quote! { pixels(6.) }),
-        ("7", quote! { pixels(7.) }),
-        ("8", quote! { pixels(8.) }),
-        ("9", quote! { pixels(9.) }),
-        ("10", quote! { pixels(10.) }),
-        ("11", quote! { pixels(11.) }),
-        ("12", quote! { pixels(12.) }),
-        ("16", quote! { pixels(16.) }),
-        ("20", quote! { pixels(20.) }),
-        ("24", quote! { pixels(24.) }),
-        ("32", quote! { pixels(32.) }),
+        ("", quote! { pixels(1.)}, "1px"),
+        ("0", quote! { pixels(0.)}, "0px"),
+        ("1", quote! { pixels(1.) }, "1px"),
+        ("2", quote! { pixels(2.) }, "2px"),
+        ("3", quote! { pixels(3.) }, "3px"),
+        ("4", quote! { pixels(4.) }, "4px"),
+        ("5", quote! { pixels(5.) }, "5px"),
+        ("6", quote! { pixels(6.) }, "6px"),
+        ("7", quote! { pixels(7.) }, "7px"),
+        ("8", quote! { pixels(8.) }, "8px"),
+        ("9", quote! { pixels(9.) }, "9px"),
+        ("10", quote! { pixels(10.) }, "10px"),
+        ("11", quote! { pixels(11.) }, "11px"),
+        ("12", quote! { pixels(12.) }, "12px"),
+        ("16", quote! { pixels(16.) }, "16px"),
+        ("20", quote! { pixels(20.) }, "20px"),
+        ("24", quote! { pixels(24.) }, "24px"),
+        ("32", quote! { pixels(32.) }, "32px"),
     ]
 }

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/storybook/src/components.rs 🔗

@@ -4,6 +4,12 @@ use gpui2::{
 };
 use std::{marker::PhantomData, rc::Rc};
 
+mod icon_button;
+mod tab;
+
+pub(crate) use icon_button::{icon_button, ButtonVariant};
+pub(crate) use tab::tab;
+
 struct ButtonHandlers<V, D> {
     click: Option<Rc<dyn Fn(&mut V, &D, &mut EventContext<V>)>>,
 }

crates/storybook/src/components/icon_button.rs 🔗

@@ -0,0 +1,50 @@
+use crate::theme::theme;
+use gpui2::elements::svg;
+use gpui2::style::{StyleHelpers, Styleable};
+use gpui2::{elements::div, IntoElement};
+use gpui2::{Element, ParentElement, ViewContext};
+
+#[derive(Element)]
+pub(crate) struct IconButton {
+    path: &'static str,
+    variant: ButtonVariant,
+}
+
+#[derive(PartialEq)]
+pub enum ButtonVariant {
+    Ghost,
+    Filled,
+}
+
+pub fn icon_button<V: 'static>(path: &'static str, variant: ButtonVariant) -> impl Element<V> {
+    IconButton { path, variant }
+}
+
+impl IconButton {
+    fn render<V: 'static>(&mut self, _: &mut V, cx: &mut ViewContext<V>) -> impl IntoElement<V> {
+        let theme = theme(cx);
+
+        let mut div = div();
+        if self.variant == ButtonVariant::Filled {
+            div = div.fill(theme.highest.on.default.background);
+        }
+
+        div.w_7()
+            .h_6()
+            .flex()
+            .items_center()
+            .justify_center()
+            .rounded_md()
+            .hover()
+            .fill(theme.highest.base.hovered.background)
+            .active()
+            .fill(theme.highest.base.pressed.background)
+            .child(
+                svg()
+                    .path(self.path)
+                    .w_4()
+                    .h_4()
+                    .fill(theme.highest.variant.default.foreground),
+            )
+    }
+}

crates/storybook/src/components/tab.rs 🔗

@@ -0,0 +1,55 @@
+use crate::theme::theme;
+use gpui2::style::{StyleHelpers, Styleable};
+use gpui2::{elements::div, IntoElement};
+use gpui2::{Element, ParentElement, ViewContext};
+
+#[derive(Element)]
+pub(crate) struct Tab {
+    title: &'static str,
+    enabled: bool,
+}
+
+pub fn tab<V: 'static>(title: &'static str, enabled: bool) -> impl Element<V> {
+    Tab { title, enabled }
+}
+
+impl Tab {
+    fn render<V: 'static>(&mut self, _: &mut V, cx: &mut ViewContext<V>) -> impl IntoElement<V> {
+        let theme = theme(cx);
+
+        div()
+            .px_2()
+            .py_0p5()
+            .flex()
+            .items_center()
+            .justify_center()
+            .rounded_lg()
+            .fill(if self.enabled {
+                theme.highest.on.default.background
+            } else {
+                theme.highest.base.default.background
+            })
+            .hover()
+            .fill(if self.enabled {
+                theme.highest.on.hovered.background
+            } else {
+                theme.highest.base.hovered.background
+            })
+            .active()
+            .fill(if self.enabled {
+                theme.highest.on.pressed.background
+            } else {
+                theme.highest.base.pressed.background
+            })
+            .child(
+                div()
+                    .text_sm()
+                    .text_color(if self.enabled {
+                        theme.highest.base.default.foreground
+                    } else {
+                        theme.highest.variant.default.foreground
+                    })
+                    .child(self.title),
+            )
+    }
+}

crates/storybook/src/modules/tab_bar.rs 🔗

@@ -0,0 +1,82 @@
+use std::marker::PhantomData;
+
+use crate::components::{icon_button, tab, ButtonVariant};
+use crate::theme::theme;
+use gpui2::elements::div::ScrollState;
+use gpui2::style::StyleHelpers;
+use gpui2::{elements::div, IntoElement};
+use gpui2::{Element, ParentElement, ViewContext};
+
+#[derive(Element)]
+pub struct TabBar<V: 'static> {
+    view_type: PhantomData<V>,
+    scroll_state: ScrollState,
+}
+
+pub fn tab_bar<V: 'static>(scroll_state: ScrollState) -> TabBar<V> {
+    TabBar {
+        view_type: PhantomData,
+        scroll_state,
+    }
+}
+
+impl<V: 'static> TabBar<V> {
+    fn render(&mut self, _: &mut V, cx: &mut ViewContext<V>) -> impl IntoElement<V> {
+        let theme = theme(cx);
+
+        div()
+            .w_full()
+            .flex()
+            // Left Side
+            .child(
+                div()
+                    .px_1()
+                    .flex()
+                    .flex_none()
+                    .gap_2()
+                    // Nav Buttons
+                    .child(
+                        div()
+                            .flex()
+                            .items_center()
+                            .gap_px()
+                            .child(icon_button("icons/arrow_left.svg", ButtonVariant::Filled))
+                            .child(icon_button("icons/arrow_right.svg", ButtonVariant::Ghost)),
+                    ),
+            )
+            .child(
+                div().w_0().flex_1().h_full().child(
+                    div()
+                        .flex()
+                        .gap_px()
+                        .overflow_x_scroll(self.scroll_state.clone())
+                        .child(tab("Cargo.toml", false))
+                        .child(tab("Channels Panel", true))
+                        .child(tab("channels_panel.rs", false))
+                        .child(tab("workspace.rs", false))
+                        .child(tab("icon_button.rs", false))
+                        .child(tab("storybook.rs", false))
+                        .child(tab("theme.rs", false))
+                        .child(tab("theme_registry.rs", false))
+                        .child(tab("styleable_helpers.rs", false)),
+                ),
+            )
+            // Right Side
+            .child(
+                div()
+                    .px_1()
+                    .flex()
+                    .flex_none()
+                    .gap_2()
+                    // Nav Buttons
+                    .child(
+                        div()
+                            .flex()
+                            .items_center()
+                            .gap_px()
+                            .child(icon_button("icons/plus.svg", ButtonVariant::Ghost))
+                            .child(icon_button("icons/split.svg", ButtonVariant::Ghost)),
+                    ),
+            )
+    }
+}

crates/storybook/src/storybook.rs 🔗

@@ -12,6 +12,7 @@ use simplelog::SimpleLogger;
 mod collab_panel;
 mod components;
 mod element_ext;
+mod modules;
 mod theme;
 mod workspace;
 
@@ -34,13 +35,13 @@ fn main() {
 
         cx.add_window(
             gpui2::WindowOptions {
-                bounds: WindowBounds::Fixed(RectF::new(vec2f(0., 0.), vec2f(1400., 900.))),
+                bounds: WindowBounds::Fixed(RectF::new(vec2f(0., 0.), vec2f(1600., 900.))),
                 center: true,
                 ..Default::default()
             },
             |cx| {
                 view(|cx| {
-                    cx.enable_inspector();
+                    // cx.enable_inspector();
                     storybook(&mut ViewContext::new(cx))
                 })
             },

crates/storybook/src/workspace.rs 🔗

@@ -1,4 +1,4 @@
-use crate::{collab_panel::collab_panel, theme::theme};
+use crate::{collab_panel::collab_panel, modules::tab_bar, theme::theme};
 use gpui2::{
     elements::{div, div::ScrollState, img, svg},
     style::{StyleHelpers, Styleable},
@@ -9,6 +9,7 @@ use gpui2::{
 struct WorkspaceElement {
     left_scroll_state: ScrollState,
     right_scroll_state: ScrollState,
+    tab_bar_scroll_state: ScrollState,
 }
 
 pub fn workspace<V: 'static>() -> impl Element<V> {
@@ -38,7 +39,19 @@ impl WorkspaceElement {
                     .flex_row()
                     .overflow_hidden()
                     .child(collab_panel(self.left_scroll_state.clone()))
-                    .child(div().h_full().flex_1())
+                    .child(
+                        div()
+                            .h_full()
+                            .flex_1()
+                            .fill(theme.highest.base.default.background)
+                            .child(
+                                div()
+                                    .flex()
+                                    .flex_col()
+                                    .flex_1()
+                                    .child(tab_bar(self.tab_bar_scroll_state.clone())),
+                            ),
+                    )
                     .child(collab_panel(self.right_scroll_state.clone())),
             )
             .child(statusbar())

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},
@@ -440,9 +442,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,
@@ -454,6 +454,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(),
     }
 }