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,
SelectionGoal,
};
use project::{FormatTrigger, Item as _, Project, ProjectPath};
use project::{search::SearchQuery, FormatTrigger, Item as _, Project, ProjectPath};
use rpc::proto::{self, update_view};
use smallvec::SmallVec;
use std::{
@ -26,6 +26,7 @@ use std::{
iter,
ops::Range,
path::{Path, PathBuf},
sync::Arc,
};
use text::Selection;
use util::{
@ -978,7 +979,26 @@ impl SearchableItem for Editor {
}
self.change_selections(None, cx, |s| s.select_ranges(ranges));
}
fn replace(
&mut self,
identifier: &Self::Match,
query: &SearchQuery,
cx: &mut ViewContext<Self>,
) {
let text = self.buffer.read(cx);
let text = text.snapshot(cx);
let text = text.text_for_range(identifier.clone()).collect::<Vec<_>>();
let text: Cow<_> = if text.len() == 1 {
text.first().cloned().unwrap().into()
} else {
let joined_chunks = text.join("");
joined_chunks.into()
};
if let Some(replacement) = query.replacement(&text) {
self.edit([(identifier.clone(), Arc::from(&*replacement))], cx);
}
}
fn match_index_for_direction(
&mut self,
matches: &Vec<Range<Anchor>>,
@ -1030,7 +1050,7 @@ impl SearchableItem for Editor {
fn find_matches(
&mut self,
query: project::search::SearchQuery,
query: Arc<project::search::SearchQuery>,
cx: &mut ViewContext<Self>,
) -> Task<Vec<Range<Anchor>>> {
let buffer = self.buffer().read(cx).snapshot(cx);

View File

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

View File

@ -13,7 +13,7 @@ use gpui::{
};
use language::{Buffer, LanguageServerId, LanguageServerName};
use lsp::IoKind;
use project::{Project, Worktree};
use project::{search::SearchQuery, Project, Worktree};
use std::{borrow::Cow, sync::Arc};
use theme::{ui, Theme};
use workspace::{
@ -524,12 +524,24 @@ impl SearchableItem for LspLogView {
fn find_matches(
&mut self,
query: project::search::SearchQuery,
query: Arc<project::search::SearchQuery>,
cx: &mut ViewContext<Self>,
) -> gpui::Task<Vec<Self::Match>> {
self.editor.update(cx, |e, cx| e.find_matches(query, cx))
}
fn replace(&mut self, _: &Self::Match, _: &SearchQuery, _: &mut ViewContext<Self>) {
// Since LSP Log is read-only, it doesn't make sense to support replace operation.
}
fn supported_options() -> workspace::searchable::SearchOptions {
workspace::searchable::SearchOptions {
case: true,
word: true,
regex: true,
// LSP log is read-only.
replacement: false,
}
}
fn active_match_index(
&mut self,
matches: Vec<Self::Match>,

View File

@ -7,6 +7,7 @@ use language::{char_kind, BufferSnapshot};
use regex::{Regex, RegexBuilder};
use smol::future::yield_now;
use std::{
borrow::Cow,
io::{BufRead, BufReader, Read},
ops::Range,
path::{Path, PathBuf},
@ -35,6 +36,7 @@ impl SearchInputs {
pub enum SearchQuery {
Text {
search: Arc<AhoCorasick<usize>>,
replacement: Option<String>,
whole_word: bool,
case_sensitive: bool,
inner: SearchInputs,
@ -42,7 +44,7 @@ pub enum SearchQuery {
Regex {
regex: Regex,
replacement: Option<String>,
multiline: bool,
whole_word: bool,
case_sensitive: bool,
@ -95,6 +97,7 @@ impl SearchQuery {
};
Self::Text {
search: Arc::new(search),
replacement: None,
whole_word,
case_sensitive,
inner,
@ -130,6 +133,7 @@ impl SearchQuery {
};
Ok(Self::Regex {
regex,
replacement: None,
multiline,
whole_word,
case_sensitive,
@ -156,7 +160,21 @@ impl SearchQuery {
))
}
}
pub fn with_replacement(mut self, new_replacement: Option<String>) -> Self {
match self {
Self::Text {
ref mut replacement,
..
}
| Self::Regex {
ref mut replacement,
..
} => {
*replacement = new_replacement;
self
}
}
}
pub fn to_proto(&self, project_id: u64) -> proto::SearchProject {
proto::SearchProject {
project_id,
@ -214,7 +232,20 @@ impl SearchQuery {
}
}
}
pub fn replacement<'a>(&self, text: &'a str) -> Option<Cow<'a, str>> {
match self {
SearchQuery::Text { replacement, .. } => replacement.clone().map(Cow::from),
SearchQuery::Regex {
regex, replacement, ..
} => {
if let Some(replacement) = replacement {
Some(regex.replace(text, replacement))
} else {
None
}
}
}
}
pub async fn search(
&self,
buffer: &BufferSnapshot,

View File

@ -2,19 +2,16 @@ use crate::{
history::SearchHistory,
mode::{next_mode, SearchMode, Side},
search_bar::{render_nav_button, render_search_mode_button},
CycleMode, NextHistoryQuery, PreviousHistoryQuery, SearchOptions, SelectAllMatches,
SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleWholeWord,
CycleMode, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleReplace,
ToggleWholeWord,
};
use collections::HashMap;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{
actions,
elements::*,
impl_actions,
platform::{CursorStyle, MouseButton},
Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
WindowContext,
actions, elements::*, impl_actions, Action, AnyViewHandle, AppContext, Entity, Subscription,
Task, View, ViewContext, ViewHandle, WindowContext,
};
use project::search::SearchQuery;
use serde::Deserialize;
@ -54,6 +51,11 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::previous_history_query);
cx.add_action(BufferSearchBar::cycle_mode);
cx.add_action(BufferSearchBar::cycle_mode_on_pane);
cx.add_action(BufferSearchBar::replace_all);
cx.add_action(BufferSearchBar::replace_next);
cx.add_action(BufferSearchBar::replace_all_on_pane);
cx.add_action(BufferSearchBar::replace_next_on_pane);
cx.add_action(BufferSearchBar::toggle_replace);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
}
@ -73,9 +75,11 @@ fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContex
pub struct BufferSearchBar {
query_editor: ViewHandle<Editor>,
replacement_editor: ViewHandle<Editor>,
active_searchable_item: Option<Box<dyn SearchableItemHandle>>,
active_match_index: Option<usize>,
active_searchable_item_subscription: Option<Subscription>,
active_search: Option<Arc<SearchQuery>>,
searchable_items_with_matches:
HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
pending_search: Option<Task<()>>,
@ -85,6 +89,7 @@ pub struct BufferSearchBar {
dismissed: bool,
search_history: SearchHistory,
current_mode: SearchMode,
replace_is_active: bool,
}
impl Entity for BufferSearchBar {
@ -156,6 +161,9 @@ impl View for BufferSearchBar {
self.query_editor.update(cx, |editor, cx| {
editor.set_placeholder_text(new_placeholder_text, cx);
});
self.replacement_editor.update(cx, |editor, cx| {
editor.set_placeholder_text("Replace with...", cx);
});
let search_button_for_mode = |mode, side, cx: &mut ViewContext<BufferSearchBar>| {
let is_active = self.current_mode == mode;
@ -212,7 +220,6 @@ impl View for BufferSearchBar {
cx,
)
};
let query_column = Flex::row()
.with_child(
Svg::for_style(theme.search.editor_icon.clone().icon)
@ -243,7 +250,57 @@ impl View for BufferSearchBar {
.with_max_width(theme.search.editor.max_width)
.with_height(theme.search.search_bar_row_height)
.flex(1., false);
let should_show_replace_input = self.replace_is_active && supported_options.replacement;
let replacement = should_show_replace_input.then(|| {
Flex::row()
.with_child(
Svg::for_style(theme.search.replace_icon.clone().icon)
.contained()
.with_style(theme.search.replace_icon.clone().container),
)
.with_child(ChildView::new(&self.replacement_editor, cx).flex(1., true))
.align_children_center()
.flex(1., true)
.contained()
.with_style(query_container_style)
.constrained()
.with_min_width(theme.search.editor.min_width)
.with_max_width(theme.search.editor.max_width)
.with_height(theme.search.search_bar_row_height)
.flex(1., false)
});
let replace_all = should_show_replace_input.then(|| {
super::replace_action(
ReplaceAll,
"Replace all",
"icons/replace_all.svg",
theme.tooltip.clone(),
theme.search.action_button.clone(),
)
});
let replace_next = should_show_replace_input.then(|| {
super::replace_action(
ReplaceNext,
"Replace next",
"icons/replace_next.svg",
theme.tooltip.clone(),
theme.search.action_button.clone(),
)
});
let switches_column = supported_options.replacement.then(|| {
Flex::row()
.align_children_center()
.with_child(super::toggle_replace_button(
self.replace_is_active,
theme.tooltip.clone(),
theme.search.option_button_component.clone(),
))
.constrained()
.with_height(theme.search.search_bar_row_height)
.contained()
.with_style(theme.search.option_button_group)
});
let mode_column = Flex::row()
.with_child(search_button_for_mode(
SearchMode::Text,
@ -261,7 +318,10 @@ impl View for BufferSearchBar {
.with_height(theme.search.search_bar_row_height);
let nav_column = Flex::row()
.with_child(self.render_action_button("all", cx))
.align_children_center()
.with_children(replace_next)
.with_children(replace_all)
.with_child(self.render_action_button("icons/select-all.svg", cx))
.with_child(Flex::row().with_children(match_count))
.with_child(nav_button_for_direction("<", Direction::Prev, cx))
.with_child(nav_button_for_direction(">", Direction::Next, cx))
@ -271,6 +331,8 @@ impl View for BufferSearchBar {
Flex::row()
.with_child(query_column)
.with_children(switches_column)
.with_children(replacement)
.with_child(mode_column)
.with_child(nav_column)
.contained()
@ -345,9 +407,18 @@ impl BufferSearchBar {
});
cx.subscribe(&query_editor, Self::on_query_editor_event)
.detach();
let replacement_editor = cx.add_view(|cx| {
Editor::auto_height(
2,
Some(Arc::new(|theme| theme.search.editor.input.clone())),
cx,
)
});
// cx.subscribe(&replacement_editor, Self::on_query_editor_event)
// .detach();
Self {
query_editor,
replacement_editor,
active_searchable_item: None,
active_searchable_item_subscription: None,
active_match_index: None,
@ -359,6 +430,8 @@ impl BufferSearchBar {
dismissed: true,
search_history: SearchHistory::default(),
current_mode: SearchMode::default(),
active_search: None,
replace_is_active: false,
}
}
@ -441,7 +514,9 @@ impl BufferSearchBar {
pub fn query(&self, cx: &WindowContext) -> String {
self.query_editor.read(cx).text(cx)
}
pub fn replacement(&self, cx: &WindowContext) -> String {
self.replacement_editor.read(cx).text(cx)
}
pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
self.active_searchable_item
.as_ref()
@ -477,37 +552,16 @@ impl BufferSearchBar {
) -> AnyElement<Self> {
let tooltip = "Select All Matches";
let tooltip_style = theme::current(cx).tooltip.clone();
let action_type_id = 0_usize;
let has_matches = self.active_match_index.is_some();
let cursor_style = if has_matches {
CursorStyle::PointingHand
} else {
CursorStyle::default()
};
enum ActionButton {}
MouseEventHandler::new::<ActionButton, _>(action_type_id, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.search
.action_button
.in_state(has_matches)
.style_for(state);
Label::new(icon, style.text.clone())
.aligned()
.contained()
.with_style(style.container)
})
.on_click(MouseButton::Left, move |_, this, cx| {
this.select_all_matches(&SelectAllMatches, cx)
})
.with_cursor_style(cursor_style)
.with_tooltip::<ActionButton>(
action_type_id,
tooltip.to_string(),
Some(Box::new(SelectAllMatches)),
tooltip_style,
cx,
)
let theme = theme::current(cx);
let style = theme.search.action_button.clone();
gpui::elements::Component::element(SafeStylable::with_style(
theme::components::action_button::Button::action(SelectAllMatches)
.with_tooltip(tooltip, tooltip_style)
.with_contents(theme::components::svg::Svg::new(icon)),
style,
))
.into_any()
}
@ -688,6 +742,7 @@ impl BufferSearchBar {
let (done_tx, done_rx) = oneshot::channel();
let query = self.query(cx);
self.pending_search.take();
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
if query.is_empty() {
self.active_match_index.take();
@ -695,7 +750,7 @@ impl BufferSearchBar {
let _ = done_tx.send(());
cx.notify();
} else {
let query = if self.current_mode == SearchMode::Regex {
let query: Arc<_> = if self.current_mode == SearchMode::Regex {
match SearchQuery::regex(
query,
self.search_options.contains(SearchOptions::WHOLE_WORD),
@ -703,7 +758,8 @@ impl BufferSearchBar {
Vec::new(),
Vec::new(),
) {
Ok(query) => query,
Ok(query) => query
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty())),
Err(_) => {
self.query_contains_error = true;
cx.notify();
@ -718,8 +774,10 @@ impl BufferSearchBar {
Vec::new(),
Vec::new(),
)
};
.with_replacement(Some(self.replacement(cx)).filter(|s| !s.is_empty()))
}
.into();
self.active_search = Some(query.clone());
let query_text = query.as_str().to_string();
let matches = active_searchable_item.find_matches(query, cx);
@ -810,6 +868,63 @@ impl BufferSearchBar {
cx.propagate_action();
}
}
fn toggle_replace(&mut self, _: &ToggleReplace, _: &mut ViewContext<Self>) {
if let Some(_) = &self.active_searchable_item {
self.replace_is_active = !self.replace_is_active;
}
}
fn replace_next(&mut self, _: &ReplaceNext, cx: &mut ViewContext<Self>) {
if !self.dismissed && self.active_search.is_some() {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(query) = self.active_search.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&searchable_item.downgrade())
{
if let Some(active_index) = self.active_match_index {
let query = query.as_ref().clone().with_replacement(
Some(self.replacement(cx)).filter(|rep| !rep.is_empty()),
);
searchable_item.replace(&matches[active_index], &query, cx);
}
self.focus_editor(&FocusEditor, cx);
}
}
}
}
}
fn replace_all(&mut self, _: &ReplaceAll, cx: &mut ViewContext<Self>) {
if !self.dismissed && self.active_search.is_some() {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(query) = self.active_search.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&searchable_item.downgrade())
{
let query = query.as_ref().clone().with_replacement(
Some(self.replacement(cx)).filter(|rep| !rep.is_empty()),
);
for m in matches {
searchable_item.replace(m, &query, cx);
}
self.focus_editor(&FocusEditor, cx);
}
}
}
}
}
fn replace_next_on_pane(pane: &mut Pane, action: &ReplaceNext, cx: &mut ViewContext<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |bar, cx| bar.replace_next(action, cx));
}
}
fn replace_all_on_pane(pane: &mut Pane, action: &ReplaceAll, cx: &mut ViewContext<Pane>) {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
search_bar.update(cx, |bar, cx| bar.replace_all(action, cx));
}
}
}
#[cfg(test)]
@ -1539,4 +1654,109 @@ mod tests {
assert_eq!(search_bar.search_options, SearchOptions::NONE);
});
}
#[gpui::test]
async fn test_replace_simple(cx: &mut TestAppContext) {
let (editor, search_bar) = init_test(cx);
search_bar
.update(cx, |search_bar, cx| {
search_bar.search("expression", None, cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
// We use $1 here as initially we should be in Text mode, where `$1` should be treated literally.
editor.set_text("expr$1", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex or regexp;[1] also referred to as
rational expr$1[2][3]) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Search for word boundaries and replace just a single one.
search_bar
.update(cx, |search_bar, cx| {
search_bar.search("or", Some(SearchOptions::WHOLE_WORD), cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("banana", cx);
});
search_bar.replace_next(&ReplaceNext, cx)
});
// Notice how the first or in the text (shORtened) is not replaced. Neither are the remaining hits of `or` in the text.
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;[1] also referred to as
rational expr$1[2][3]) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Let's turn on regex mode.
search_bar
.update(cx, |search_bar, cx| {
search_bar.activate_search_mode(SearchMode::Regex, cx);
search_bar.search("\\[([^\\]]+)\\]", None, cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("${1}number", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;1number also referred to as
rational expr$12number3number) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching algorithms
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
// Now with a whole-word twist.
search_bar
.update(cx, |search_bar, cx| {
search_bar.activate_search_mode(SearchMode::Regex, cx);
search_bar.search("a\\w+s", Some(SearchOptions::WHOLE_WORD), cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.replacement_editor.update(cx, |editor, cx| {
editor.set_text("things", cx);
});
search_bar.replace_all(&ReplaceAll, cx)
});
// The only word affected by this edit should be `algorithms`, even though there's a bunch
// of words in this text that would match this regex if not for WHOLE_WORD.
assert_eq!(
editor.read_with(cx, |this, cx| { this.text(cx) }),
r#"
A regular expr$1 (shortened as regex banana regexp;1number also referred to as
rational expr$12number3number) is a sequence of characters that specifies a search
pattern in text. Usually such patterns are used by string-searching things
for "find" or "find and replace" operations on strings, or for input validation.
"#
.unindent()
);
}
}

View File

@ -8,7 +8,9 @@ use gpui::{
pub use mode::SearchMode;
use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView};
use theme::components::{action_button::Button, svg::Svg, ComponentExt, ToggleIconButtonStyle};
use theme::components::{
action_button::Button, svg::Svg, ComponentExt, IconButtonStyle, ToggleIconButtonStyle,
};
pub mod buffer_search;
mod history;
@ -27,6 +29,7 @@ actions!(
CycleMode,
ToggleWholeWord,
ToggleCaseSensitive,
ToggleReplace,
SelectNextMatch,
SelectPrevMatch,
SelectAllMatches,
@ -34,7 +37,9 @@ actions!(
PreviousHistoryQuery,
ActivateTextMode,
ActivateSemanticMode,
ActivateRegexMode
ActivateRegexMode,
ReplaceAll,
ReplaceNext
]
);
@ -98,3 +103,32 @@ impl SearchOptions {
.into_any()
}
}
fn toggle_replace_button<V: View>(
active: bool,
tooltip_style: TooltipStyle,
button_style: ToggleIconButtonStyle,
) -> AnyElement<V> {
Button::dynamic_action(Box::new(ToggleReplace))
.with_tooltip("Toggle replace", tooltip_style)
.with_contents(theme::components::svg::Svg::new("icons/replace.svg"))
.toggleable(active)
.with_style(button_style)
.element()
.into_any()
}
fn replace_action<V: View>(
action: impl Action,
name: &'static str,
icon_path: &'static str,
tooltip_style: TooltipStyle,
button_style: IconButtonStyle,
) -> AnyElement<V> {
Button::dynamic_action(Box::new(action))
.with_tooltip(name, tooltip_style)
.with_contents(theme::components::svg::Svg::new(icon_path))
.with_style(button_style)
.element()
.into_any()
}

View File

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

View File

@ -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<Interactive<IconButton>>,
pub option_button_component: ToggleIconButtonStyle,
pub action_button: Toggleable<Interactive<ContainedText>>,
pub match_background: Color,
pub match_index: ContainedText,
pub major_results_status: TextStyle,
@ -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<Interactive<IconButton>>,
pub action_button: IconButtonStyle,
}
#[derive(Clone, Deserialize, Default, JsonSchema)]

View File

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

View File

@ -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(),
}
}