diff --git a/assets/icons/select-all.svg b/assets/icons/select-all.svg new file mode 100644 index 0000000000..45a10bba42 --- /dev/null +++ b/assets/icons/select-all.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index d999872592..b31c9dcd1b 100644 --- a/crates/editor/src/items.rs +++ b/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, + ) { + let text = self.buffer.read(cx); + let text = text.snapshot(cx); + let text = text.text_for_range(identifier.clone()).collect::>(); + 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>, @@ -1030,7 +1050,7 @@ impl SearchableItem for Editor { fn find_matches( &mut self, - query: project::search::SearchQuery, + query: Arc, cx: &mut ViewContext, ) -> Task>> { let buffer = self.buffer().read(cx).snapshot(cx); diff --git a/crates/feedback/src/feedback_editor.rs b/crates/feedback/src/feedback_editor.rs index a717223f6d..0b8a29e114 100644 --- a/crates/feedback/src/feedback_editor.rs +++ b/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.editor + .update(cx, |e, cx| e.replace(matches, query, cx)); + } fn find_matches( &mut self, - query: project::search::SearchQuery, + query: Arc, cx: &mut ViewContext, ) -> Task> { self.editor diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index a918e3d151..587e6ed25a 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/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, cx: &mut ViewContext, ) -> gpui::Task> { self.editor.update(cx, |e, cx| e.find_matches(query, cx)) } + fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext) { + // 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, diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 6c53d2e934..bf81158701 100644 --- a/crates/project/src/search.rs +++ b/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>, + replacement: Option, whole_word: bool, case_sensitive: bool, inner: SearchInputs, @@ -42,7 +44,7 @@ pub enum SearchQuery { Regex { regex: Regex, - + replacement: Option, 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) -> 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> { + 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, diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 78729df936..6a227812d1 100644 --- a/crates/search/src/buffer_search.rs +++ b/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::(SearchOptions::CASE_SENSITIVE, cx); add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); } @@ -73,9 +75,11 @@ fn add_toggle_option_action(option: SearchOptions, cx: &mut AppContex pub struct BufferSearchBar { query_editor: ViewHandle, + replacement_editor: ViewHandle, active_searchable_item: Option>, active_match_index: Option, active_searchable_item_subscription: Option, + active_search: Option>, searchable_items_with_matches: HashMap, Vec>>, pending_search: Option>, @@ -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| { 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) -> Option { self.active_searchable_item .as_ref() @@ -477,37 +552,16 @@ impl BufferSearchBar { ) -> AnyElement { 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::(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::( - 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) { + if let Some(_) = &self.active_searchable_item { + self.replace_is_active = !self.replace_is_active; + } + } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { + 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) { + 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) { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + search_bar.update(cx, |bar, cx| bar.replace_next(action, cx)); + } + } + fn replace_all_on_pane(pane: &mut Pane, action: &ReplaceAll, cx: &mut ViewContext) { + if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::() { + 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() + ); + } } diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 47f7f485c4..0135ed4eed 100644 --- a/crates/search/src/search.rs +++ b/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( + active: bool, + tooltip_style: TooltipStyle, + button_style: ToggleIconButtonStyle, +) -> AnyElement { + 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( + action: impl Action, + name: &'static str, + icon_path: &'static str, + tooltip_style: TooltipStyle, + button_style: IconButtonStyle, +) -> AnyElement { + 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() +} diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index a12f9d3c3c..b79f655f81 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/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, cx: &mut ViewContext, ) -> Task>> { - 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 { +pub fn regex_search_for_query(query: &project::search::SearchQuery) -> Option { 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, cx: &mut ViewContext, ) -> Task> { - 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) { + // 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. diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index cd983322db..cc90d96420 100644 --- a/crates/theme/src/theme.rs +++ b/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>, pub option_button_component: ToggleIconButtonStyle, - pub action_button: Toggleable>, 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>, + pub action_button: IconButtonStyle, } #[derive(Clone, Deserialize, Default, JsonSchema)] diff --git a/crates/workspace/src/searchable.rs b/crates/workspace/src/searchable.rs index 7a470db7c9..ddde5c3554 100644 --- a/crates/workspace/src/searchable.rs +++ b/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, ); fn select_matches(&mut self, matches: Vec, cx: &mut ViewContext); + fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext); fn match_index_for_direction( &mut self, matches: &Vec, @@ -74,7 +78,7 @@ pub trait SearchableItem: Item { } fn find_matches( &mut self, - query: SearchQuery, + query: Arc, cx: &mut ViewContext, ) -> Task>; fn active_match_index( @@ -103,6 +107,7 @@ pub trait SearchableItemHandle: ItemHandle { cx: &mut WindowContext, ); fn select_matches(&self, matches: &Vec>, cx: &mut WindowContext); + fn replace(&self, _: &Box, _: &SearchQuery, _: &mut WindowContext); fn match_index_for_direction( &self, matches: &Vec>, @@ -113,7 +118,7 @@ pub trait SearchableItemHandle: ItemHandle { ) -> usize; fn find_matches( &self, - query: SearchQuery, + query: Arc, cx: &mut WindowContext, ) -> Task>>; fn active_match_index( @@ -189,7 +194,7 @@ impl SearchableItemHandle for ViewHandle { } fn find_matches( &self, - query: SearchQuery, + query: Arc, cx: &mut WindowContext, ) -> Task>> { let matches = self.update(cx, |this, cx| this.find_matches(query, cx)); @@ -209,6 +214,11 @@ impl SearchableItemHandle for ViewHandle { let matches = downcast_matches(matches); self.update(cx, |this, cx| this.active_match_index(matches, cx)) } + + fn replace(&self, matches: &Box, query: &SearchQuery, cx: &mut WindowContext) { + let matches = matches.downcast_ref().unwrap(); + self.update(cx, |this, cx| this.replace(matches, query, cx)) + } } fn downcast_matches(matches: &Vec>) -> Vec { diff --git a/styles/src/style_tree/search.ts b/styles/src/style_tree/search.ts index 8174690fde..bc95b91819 100644 --- a/styles/src/style_tree/search.ts +++ b/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(), } }