diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index b31c9dcd1b8698e2f92585bca563c4ba4ea1b1fc..568ea223cc10e7037414e5ca4b18d15f18779830 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -995,7 +995,7 @@ impl SearchableItem for Editor { joined_chunks.into() }; - if let Some(replacement) = query.replacement(&text) { + if let Some(replacement) = query.replacement_for(&text) { self.edit([(identifier.clone(), Arc::from(&*replacement))], cx); } } diff --git a/crates/project/src/search.rs b/crates/project/src/search.rs index 5586c1e867c2a27995df7a403986be5fb10165be..46dd30c8a0e6151a9d37ac293dd0f1f3a2e8c9ac 100644 --- a/crates/project/src/search.rs +++ b/crates/project/src/search.rs @@ -231,7 +231,16 @@ impl SearchQuery { } } } - pub fn replacement<'a>(&self, text: &'a str) -> Option> { + /// Returns the replacement text for this `SearchQuery`. + pub fn replacement(&self) -> Option<&str> { + match self { + SearchQuery::Text { replacement, .. } | SearchQuery::Regex { replacement, .. } => { + replacement.as_deref() + } + } + } + /// Replaces search hits if replacement is set. `text` is assumed to be a string that matches this `SearchQuery` exactly, without any leftovers on either side. + pub fn replacement_for<'a>(&self, text: &'a str) -> Option> { match self { SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from), SearchQuery::Regex { diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 142b548a9341cf459e12eb3072ac57c75183e9ba..e076161256284f0d46522855cb24e522d76c00d8 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -90,7 +90,7 @@ pub struct BufferSearchBar { dismissed: bool, search_history: SearchHistory, current_mode: SearchMode, - replace_is_active: bool, + replace_enabled: bool, } impl Entity for BufferSearchBar { @@ -266,7 +266,7 @@ 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 should_show_replace_input = self.replace_enabled && supported_options.replacement; let replacement = should_show_replace_input.then(|| { Flex::row() @@ -308,7 +308,7 @@ impl View for BufferSearchBar { Flex::row() .align_children_center() .with_child(super::toggle_replace_button( - self.replace_is_active, + self.replace_enabled, theme.tooltip.clone(), theme.search.option_button_component.clone(), )) @@ -447,7 +447,7 @@ impl BufferSearchBar { search_history: SearchHistory::default(), current_mode: SearchMode::default(), active_search: None, - replace_is_active: false, + replace_enabled: false, } } @@ -891,7 +891,10 @@ impl BufferSearchBar { } fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext) { if let Some(_) = &self.active_searchable_item { - self.replace_is_active = !self.replace_is_active; + self.replace_enabled = !self.replace_enabled; + if !self.replace_enabled { + cx.focus(&self.query_editor); + } cx.notify(); } } @@ -901,10 +904,13 @@ impl BufferSearchBar { search_bar.update(cx, |bar, cx| { if let Some(_) = &bar.active_searchable_item { should_propagate = false; - bar.replace_is_active = !bar.replace_is_active; + bar.replace_enabled = !bar.replace_enabled; if bar.dismissed { bar.show(cx); } + if !bar.replace_enabled { + cx.focus(&bar.query_editor); + } cx.notify(); } }); @@ -914,6 +920,7 @@ impl BufferSearchBar { } } fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { + let mut should_propagate = true; 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() { @@ -929,10 +936,15 @@ impl BufferSearchBar { searchable_item.replace(&matches[active_index], &query, cx); self.select_next_match(&SelectNextMatch, cx); } + should_propagate = false; + self.focus_editor(&FocusEditor, cx); } } } } + if should_propagate { + cx.propagate_action(); + } } fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { if !self.dismissed && self.active_search.is_some() { diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 69587f856e94e776618b9c7a8846fb3ba726b38c..592c3354aceb79e34702757a0e2cbadf498aac97 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -3,8 +3,8 @@ use crate::{ mode::{SearchMode, Side}, search_bar::{render_nav_button, render_option_button_icon, render_search_mode_button}, ActivateRegexMode, ActivateSemanticMode, ActivateTextMode, CycleMode, NextHistoryQuery, - PreviousHistoryQuery, SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, - ToggleWholeWord, + PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions, SelectNextMatch, SelectPrevMatch, + ToggleCaseSensitive, ToggleReplace, ToggleWholeWord, }; use anyhow::{Context, Result}; use collections::HashMap; @@ -64,10 +64,14 @@ pub fn init(cx: &mut AppContext) { cx.add_action(ProjectSearchBar::search_in_new); cx.add_action(ProjectSearchBar::select_next_match); cx.add_action(ProjectSearchBar::select_prev_match); + cx.add_action(ProjectSearchBar::replace_next); + cx.add_action(ProjectSearchBar::replace_all); cx.add_action(ProjectSearchBar::cycle_mode); cx.add_action(ProjectSearchBar::next_history_query); cx.add_action(ProjectSearchBar::previous_history_query); cx.add_action(ProjectSearchBar::activate_regex_mode); + cx.add_action(ProjectSearchBar::toggle_replace); + cx.add_action(ProjectSearchBar::toggle_replace_on_a_pane); cx.add_action(ProjectSearchBar::activate_text_mode); // This action should only be registered if the semantic index is enabled @@ -77,6 +81,8 @@ pub fn init(cx: &mut AppContext) { cx.capture_action(ProjectSearchBar::tab); cx.capture_action(ProjectSearchBar::tab_previous); + cx.capture_action(ProjectSearchView::replace_all); + cx.capture_action(ProjectSearchView::replace_next); add_toggle_option_action::(SearchOptions::CASE_SENSITIVE, cx); add_toggle_option_action::(SearchOptions::WHOLE_WORD, cx); add_toggle_filters_action::(cx); @@ -127,6 +133,7 @@ enum InputPanel { pub struct ProjectSearchView { model: ModelHandle, query_editor: ViewHandle, + replacement_editor: ViewHandle, results_editor: ViewHandle, semantic_state: Option, semantic_permissioned: Option, @@ -138,6 +145,7 @@ pub struct ProjectSearchView { included_files_editor: ViewHandle, excluded_files_editor: ViewHandle, filters_enabled: bool, + replace_enabled: bool, current_mode: SearchMode, } @@ -844,6 +852,45 @@ impl ProjectSearchView { cx.notify(); } + fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext) { + let model = self.model.read(cx); + if let Some(query) = model.active_query.as_ref() { + if model.match_ranges.is_empty() { + return; + } + if let Some(active_index) = self.active_match_index { + let query = query.clone().with_replacement(self.replacement(cx)); + self.results_editor.replace( + &(Box::new(model.match_ranges[active_index].clone()) as _), + &query, + cx, + ); + self.select_match(Direction::Next, cx) + } + } + } + pub fn replacement(&self, cx: &AppContext) -> String { + self.replacement_editor.read(cx).text(cx) + } + fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext) { + let model = self.model.read(cx); + if let Some(query) = model.active_query.as_ref() { + if model.match_ranges.is_empty() { + return; + } + if self.active_match_index.is_some() { + let query = query.clone().with_replacement(self.replacement(cx)); + let matches = model + .match_ranges + .iter() + .map(|item| Box::new(item.clone()) as _) + .collect::>(); + for item in matches { + self.results_editor.replace(&item, &query, cx); + } + } + } + } fn new( model: ModelHandle, @@ -852,6 +899,7 @@ impl ProjectSearchView { ) -> Self { let project; let excerpts; + let mut replacement_text = None; let mut query_text = String::new(); // Read in settings if available @@ -871,6 +919,7 @@ impl ProjectSearchView { excerpts = model.excerpts.clone(); if let Some(active_query) = model.active_query.as_ref() { query_text = active_query.as_str().to_string(); + replacement_text = active_query.replacement().map(ToOwned::to_owned); options = SearchOptions::from_query(active_query); } } @@ -891,7 +940,17 @@ impl ProjectSearchView { cx.emit(ViewEvent::EditorEvent(event.clone())) }) .detach(); - + let replacement_editor = cx.add_view(|cx| { + let mut editor = Editor::single_line( + Some(Arc::new(|theme| theme.search.editor.input.clone())), + cx, + ); + editor.set_placeholder_text("Replace in project..", cx); + if let Some(text) = replacement_text { + editor.set_text(text, cx); + } + editor + }); let results_editor = cx.add_view(|cx| { let mut editor = Editor::for_multibuffer(excerpts, Some(project.clone()), cx); editor.set_searchable(false); @@ -945,6 +1004,7 @@ impl ProjectSearchView { // Check if Worktrees have all been previously indexed let mut this = ProjectSearchView { + replacement_editor, search_id: model.read(cx).search_id, model, query_editor, @@ -959,6 +1019,7 @@ impl ProjectSearchView { excluded_files_editor, filters_enabled, current_mode, + replace_enabled: false, }; this.model_changed(cx); this @@ -1346,6 +1407,26 @@ impl ProjectSearchBar { } } + fn replace_next(pane: &mut Pane, _: &ReplaceNext, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.replace_next(&ReplaceNext, cx)); + } else { + cx.propagate_action(); + } + } + fn replace_all(pane: &mut Pane, _: &ReplaceAll, cx: &mut ViewContext) { + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |view, cx| view.replace_all(&ReplaceAll, cx)); + } else { + cx.propagate_action(); + } + } fn select_prev_match(pane: &mut Pane, _: &SelectPrevMatch, cx: &mut ViewContext) { if let Some(search_view) = pane .active_item() @@ -1376,12 +1457,16 @@ impl ProjectSearchBar { }; active_project_search.update(cx, |project_view, cx| { - let views = &[ - &project_view.query_editor, - &project_view.included_files_editor, - &project_view.excluded_files_editor, - ]; - + let mut views = vec![&project_view.query_editor]; + if project_view.filters_enabled { + views.extend([ + &project_view.included_files_editor, + &project_view.excluded_files_editor, + ]); + } + if project_view.replace_enabled { + views.push(&project_view.replacement_editor); + } let current_index = match views .iter() .enumerate() @@ -1417,7 +1502,36 @@ impl ProjectSearchBar { false } } - + fn toggle_replace(&mut self, _: &ToggleReplace, cx: &mut ViewContext) { + if let Some(search) = &self.active_project_search { + search.update(cx, |this, cx| { + this.replace_enabled = !this.replace_enabled; + if !this.replace_enabled { + cx.focus(&this.query_editor); + } + cx.notify(); + }); + } + } + fn toggle_replace_on_a_pane(pane: &mut Pane, _: &ToggleReplace, cx: &mut ViewContext) { + let mut should_propagate = true; + if let Some(search_view) = pane + .active_item() + .and_then(|item| item.downcast::()) + { + search_view.update(cx, |this, cx| { + should_propagate = false; + this.replace_enabled = !this.replace_enabled; + if !this.replace_enabled { + cx.focus(&this.query_editor); + } + cx.notify(); + }); + } + if should_propagate { + cx.propagate_action(); + } + } fn activate_text_mode(pane: &mut Pane, _: &ActivateTextMode, cx: &mut ViewContext) { if let Some(search_view) = pane .active_item() @@ -1653,7 +1767,43 @@ impl View for ProjectSearchBar { .with_style(theme.search.match_index.container) .aligned() }); - + let should_show_replace_input = search.replace_enabled; + 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(&search.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 query_column = Flex::column() .with_spacing(theme.search.search_row_spacing) .with_child( @@ -1706,7 +1856,17 @@ impl View for ProjectSearchBar { .flex(1., false) })) .flex(1., false); - + let switches_column = Flex::row() + .align_children_center() + .with_child(super::toggle_replace_button( + search.replace_enabled, + 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( @@ -1744,6 +1904,8 @@ impl View for ProjectSearchBar { }; let nav_column = Flex::row() + .with_children(replace_next) + .with_children(replace_all) .with_child(Flex::row().with_children(matches)) .with_child(nav_button_for_direction("<", Direction::Prev, cx)) .with_child(nav_button_for_direction(">", Direction::Next, cx)) @@ -1753,6 +1915,8 @@ impl View for ProjectSearchBar { Flex::row() .with_child(query_column) + .with_child(switches_column) + .with_children(replacement) .with_child(mode_column) .with_child(nav_column) .contained()