Z 1200/replace in buffer (#2922)

This is still WIP, mostly pending styling. I added a pretty rudimentary
text field and no buttons whatsoever other than that. I am targeting a
Preview of 09.13, as I am gonna be on PTO for the next week.

I dislike the current implementation slightly because of `regex`'s crate
syntax and lack of support of backreferences. What strikes me as odd wrt
to syntax is that it will just replace a capture name with empty string
if that capture is missing from the regex. While this is perfectly fine
behaviour for conditionally-matched capture groups (e.g. `(foo)?`), I
think it should still error out if there's no group with a given name
(conditional or not).
Release Notes:

- Added "Replace" functionality to buffer search.
This commit is contained in:
Piotr Osiewicz 2023-09-12 18:46:54 +02:00 committed by GitHub
parent c545788168
commit 4cb8647702
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 471 additions and 96 deletions

View File

@ -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"/>
<path d="M2.19366 3.84943C2.19188 4.26418 2.32864 4.59864 2.60673 4.84707C2.88052 5.09166 3.25136 5.26933 3.71609 5.3824C3.71616 5.38242 3.71623 5.38243 3.7163 5.38245L4.30919 5.53134L4.30919 5.53134L4.30965 5.53145C4.50649 5.57891 4.67124 5.63133 4.80447 5.68843L4.80469 5.68852C4.93838 5.74508 5.03564 5.81206 5.10001 5.8877L5.10001 5.8877L5.10041 5.88816C5.16432 5.96142 5.19716 6.05222 5.19716 6.16389C5.19716 6.28412 5.1609 6.38933 5.0882 6.48141C5.01496 6.57418 4.91031 6.64838 4.77141 6.70259L4.77121 6.70266C4.63472 6.75659 4.47185 6.7843 4.28146 6.7843C4.08801 6.7843 3.91607 6.75496 3.76491 6.69726C3.61654 6.6382 3.49924 6.55209 3.41132 6.43942C3.3502 6.35821 3.30747 6.26204 3.28375 6.14992C3.26238 6.04888 3.1772 5.96225 3.06518 5.96225H2.26366C2.14682 5.96225 2.04842 6.05919 2.0592 6.18012C2.08842 6.50802 2.1826 6.79102 2.34331 7.02735L2.34352 7.02767C2.53217 7.30057 2.79377 7.50587 3.12633 7.64399L3.12642 7.64402C3.46009 7.78185 3.84993 7.85 4.29476 7.85C4.74293 7.85 5.12859 7.7828 5.45023 7.64651L5.45036 7.64646C5.77328 7.50857 6.02259 7.31417 6.19551 7.06217C6.37037 6.80817 6.4579 6.50901 6.45972 6.16682L6.45972 6.16616C6.4579 5.9333 6.41513 5.72482 6.33012 5.54178C6.2474 5.35987 6.13061 5.20175 5.98007 5.06773C5.83038 4.93448 5.65389 4.82273 5.4511 4.7322C5.24919 4.64206 5.02795 4.57016 4.78757 4.51632L4.29841 4.39935L4.29841 4.39934L4.29771 4.39919C4.18081 4.37301 4.07116 4.34168 3.9687 4.30523C3.86715 4.26734 3.77847 4.22375 3.70232 4.17471C3.62796 4.12508 3.57037 4.06717 3.52849 4.00124C3.49012 3.93815 3.47157 3.86312 3.47481 3.77407L3.47484 3.77407V3.77225C3.47484 3.66563 3.50527 3.57146 3.56612 3.48808C3.6287 3.40475 3.71977 3.33801 3.84235 3.28931L3.84235 3.28932L3.84289 3.28909C3.96465 3.23906 4.1165 3.21304 4.30008 3.21304C4.57006 3.21304 4.77746 3.27105 4.92754 3.38154C5.04235 3.46608 5.11838 3.57594 5.15673 3.71259C5.18352 3.80802 5.26636 3.89142 5.37611 3.89142H6.17259C6.28852 3.89142 6.38806 3.7953 6.37515 3.67382C6.34686 3.4077 6.26051 3.16831 6.1158 2.95658C5.94159 2.70169 5.6982 2.50368 5.38762 2.36201L5.36687 2.4075M2.19366 3.84943C2.19187 3.51004 2.28242 3.21139 2.46644 2.9556L2.46658 2.9554C2.65148 2.70093 2.90447 2.50326 3.22368 2.36179C3.54316 2.2202 3.90494 2.15 4.30807 2.15C4.71809 2.15 5.07841 2.22014 5.38773 2.36206L5.36687 2.4075M2.19366 3.84943C2.19366 3.84951 2.19366 3.84959 2.19366 3.84967L2.24366 3.8494L2.19366 3.84918C2.19366 3.84926 2.19366 3.84935 2.19366 3.84943ZM5.36687 2.4075C5.06537 2.26917 4.71244 2.2 4.30807 2.2C3.91079 2.2 3.55608 2.26917 3.24394 2.4075C2.93179 2.54584 2.68616 2.73827 2.50703 2.9848L3.82389 3.24285L3.82389 3.24285C3.95336 3.18964 4.11209 3.16304 4.30008 3.16304C4.57676 3.16304 4.79579 3.22245 4.95718 3.34128C5.08094 3.43239 5.1635 3.55166 5.20487 3.69908C5.2271 3.77827 5.29386 3.84142 5.37611 3.84142H6.17259C6.26198 3.84142 6.33488 3.76799 6.32543 3.6791C6.29797 3.4208 6.21433 3.18936 6.07452 2.9848C5.90603 2.73827 5.67015 2.54584 5.36687 2.4075ZM4.78958 6.74917C4.64593 6.80592 4.47655 6.8343 4.28146 6.8343C4.08283 6.8343 3.90458 6.80415 3.74674 6.74384C3.59067 6.68177 3.46563 6.59043 3.37163 6.46983L4.78958 6.74917ZM4.78958 6.74917C4.93502 6.69241 5.04764 6.61349 5.12745 6.5124M4.78958 6.74917L5.12745 6.5124M5.12745 6.5124C5.20726 6.4113 5.24716 6.29514 5.24716 6.16389M5.12745 6.5124L5.24716 6.16389M5.24716 6.16389C5.24716 6.04152 5.2108 5.93865 5.13809 5.85529L5.24716 6.16389Z" fill="#687076" stroke="#687076" stroke-width="0.1"/>
</svg>

After

Width:  |  Height:  |  Size: 3.7 KiB

View File

@ -16,7 +16,7 @@ use language::{
proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point, proto::serialize_anchor as serialize_text_anchor, Bias, Buffer, OffsetRangeExt, Point,
SelectionGoal, SelectionGoal,
}; };
use project::{FormatTrigger, Item as _, Project, ProjectPath}; use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
use rpc::proto::{self, update_view}; use rpc::proto::{self, update_view};
use smallvec::SmallVec; use smallvec::SmallVec;
use std::{ use std::{
@ -26,6 +26,7 @@ use std::{
iter, iter,
ops::Range, ops::Range,
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::Arc,
}; };
use text::Selection; use text::Selection;
use util::{ use util::{
@ -978,7 +979,26 @@ impl SearchableItem for Editor {
} }
self.change_selections(None, cx, |s| s.select_ranges(ranges)); 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( fn match_index_for_direction(
&mut self, &mut self,
matches: &Vec<Range<Anchor>>, matches: &Vec<Range<Anchor>>,
@ -1030,7 +1050,7 @@ impl SearchableItem for Editor {
fn find_matches( fn find_matches(
&mut self, &mut self,
query: project::search::SearchQuery, query: Arc<project::search::SearchQuery>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> Task<Vec<Range<Anchor>>> { ) -> Task<Vec<Range<Anchor>>> {
let buffer = self.buffer().read(cx).snapshot(cx); let buffer = self.buffer().read(cx).snapshot(cx);

View File

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

View File

@ -13,7 +13,7 @@ use gpui::{
}; };
use language::{Buffer, LanguageServerId, LanguageServerName}; use language::{Buffer, LanguageServerId, LanguageServerName};
use lsp::IoKind; use lsp::IoKind;
use project::{Project, Worktree}; use project::{search::SearchQuery, Project, Worktree};
use std::{borrow::Cow, sync::Arc}; use std::{borrow::Cow, sync::Arc};
use theme::{ui, Theme}; use theme::{ui, Theme};
use workspace::{ use workspace::{
@ -524,12 +524,24 @@ impl SearchableItem for LspLogView {
fn find_matches( fn find_matches(
&mut self, &mut self,
query: project::search::SearchQuery, query: Arc<project::search::SearchQuery>,
cx: &mut ViewContext<Self>, cx: &mut ViewContext<Self>,
) -> gpui::Task<Vec<Self::Match>> { ) -> gpui::Task<Vec<Self::Match>> {
self.editor.update(cx, |e, cx| e.find_matches(query, cx)) 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( fn active_match_index(
&mut self, &mut self,
matches: Vec<Self::Match>, matches: Vec<Self::Match>,

View File

@ -7,6 +7,7 @@ use language::{char_kind, BufferSnapshot};
use regex::{Regex, RegexBuilder}; use regex::{Regex, RegexBuilder};
use smol::future::yield_now; use smol::future::yield_now;
use std::{ use std::{
borrow::Cow,
io::{BufRead, BufReader, Read}, io::{BufRead, BufReader, Read},
ops::Range, ops::Range,
path::{Path, PathBuf}, path::{Path, PathBuf},
@ -35,6 +36,7 @@ impl SearchInputs {
pub enum SearchQuery { pub enum SearchQuery {
Text { Text {
search: Arc<AhoCorasick<usize>>, search: Arc<AhoCorasick<usize>>,
replacement: Option<String>,
whole_word: bool, whole_word: bool,
case_sensitive: bool, case_sensitive: bool,
inner: SearchInputs, inner: SearchInputs,
@ -42,7 +44,7 @@ pub enum SearchQuery {
Regex { Regex {
regex: Regex, regex: Regex,
replacement: Option<String>,
multiline: bool, multiline: bool,
whole_word: bool, whole_word: bool,
case_sensitive: bool, case_sensitive: bool,
@ -95,6 +97,7 @@ impl SearchQuery {
}; };
Self::Text { Self::Text {
search: Arc::new(search), search: Arc::new(search),
replacement: None,
whole_word, whole_word,
case_sensitive, case_sensitive,
inner, inner,
@ -130,6 +133,7 @@ impl SearchQuery {
}; };
Ok(Self::Regex { Ok(Self::Regex {
regex, regex,
replacement: None,
multiline, multiline,
whole_word, whole_word,
case_sensitive, 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 { pub fn to_proto(&self, project_id: u64) -> proto::SearchProject {
proto::SearchProject { proto::SearchProject {
project_id, 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( pub async fn search(
&self, &self,
buffer: &BufferSnapshot, buffer: &BufferSnapshot,

View File

@ -2,19 +2,16 @@ use crate::{
history::SearchHistory, history::SearchHistory,
mode::{next_mode, SearchMode, Side}, mode::{next_mode, SearchMode, Side},
search_bar::{render_nav_button, render_search_mode_button}, search_bar::{render_nav_button, render_search_mode_button},
CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectAllMatches, CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleReplace,
ToggleWholeWord,
}; };
use collections::HashMap; use collections::HashMap;
use editor::Editor; use editor::Editor;
use futures::channel::oneshot; use futures::channel::oneshot;
use gpui::{ use gpui::{
actions, actions, elements::*, impl_actions, Action, AnyViewHandle, AppContext, Entity, Subscription,
elements::*, Task, View, ViewContext, ViewHandle, WindowContext,
impl_actions,
platform::{CursorStyle, MouseButton},
Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
WindowContext,
}; };
use project::search::SearchQuery; use project::search::SearchQuery;
use serde::Deserialize; use serde::Deserialize;
@ -54,6 +51,11 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::previous_history_query); cx.add_action(BufferSearchBar::previous_history_query);
cx.add_action(BufferSearchBar::cycle_mode); cx.add_action(BufferSearchBar::cycle_mode);
cx.add_action(BufferSearchBar::cycle_mode_on_pane); 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::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, 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 { pub struct BufferSearchBar {
query_editor: ViewHandle<Editor>, query_editor: ViewHandle<Editor>,
replacement_editor: ViewHandle<Editor>,
active_searchable_item: Option<Box<dyn SearchableItemHandle>>, active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>, active_match_index: Option<usize>,
active_searchable_item_subscription: Option<Subscription>, active_searchable_item_subscription: Option<Subscription>,
active_search: Option<Arc<SearchQuery>>,
searchable_items_with_matches: searchable_items_with_matches:
HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>, HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
pending_search: Option<Task<()>>, pending_search: Option<Task<()>>,
@ -85,6 +89,7 @@ pub struct BufferSearchBar {
dismissed: bool, dismissed: bool,
search_history: SearchHistory, search_history: SearchHistory,
current_mode: SearchMode, current_mode: SearchMode,
replace_is_active: bool,
} }
impl Entity for BufferSearchBar { impl Entity for BufferSearchBar {
@ -156,6 +161,9 @@ impl View for BufferSearchBar {
self.query_editor.update(cx, |editor, cx| { self.query_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(new_placeholder_text, 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 search_button_for_mode = |mode, side, cx: &mut ViewContext<BufferSearchBar>| {
let is_active = self.current_mode == mode; let is_active = self.current_mode == mode;
@ -212,7 +220,6 @@ impl View for BufferSearchBar {
cx, cx,
) )
}; };
let query_column = Flex::row() let query_column = Flex::row()
.with_child( .with_child(
Svg::for_style(theme.search.editor_icon.clone().icon) 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_max_width(theme.search.editor.max_width)
.with_height(theme.search.search_bar_row_height) .with_height(theme.search.search_bar_row_height)
.flex(1., false); .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() let mode_column = Flex::row()
.with_child(search_button_for_mode( .with_child(search_button_for_mode(
SearchMode::Text, SearchMode::Text,
@ -261,7 +318,10 @@ impl View for BufferSearchBar {
.with_height(theme.search.search_bar_row_height); .with_height(theme.search.search_bar_row_height);
let nav_column = Flex::row() 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(Flex::row().with_children(match_count))
.with_child(nav_button_for_direction("<", Direction::Prev, cx)) .with_child(nav_button_for_direction("<", Direction::Prev, cx))
.with_child(nav_button_for_direction(">", Direction::Next, cx)) .with_child(nav_button_for_direction(">", Direction::Next, cx))
@ -271,6 +331,8 @@ impl View for BufferSearchBar {
Flex::row() Flex::row()
.with_child(query_column) .with_child(query_column)
.with_children(switches_column)
.with_children(replacement)
.with_child(mode_column) .with_child(mode_column)
.with_child(nav_column) .with_child(nav_column)
.contained() .contained()
@ -345,9 +407,18 @@ impl BufferSearchBar {
}); });
cx.subscribe(&query_editor, Self::on_query_editor_event) cx.subscribe(&query_editor, Self::on_query_editor_event)
.detach(); .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 { Self {
query_editor, query_editor,
replacement_editor,
active_searchable_item: None, active_searchable_item: None,
active_searchable_item_subscription: None, active_searchable_item_subscription: None,
active_match_index: None, active_match_index: None,
@ -359,6 +430,8 @@ impl BufferSearchBar {
dismissed: true, dismissed: true,
search_history: SearchHistory::default(), search_history: SearchHistory::default(),
current_mode: SearchMode::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 { pub fn query(&self, cx: &WindowContext) -> String {
self.query_editor.read(cx).text(cx) 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> { pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
self.active_searchable_item self.active_searchable_item
.as_ref() .as_ref()
@ -477,37 +552,16 @@ impl BufferSearchBar {
) -> AnyElement<Self> { ) -> AnyElement<Self> {
let tooltip = "Select All Matches"; let tooltip = "Select All Matches";
let tooltip_style = theme::current(cx).tooltip.clone(); let tooltip_style = theme::current(cx).tooltip.clone();
let action_type_id = 0_usize;
let has_matches = self.active_match_index.is_some(); let theme = theme::current(cx);
let cursor_style = if has_matches { let style = theme.search.action_button.clone();
CursorStyle::PointingHand
} else { gpui::elements::Component::element(SafeStylable::with_style(
CursorStyle::default() theme::components::action_button::Button::action(SelectAllMatches)
}; .with_tooltip(tooltip, tooltip_style)
enum ActionButton {} .with_contents(theme::components::svg::Svg::new(icon)),
MouseEventHandler::new::<ActionButton, _>(action_type_id, cx, |state, cx| { style,
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,
)
.into_any() .into_any()
} }
@ -688,6 +742,7 @@ impl BufferSearchBar {
let (done_tx, done_rx) = oneshot::channel(); let (done_tx, done_rx) = oneshot::channel();
let query = self.query(cx); let query = self.query(cx);
self.pending_search.take(); self.pending_search.take();
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() { if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
if query.is_empty() { if query.is_empty() {
self.active_match_index.take(); self.active_match_index.take();
@ -695,7 +750,7 @@ impl BufferSearchBar {
let _ = done_tx.send(()); let _ = done_tx.send(());
cx.notify(); cx.notify();
} else { } else {
let query = if self.current_mode == SearchMode::Regex { let query: Arc<_> = if self.current_mode == SearchMode::Regex {
match SearchQuery::regex( match SearchQuery::regex(
query, query,
self.search_options.contains(SearchOptions::WHOLE_WORD), self.search_options.contains(SearchOptions::WHOLE_WORD),
@ -703,7 +758,8 @@ impl BufferSearchBar {
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
) { ) {
Ok(query) => query, Ok(query) => query
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty())),
Err(_) => { Err(_) => {
self.query_contains_error = true; self.query_contains_error = true;
cx.notify(); cx.notify();
@ -718,8 +774,10 @@ impl BufferSearchBar {
Vec::new(), Vec::new(),
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 query_text = query.as_str().to_string();
let matches = active_searchable_item.find_matches(query, cx); let matches = active_searchable_item.find_matches(query, cx);
@ -810,6 +868,63 @@ impl BufferSearchBar {
cx.propagate_action(); 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)] #[cfg(test)]
@ -1539,4 +1654,109 @@ mod tests {
assert_eq!(search_bar.search_options, SearchOptions::NONE); 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()
);
}
} }

View File

@ -8,7 +8,9 @@ use gpui::{
pub use mode::SearchMode; pub use mode::SearchMode;
use project::search::SearchQuery; use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView}; 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; pub mod buffer_search;
mod history; mod history;
@ -27,6 +29,7 @@ actions!(
CycleMode, CycleMode,
ToggleWholeWord, ToggleWholeWord,
ToggleCaseSensitive, ToggleCaseSensitive,
ToggleReplace,
SelectNextMatch, SelectNextMatch,
SelectPrevMatch, SelectPrevMatch,
SelectAllMatches, SelectAllMatches,
@ -34,7 +37,9 @@ actions!(
PreviousHistoryQuery, PreviousHistoryQuery,
ActivateTextMode, ActivateTextMode,
ActivateSemanticMode, ActivateSemanticMode,
ActivateRegexMode ActivateRegexMode,
ReplaceAll,
ReplaceNext
] ]
); );
@ -98,3 +103,32 @@ impl SearchOptions {
.into_any() .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()
}

View File

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

View File

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

View File

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

View File

@ -30,9 +30,6 @@ export default function search(): any {
selection: theme.players[0], selection: theme.players[0],
text: text(theme.highest, "mono", "default"), text: text(theme.highest, "mono", "default"),
border: border(theme.highest), border: border(theme.highest),
margin: {
right: SEARCH_ROW_SPACING,
},
padding: { padding: {
top: 4, top: 4,
bottom: 4, bottom: 4,
@ -125,7 +122,7 @@ export default function search(): any {
button_width: 32, button_width: 32,
background: background(theme.highest, "on"), background: background(theme.highest, "on"),
corner_radius: 2, corner_radius: 6,
margin: { right: 2 }, margin: { right: 2 },
border: { border: {
width: 1, 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, editor,
invalid_editor: { invalid_editor: {
...editor, ...editor,
@ -218,6 +195,7 @@ export default function search(): any {
match_index: { match_index: {
...text(theme.highest, "mono", { size: "sm" }), ...text(theme.highest, "mono", { size: "sm" }),
padding: { padding: {
left: SEARCH_ROW_SPACING,
right: SEARCH_ROW_SPACING, right: SEARCH_ROW_SPACING,
}, },
}, },
@ -398,6 +376,59 @@ export default function search(): any {
search_row_spacing: 8, search_row_spacing: 8,
option_button_height: 22, option_button_height: 22,
modes_container: {}, 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(), ...search_results(),
} }
} }