[WIP] Replace in project (#2984)
Targeting Preview of 09.27. This is still pending several touchups/clearups: - We should watch multibuffer for changes and rescan the excerpts. This should also update match count. - Closing editor while multibuffer with 100's of changed files is open leads to us prompting for save once per each file in the multibuffer. One could in theory save in multibuffer before closing it (thus avoiding unnecessary prompts), but it'd be cool to be able to "Save all"/"Discard All". Release Notes: - Added "Replace in project" functionality
This commit is contained in:
parent
d090fd25e4
commit
dbfa1d7263
4 changed files with 205 additions and 20 deletions
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -231,7 +231,16 @@ impl SearchQuery {
|
|||
}
|
||||
}
|
||||
}
|
||||
pub fn replacement<'a>(&self, text: &'a str) -> Option<Cow<'a, str>> {
|
||||
/// 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<Cow<'a, str>> {
|
||||
match self {
|
||||
SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from),
|
||||
SearchQuery::Regex {
|
||||
|
|
|
@ -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<Self>) {
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
if !self.dismissed && self.active_search.is_some() {
|
||||
|
|
|
@ -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::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
|
||||
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
|
||||
add_toggle_filters_action::<ToggleFilters>(cx);
|
||||
|
@ -127,6 +133,7 @@ enum InputPanel {
|
|||
pub struct ProjectSearchView {
|
||||
model: ModelHandle<ProjectSearch>,
|
||||
query_editor: ViewHandle<Editor>,
|
||||
replacement_editor: ViewHandle<Editor>,
|
||||
results_editor: ViewHandle<Editor>,
|
||||
semantic_state: Option<SemanticState>,
|
||||
semantic_permissioned: Option<bool>,
|
||||
|
@ -138,6 +145,7 @@ pub struct ProjectSearchView {
|
|||
included_files_editor: ViewHandle<Editor>,
|
||||
excluded_files_editor: ViewHandle<Editor>,
|
||||
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<Self>) {
|
||||
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<Self>) {
|
||||
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::<Vec<_>>();
|
||||
for item in matches {
|
||||
self.results_editor.replace(&item, &query, cx);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn new(
|
||||
model: ModelHandle<ProjectSearch>,
|
||||
|
@ -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<Pane>) {
|
||||
if let Some(search_view) = pane
|
||||
.active_item()
|
||||
.and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
{
|
||||
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<Pane>) {
|
||||
if let Some(search_view) = pane
|
||||
.active_item()
|
||||
.and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
{
|
||||
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<Pane>) {
|
||||
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<Self>) {
|
||||
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<Pane>) {
|
||||
let mut should_propagate = true;
|
||||
if let Some(search_view) = pane
|
||||
.active_item()
|
||||
.and_then(|item| item.downcast::<ProjectSearchView>())
|
||||
{
|
||||
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<Pane>) {
|
||||
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()
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue