search: Fix some inconsistencies between project and buffer search bars (#36103)

- project search query string now turns red when no results are found
matching buffer search behavior
- General code deduplication as well as more consistent layout between
the two bars, as some minor details have drifted apart
- Tab cycling in buffer search now ends up in editor focus when cycling
backwards, matching forward cycling
- Report parse errors in filter include and exclude editors

Release Notes:

- N/A
This commit is contained in:
Lukas Wirth 2025-08-15 09:56:47 +02:00 committed by GitHub
parent 23d0433158
commit 8d6982e78f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 545 additions and 689 deletions

View file

@ -3,20 +3,23 @@ mod registrar;
use crate::{
FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext, SearchOptions,
SelectAllMatches, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, ToggleRegex,
ToggleReplace, ToggleSelection, ToggleWholeWord, search_bar::render_nav_button,
ToggleReplace, ToggleSelection, ToggleWholeWord,
search_bar::{
input_base_styles, render_action_button, render_text_input, toggle_replace_button,
},
};
use any_vec::AnyVec;
use anyhow::Context as _;
use collections::HashMap;
use editor::{
DisplayPoint, Editor, EditorElement, EditorSettings, EditorStyle,
DisplayPoint, Editor, EditorSettings,
actions::{Backtab, Tab},
};
use futures::channel::oneshot;
use gpui::{
Action, App, ClickEvent, Context, Entity, EventEmitter, FocusHandle, Focusable,
InteractiveElement as _, IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle,
Styled, Subscription, Task, TextStyle, Window, actions, div,
Action, App, ClickEvent, Context, Entity, EventEmitter, Focusable, InteractiveElement as _,
IntoElement, KeyContext, ParentElement as _, Render, ScrollHandle, Styled, Subscription, Task,
Window, actions, div,
};
use language::{Language, LanguageRegistry};
use project::{
@ -27,7 +30,6 @@ use schemars::JsonSchema;
use serde::Deserialize;
use settings::Settings;
use std::sync::Arc;
use theme::ThemeSettings;
use zed_actions::outline::ToggleOutline;
use ui::{
@ -125,46 +127,6 @@ pub struct BufferSearchBar {
}
impl BufferSearchBar {
fn render_text_input(
&self,
editor: &Entity<Editor>,
color_override: Option<Color>,
cx: &mut Context<Self>,
) -> impl IntoElement {
let (color, use_syntax) = if editor.read(cx).read_only(cx) {
(cx.theme().colors().text_disabled, false)
} else {
match color_override {
Some(color_override) => (color_override.color(cx), false),
None => (cx.theme().colors().text, true),
}
};
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color,
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_size: rems(0.875).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(1.3),
..TextStyle::default()
};
let mut editor_style = EditorStyle {
background: cx.theme().colors().toolbar_background,
local_player: cx.theme().players().local(),
text: text_style,
..EditorStyle::default()
};
if use_syntax {
editor_style.syntax = cx.theme().syntax().clone();
}
EditorElement::new(editor, editor_style)
}
pub fn query_editor_focused(&self) -> bool {
self.query_editor_focused
}
@ -185,7 +147,14 @@ impl Render for BufferSearchBar {
let hide_inline_icons = self.editor_needed_width
> self.editor_scroll_handle.bounds().size.width - window.rem_size() * 6.;
let supported_options = self.supported_options(cx);
let workspace::searchable::SearchOptions {
case,
word,
regex,
replacement,
selection,
find_in_results,
} = self.supported_options(cx);
if self.query_editor.update(cx, |query_editor, _cx| {
query_editor.placeholder_text().is_none()
@ -220,268 +189,205 @@ impl Render for BufferSearchBar {
}
})
.unwrap_or_else(|| "0/0".to_string());
let should_show_replace_input = self.replace_enabled && supported_options.replacement;
let should_show_replace_input = self.replace_enabled && replacement;
let in_replace = self.replacement_editor.focus_handle(cx).is_focused(window);
let theme_colors = cx.theme().colors();
let query_border = if self.query_error.is_some() {
Color::Error.color(cx)
} else {
theme_colors.border
};
let replacement_border = theme_colors.border;
let container_width = window.viewport_size().width;
let input_width = SearchInputWidth::calc_width(container_width);
let input_base_styles =
|border_color| input_base_styles(border_color, |div| div.w(input_width));
let query_column = input_base_styles(query_border)
.id("editor-scroll")
.track_scroll(&self.editor_scroll_handle)
.child(render_text_input(&self.query_editor, color_override, cx))
.when(!hide_inline_icons, |div| {
div.child(
h_flex()
.gap_1()
.when(case, |div| {
div.child(SearchOptions::CASE_SENSITIVE.as_button(
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_case_sensitive(&ToggleCaseSensitive, window, cx)
}),
))
})
.when(word, |div| {
div.child(SearchOptions::WHOLE_WORD.as_button(
self.search_options.contains(SearchOptions::WHOLE_WORD),
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_whole_word(&ToggleWholeWord, window, cx)
}),
))
})
.when(regex, |div| {
div.child(SearchOptions::REGEX.as_button(
self.search_options.contains(SearchOptions::REGEX),
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_regex(&ToggleRegex, window, cx)
}),
))
}),
)
});
let mode_column = h_flex()
.gap_1()
.min_w_64()
.when(replacement, |this| {
this.child(toggle_replace_button(
"buffer-search-bar-toggle-replace-button",
focus_handle.clone(),
self.replace_enabled,
cx.listener(|this, _: &ClickEvent, window, cx| {
this.toggle_replace(&ToggleReplace, window, cx);
}),
))
})
.when(selection, |this| {
this.child(
IconButton::new(
"buffer-search-bar-toggle-search-selection-button",
IconName::Quote,
)
.style(ButtonStyle::Subtle)
.shape(IconButtonShape::Square)
.when(self.selection_search_enabled, |button| {
button.style(ButtonStyle::Filled)
})
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.toggle_selection(&ToggleSelection, window, cx);
}))
.toggle_state(self.selection_search_enabled)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Search Selection",
&ToggleSelection,
&focus_handle,
window,
cx,
)
}
}),
)
})
.when(!find_in_results, |el| {
let query_focus = self.query_editor.focus_handle(cx);
let matches_column = h_flex()
.pl_2()
.ml_2()
.border_l_1()
.border_color(theme_colors.border_variant)
.child(render_action_button(
"buffer-search-nav-button",
ui::IconName::ChevronLeft,
self.active_match_index.is_some(),
"Select Previous Match",
&SelectPreviousMatch,
query_focus.clone(),
))
.child(render_action_button(
"buffer-search-nav-button",
ui::IconName::ChevronRight,
self.active_match_index.is_some(),
"Select Next Match",
&SelectNextMatch,
query_focus.clone(),
))
.when(!narrow_mode, |this| {
this.child(div().ml_2().min_w(rems_from_px(40.)).child(
Label::new(match_text).size(LabelSize::Small).color(
if self.active_match_index.is_some() {
Color::Default
} else {
Color::Disabled
},
),
))
});
el.child(render_action_button(
"buffer-search-nav-button",
IconName::SelectAll,
true,
"Select All Matches",
&SelectAllMatches,
query_focus,
))
.child(matches_column)
})
.when(find_in_results, |el| {
el.child(render_action_button(
"buffer-search",
IconName::Close,
true,
"Close Search Bar",
&Dismiss,
focus_handle.clone(),
))
});
let search_line = h_flex()
.w_full()
.gap_2()
.when(find_in_results, |el| {
el.child(Label::new("Find in results").color(Color::Hint))
})
.child(query_column)
.child(mode_column);
let replace_line =
should_show_replace_input.then(|| {
let replace_column = input_base_styles(replacement_border)
.child(render_text_input(&self.replacement_editor, None, cx));
let focus_handle = self.replacement_editor.read(cx).focus_handle(cx);
let replace_actions = h_flex()
.min_w_64()
.gap_1()
.child(render_action_button(
"buffer-search-replace-button",
IconName::ReplaceNext,
true,
"Replace Next Match",
&ReplaceNext,
focus_handle.clone(),
))
.child(render_action_button(
"buffer-search-replace-button",
IconName::ReplaceAll,
true,
"Replace All Matches",
&ReplaceAll,
focus_handle,
));
h_flex()
.w_full()
.gap_2()
.child(replace_column)
.child(replace_actions)
});
let mut key_context = KeyContext::new_with_defaults();
key_context.add("BufferSearchBar");
if in_replace {
key_context.add("in_replace");
}
let query_border = if self.query_error.is_some() {
Color::Error.color(cx)
} else {
cx.theme().colors().border
};
let replacement_border = cx.theme().colors().border;
let container_width = window.viewport_size().width;
let input_width = SearchInputWidth::calc_width(container_width);
let input_base_styles = |border_color| {
h_flex()
.min_w_32()
.w(input_width)
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(border_color)
.rounded_lg()
};
let search_line = h_flex()
.gap_2()
.when(supported_options.find_in_results, |el| {
el.child(Label::new("Find in results").color(Color::Hint))
})
.child(
input_base_styles(query_border)
.id("editor-scroll")
.track_scroll(&self.editor_scroll_handle)
.child(self.render_text_input(&self.query_editor, color_override, cx))
.when(!hide_inline_icons, |div| {
div.child(
h_flex()
.gap_1()
.children(supported_options.case.then(|| {
self.render_search_option_button(
SearchOptions::CASE_SENSITIVE,
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_case_sensitive(
&ToggleCaseSensitive,
window,
cx,
)
}),
)
}))
.children(supported_options.word.then(|| {
self.render_search_option_button(
SearchOptions::WHOLE_WORD,
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_whole_word(&ToggleWholeWord, window, cx)
}),
)
}))
.children(supported_options.regex.then(|| {
self.render_search_option_button(
SearchOptions::REGEX,
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_regex(&ToggleRegex, window, cx)
}),
)
})),
)
}),
)
.child(
h_flex()
.gap_1()
.min_w_64()
.when(supported_options.replacement, |this| {
this.child(
IconButton::new(
"buffer-search-bar-toggle-replace-button",
IconName::Replace,
)
.style(ButtonStyle::Subtle)
.shape(IconButtonShape::Square)
.when(self.replace_enabled, |button| {
button.style(ButtonStyle::Filled)
})
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.toggle_replace(&ToggleReplace, window, cx);
}))
.toggle_state(self.replace_enabled)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Replace",
&ToggleReplace,
&focus_handle,
window,
cx,
)
}
}),
)
})
.when(supported_options.selection, |this| {
this.child(
IconButton::new(
"buffer-search-bar-toggle-search-selection-button",
IconName::Quote,
)
.style(ButtonStyle::Subtle)
.shape(IconButtonShape::Square)
.when(self.selection_search_enabled, |button| {
button.style(ButtonStyle::Filled)
})
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.toggle_selection(&ToggleSelection, window, cx);
}))
.toggle_state(self.selection_search_enabled)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Search Selection",
&ToggleSelection,
&focus_handle,
window,
cx,
)
}
}),
)
})
.when(!supported_options.find_in_results, |el| {
el.child(
IconButton::new("select-all", ui::IconName::SelectAll)
.on_click(|_, window, cx| {
window.dispatch_action(SelectAllMatches.boxed_clone(), cx)
})
.shape(IconButtonShape::Square)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Select All Matches",
&SelectAllMatches,
&focus_handle,
window,
cx,
)
}
}),
)
.child(
h_flex()
.pl_2()
.ml_1()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.child(render_nav_button(
ui::IconName::ChevronLeft,
self.active_match_index.is_some(),
"Select Previous Match",
&SelectPreviousMatch,
focus_handle.clone(),
))
.child(render_nav_button(
ui::IconName::ChevronRight,
self.active_match_index.is_some(),
"Select Next Match",
&SelectNextMatch,
focus_handle.clone(),
)),
)
.when(!narrow_mode, |this| {
this.child(h_flex().ml_2().min_w(rems_from_px(40.)).child(
Label::new(match_text).size(LabelSize::Small).color(
if self.active_match_index.is_some() {
Color::Default
} else {
Color::Disabled
},
),
))
})
})
.when(supported_options.find_in_results, |el| {
el.child(
IconButton::new(SharedString::from("Close"), IconName::Close)
.shape(IconButtonShape::Square)
.tooltip(move |window, cx| {
Tooltip::for_action("Close Search Bar", &Dismiss, window, cx)
})
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.dismiss(&Dismiss, window, cx)
})),
)
}),
);
let replace_line = should_show_replace_input.then(|| {
h_flex()
.gap_2()
.child(
input_base_styles(replacement_border).child(self.render_text_input(
&self.replacement_editor,
None,
cx,
)),
)
.child(
h_flex()
.min_w_64()
.gap_1()
.child(
IconButton::new("search-replace-next", ui::IconName::ReplaceNext)
.shape(IconButtonShape::Square)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Replace Next Match",
&ReplaceNext,
&focus_handle,
window,
cx,
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.replace_next(&ReplaceNext, window, cx)
})),
)
.child(
IconButton::new("search-replace-all", ui::IconName::ReplaceAll)
.shape(IconButtonShape::Square)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Replace All Matches",
&ReplaceAll,
&focus_handle,
window,
cx,
)
}
})
.on_click(cx.listener(|this, _, window, cx| {
this.replace_all(&ReplaceAll, window, cx)
})),
),
)
});
let query_error_line = self.query_error.as_ref().map(|error| {
Label::new(error)
@ -491,10 +397,26 @@ impl Render for BufferSearchBar {
.ml_2()
});
let search_line =
h_flex()
.relative()
.child(search_line)
.when(!narrow_mode && !find_in_results, |div| {
div.child(h_flex().absolute().right_0().child(render_action_button(
"buffer-search",
IconName::Close,
true,
"Close Search Bar",
&Dismiss,
focus_handle.clone(),
)))
.w_full()
});
v_flex()
.id("buffer_search")
.gap_2()
.py(px(1.0))
.w_full()
.track_scroll(&self.scroll_handle)
.key_context(key_context)
.capture_action(cx.listener(Self::tab))
@ -509,43 +431,26 @@ impl Render for BufferSearchBar {
active_searchable_item.relay_action(Box::new(ToggleOutline), window, cx);
}
}))
.when(self.supported_options(cx).replacement, |this| {
.when(replacement, |this| {
this.on_action(cx.listener(Self::toggle_replace))
.when(in_replace, |this| {
this.on_action(cx.listener(Self::replace_next))
.on_action(cx.listener(Self::replace_all))
})
})
.when(self.supported_options(cx).case, |this| {
.when(case, |this| {
this.on_action(cx.listener(Self::toggle_case_sensitive))
})
.when(self.supported_options(cx).word, |this| {
.when(word, |this| {
this.on_action(cx.listener(Self::toggle_whole_word))
})
.when(self.supported_options(cx).regex, |this| {
.when(regex, |this| {
this.on_action(cx.listener(Self::toggle_regex))
})
.when(self.supported_options(cx).selection, |this| {
.when(selection, |this| {
this.on_action(cx.listener(Self::toggle_selection))
})
.child(h_flex().relative().child(search_line.w_full()).when(
!narrow_mode && !supported_options.find_in_results,
|div| {
div.child(
h_flex().absolute().right_0().child(
IconButton::new(SharedString::from("Close"), IconName::Close)
.shape(IconButtonShape::Square)
.tooltip(move |window, cx| {
Tooltip::for_action("Close Search Bar", &Dismiss, window, cx)
})
.on_click(cx.listener(|this, _: &ClickEvent, window, cx| {
this.dismiss(&Dismiss, window, cx)
})),
),
)
.w_full()
},
))
.child(search_line)
.children(query_error_line)
.children(replace_line)
}
@ -792,7 +697,7 @@ impl BufferSearchBar {
active_editor.search_bar_visibility_changed(false, window, cx);
active_editor.toggle_filtered_search_ranges(false, window, cx);
let handle = active_editor.item_focus_handle(cx);
self.focus(&handle, window, cx);
self.focus(&handle, window);
}
cx.emit(Event::UpdateLocation);
cx.emit(ToolbarItemEvent::ChangeLocation(
@ -948,7 +853,7 @@ impl BufferSearchBar {
}
pub fn focus_replace(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.focus(&self.replacement_editor.focus_handle(cx), window, cx);
self.focus(&self.replacement_editor.focus_handle(cx), window);
cx.notify();
}
@ -975,16 +880,6 @@ impl BufferSearchBar {
self.update_matches(!updated, window, cx)
}
fn render_search_option_button<Action: Fn(&ClickEvent, &mut Window, &mut App) + 'static>(
&self,
option: SearchOptions,
focus_handle: FocusHandle,
action: Action,
) -> impl IntoElement + use<Action> {
let is_active = self.search_options.contains(option);
option.as_button(is_active, focus_handle, action)
}
pub fn focus_editor(&mut self, _: &FocusEditor, window: &mut Window, cx: &mut Context<Self>) {
if let Some(active_editor) = self.active_searchable_item.as_ref() {
let handle = active_editor.item_focus_handle(cx);
@ -1400,28 +1295,32 @@ impl BufferSearchBar {
}
fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
// Search -> Replace -> Editor
let focus_handle = if self.replace_enabled && self.query_editor_focused {
self.replacement_editor.focus_handle(cx)
} else if let Some(item) = self.active_searchable_item.as_ref() {
item.item_focus_handle(cx)
} else {
return;
};
self.focus(&focus_handle, window, cx);
cx.stop_propagation();
self.cycle_field(Direction::Next, window, cx);
}
fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
// Search -> Replace -> Search
let focus_handle = if self.replace_enabled && self.query_editor_focused {
self.replacement_editor.focus_handle(cx)
} else if self.replacement_editor_focused {
self.query_editor.focus_handle(cx)
} else {
return;
self.cycle_field(Direction::Prev, window, cx);
}
fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
let mut handles = vec![self.query_editor.focus_handle(cx)];
if self.replace_enabled {
handles.push(self.replacement_editor.focus_handle(cx));
}
if let Some(item) = self.active_searchable_item.as_ref() {
handles.push(item.item_focus_handle(cx));
}
let current_index = match handles.iter().position(|focus| focus.is_focused(window)) {
Some(index) => index,
None => return,
};
self.focus(&focus_handle, window, cx);
let new_index = match direction {
Direction::Next => (current_index + 1) % handles.len(),
Direction::Prev if current_index == 0 => handles.len() - 1,
Direction::Prev => (current_index - 1) % handles.len(),
};
let next_focus_handle = &handles[new_index];
self.focus(next_focus_handle, window);
cx.stop_propagation();
}
@ -1469,10 +1368,8 @@ impl BufferSearchBar {
}
}
fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window, cx: &mut Context<Self>) {
cx.on_next_frame(window, |_, window, _| {
window.invalidate_character_coordinates();
});
fn focus(&self, handle: &gpui::FocusHandle, window: &mut Window) {
window.invalidate_character_coordinates();
window.focus(handle);
}
@ -1484,7 +1381,7 @@ impl BufferSearchBar {
} else {
self.query_editor.focus_handle(cx)
};
self.focus(&handle, window, cx);
self.focus(&handle, window);
cx.notify();
}
}

View file

@ -1,20 +1,25 @@
use crate::{
BufferSearchBar, FocusSearch, NextHistoryQuery, PreviousHistoryQuery, ReplaceAll, ReplaceNext,
SearchOptions, SelectNextMatch, SelectPreviousMatch, ToggleCaseSensitive, ToggleIncludeIgnored,
ToggleRegex, ToggleReplace, ToggleWholeWord, buffer_search::Deploy,
ToggleRegex, ToggleReplace, ToggleWholeWord,
buffer_search::Deploy,
search_bar::{
input_base_styles, render_action_button, render_text_input, toggle_replace_button,
},
};
use anyhow::Context as _;
use collections::{HashMap, HashSet};
use collections::HashMap;
use editor::{
Anchor, Editor, EditorElement, EditorEvent, EditorSettings, EditorStyle, MAX_TAB_TITLE_LEN,
MultiBuffer, SelectionEffects, actions::SelectAll, items::active_match_index,
Anchor, Editor, EditorEvent, EditorSettings, MAX_TAB_TITLE_LEN, MultiBuffer, SelectionEffects,
actions::{Backtab, SelectAll, Tab},
items::active_match_index,
};
use futures::{StreamExt, stream::FuturesOrdered};
use gpui::{
Action, AnyElement, AnyView, App, Axis, Context, Entity, EntityId, EventEmitter, FocusHandle,
Focusable, Global, Hsla, InteractiveElement, IntoElement, KeyContext, ParentElement, Point,
Render, SharedString, Styled, Subscription, Task, TextStyle, UpdateGlobal, WeakEntity, Window,
actions, div,
Render, SharedString, Styled, Subscription, Task, UpdateGlobal, WeakEntity, Window, actions,
div,
};
use language::{Buffer, Language};
use menu::Confirm;
@ -32,7 +37,6 @@ use std::{
pin::pin,
sync::Arc,
};
use theme::ThemeSettings;
use ui::{
Icon, IconButton, IconButtonShape, IconName, KeyBinding, Label, LabelCommon, LabelSize,
Toggleable, Tooltip, h_flex, prelude::*, utils::SearchInputWidth, v_flex,
@ -208,7 +212,7 @@ pub struct ProjectSearchView {
replacement_editor: Entity<Editor>,
results_editor: Entity<Editor>,
search_options: SearchOptions,
panels_with_errors: HashSet<InputPanel>,
panels_with_errors: HashMap<InputPanel, String>,
active_match_index: Option<usize>,
search_id: usize,
included_files_editor: Entity<Editor>,
@ -218,7 +222,6 @@ pub struct ProjectSearchView {
included_opened_only: bool,
regex_language: Option<Arc<Language>>,
_subscriptions: Vec<Subscription>,
query_error: Option<String>,
}
#[derive(Debug, Clone)]
@ -879,7 +882,7 @@ impl ProjectSearchView {
query_editor,
results_editor,
search_options: options,
panels_with_errors: HashSet::default(),
panels_with_errors: HashMap::default(),
active_match_index: None,
included_files_editor,
excluded_files_editor,
@ -888,7 +891,6 @@ impl ProjectSearchView {
included_opened_only: false,
regex_language: None,
_subscriptions: subscriptions,
query_error: None,
};
this.entity_changed(window, cx);
this
@ -1152,14 +1154,16 @@ impl ProjectSearchView {
Ok(included_files) => {
let should_unmark_error =
self.panels_with_errors.remove(&InputPanel::Include);
if should_unmark_error {
if should_unmark_error.is_some() {
cx.notify();
}
included_files
}
Err(_e) => {
let should_mark_error = self.panels_with_errors.insert(InputPanel::Include);
if should_mark_error {
Err(e) => {
let should_mark_error = self
.panels_with_errors
.insert(InputPanel::Include, e.to_string());
if should_mark_error.is_none() {
cx.notify();
}
PathMatcher::default()
@ -1174,15 +1178,17 @@ impl ProjectSearchView {
Ok(excluded_files) => {
let should_unmark_error =
self.panels_with_errors.remove(&InputPanel::Exclude);
if should_unmark_error {
if should_unmark_error.is_some() {
cx.notify();
}
excluded_files
}
Err(_e) => {
let should_mark_error = self.panels_with_errors.insert(InputPanel::Exclude);
if should_mark_error {
Err(e) => {
let should_mark_error = self
.panels_with_errors
.insert(InputPanel::Exclude, e.to_string());
if should_mark_error.is_none() {
cx.notify();
}
PathMatcher::default()
@ -1219,19 +1225,19 @@ impl ProjectSearchView {
) {
Ok(query) => {
let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
if should_unmark_error {
if should_unmark_error.is_some() {
cx.notify();
}
self.query_error = None;
Some(query)
}
Err(e) => {
let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
if should_mark_error {
let should_mark_error = self
.panels_with_errors
.insert(InputPanel::Query, e.to_string());
if should_mark_error.is_none() {
cx.notify();
}
self.query_error = Some(e.to_string());
None
}
@ -1249,15 +1255,17 @@ impl ProjectSearchView {
) {
Ok(query) => {
let should_unmark_error = self.panels_with_errors.remove(&InputPanel::Query);
if should_unmark_error {
if should_unmark_error.is_some() {
cx.notify();
}
Some(query)
}
Err(_e) => {
let should_mark_error = self.panels_with_errors.insert(InputPanel::Query);
if should_mark_error {
Err(e) => {
let should_mark_error = self
.panels_with_errors
.insert(InputPanel::Query, e.to_string());
if should_mark_error.is_none() {
cx.notify();
}
@ -1512,7 +1520,7 @@ impl ProjectSearchView {
}
fn border_color_for(&self, panel: InputPanel, cx: &App) -> Hsla {
if self.panels_with_errors.contains(&panel) {
if self.panels_with_errors.contains_key(&panel) {
Color::Error.color(cx)
} else {
cx.theme().colors().border
@ -1610,16 +1618,11 @@ impl ProjectSearchBar {
}
}
fn tab(&mut self, _: &editor::actions::Tab, window: &mut Window, cx: &mut Context<Self>) {
fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context<Self>) {
self.cycle_field(Direction::Next, window, cx);
}
fn backtab(
&mut self,
_: &editor::actions::Backtab,
window: &mut Window,
cx: &mut Context<Self>,
) {
fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context<Self>) {
self.cycle_field(Direction::Prev, window, cx);
}
@ -1634,29 +1637,22 @@ impl ProjectSearchBar {
fn cycle_field(&mut self, direction: Direction, window: &mut Window, cx: &mut Context<Self>) {
let active_project_search = match &self.active_project_search {
Some(active_project_search) => active_project_search,
None => {
return;
}
None => return,
};
active_project_search.update(cx, |project_view, cx| {
let mut views = vec![&project_view.query_editor];
let mut views = vec![project_view.query_editor.focus_handle(cx)];
if project_view.replace_enabled {
views.push(&project_view.replacement_editor);
views.push(project_view.replacement_editor.focus_handle(cx));
}
if project_view.filters_enabled {
views.extend([
&project_view.included_files_editor,
&project_view.excluded_files_editor,
project_view.included_files_editor.focus_handle(cx),
project_view.excluded_files_editor.focus_handle(cx),
]);
}
let current_index = match views
.iter()
.enumerate()
.find(|(_, editor)| editor.focus_handle(cx).is_focused(window))
{
Some((index, _)) => index,
let current_index = match views.iter().position(|focus| focus.is_focused(window)) {
Some(index) => index,
None => return,
};
@ -1665,8 +1661,8 @@ impl ProjectSearchBar {
Direction::Prev if current_index == 0 => views.len() - 1,
Direction::Prev => (current_index - 1) % views.len(),
};
let next_focus_handle = views[new_index].focus_handle(cx);
window.focus(&next_focus_handle);
let next_focus_handle = &views[new_index];
window.focus(next_focus_handle);
cx.stop_propagation();
});
}
@ -1915,37 +1911,6 @@ impl ProjectSearchBar {
})
}
}
fn render_text_input(&self, editor: &Entity<Editor>, cx: &Context<Self>) -> impl IntoElement {
let (color, use_syntax) = if editor.read(cx).read_only(cx) {
(cx.theme().colors().text_disabled, false)
} else {
(cx.theme().colors().text, true)
};
let settings = ThemeSettings::get_global(cx);
let text_style = TextStyle {
color,
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_size: rems(0.875).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(1.3),
..TextStyle::default()
};
let mut editor_style = EditorStyle {
background: cx.theme().colors().toolbar_background,
local_player: cx.theme().players().local(),
text: text_style,
..EditorStyle::default()
};
if use_syntax {
editor_style.syntax = cx.theme().syntax().clone();
}
EditorElement::new(editor, editor_style)
}
}
impl Render for ProjectSearchBar {
@ -1959,28 +1924,43 @@ impl Render for ProjectSearchBar {
let container_width = window.viewport_size().width;
let input_width = SearchInputWidth::calc_width(container_width);
enum BaseStyle {
SingleInput,
MultipleInputs,
}
let input_base_styles = |base_style: BaseStyle, panel: InputPanel| {
h_flex()
.min_w_32()
.map(|div| match base_style {
BaseStyle::SingleInput => div.w(input_width),
BaseStyle::MultipleInputs => div.flex_grow(),
})
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(search.border_color_for(panel, cx))
.rounded_lg()
let input_base_styles = |panel: InputPanel| {
input_base_styles(search.border_color_for(panel, cx), |div| match panel {
InputPanel::Query | InputPanel::Replacement => div.w(input_width),
InputPanel::Include | InputPanel::Exclude => div.flex_grow(),
})
};
let theme_colors = cx.theme().colors();
let project_search = search.entity.read(cx);
let limit_reached = project_search.limit_reached;
let query_column = input_base_styles(BaseStyle::SingleInput, InputPanel::Query)
let color_override = match (
project_search.no_results,
&project_search.active_query,
&project_search.last_search_query_text,
) {
(Some(true), Some(q), Some(p)) if q.as_str() == p => Some(Color::Error),
_ => None,
};
let match_text = search
.active_match_index
.and_then(|index| {
let index = index + 1;
let match_quantity = project_search.match_ranges.len();
if match_quantity > 0 {
debug_assert!(match_quantity >= index);
if limit_reached {
Some(format!("{index}/{match_quantity}+"))
} else {
Some(format!("{index}/{match_quantity}"))
}
} else {
None
}
})
.unwrap_or_else(|| "0/0".to_string());
let query_column = input_base_styles(InputPanel::Query)
.on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
.on_action(cx.listener(|this, action, window, cx| {
this.previous_history_query(action, window, cx)
@ -1988,7 +1968,7 @@ impl Render for ProjectSearchBar {
.on_action(
cx.listener(|this, action, window, cx| this.next_history_query(action, window, cx)),
)
.child(self.render_text_input(&search.query_editor, cx))
.child(render_text_input(&search.query_editor, color_override, cx))
.child(
h_flex()
.gap_1()
@ -2017,6 +1997,7 @@ impl Render for ProjectSearchBar {
let mode_column = h_flex()
.gap_1()
.min_w_64()
.child(
IconButton::new("project-search-filter-button", IconName::Filter)
.shape(IconButtonShape::Square)
@ -2045,109 +2026,46 @@ impl Render for ProjectSearchBar {
}
}),
)
.child(
IconButton::new("project-search-toggle-replace", IconName::Replace)
.shape(IconButtonShape::Square)
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_replace(&ToggleReplace, window, cx);
}))
.toggle_state(
self.active_project_search
.as_ref()
.map(|search| search.read(cx).replace_enabled)
.unwrap_or_default(),
)
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Toggle Replace",
&ToggleReplace,
&focus_handle,
window,
cx,
)
}
}),
);
.child(toggle_replace_button(
"project-search-toggle-replace",
focus_handle.clone(),
self.active_project_search
.as_ref()
.map(|search| search.read(cx).replace_enabled)
.unwrap_or_default(),
cx.listener(|this, _, window, cx| {
this.toggle_replace(&ToggleReplace, window, cx);
}),
));
let limit_reached = search.entity.read(cx).limit_reached;
let match_text = search
.active_match_index
.and_then(|index| {
let index = index + 1;
let match_quantity = search.entity.read(cx).match_ranges.len();
if match_quantity > 0 {
debug_assert!(match_quantity >= index);
if limit_reached {
Some(format!("{index}/{match_quantity}+"))
} else {
Some(format!("{index}/{match_quantity}"))
}
} else {
None
}
})
.unwrap_or_else(|| "0/0".to_string());
let query_focus = search.query_editor.focus_handle(cx);
let matches_column = h_flex()
.pl_2()
.ml_2()
.border_l_1()
.border_color(cx.theme().colors().border_variant)
.child(
IconButton::new("project-search-prev-match", IconName::ChevronLeft)
.shape(IconButtonShape::Square)
.disabled(search.active_match_index.is_none())
.on_click(cx.listener(|this, _, window, cx| {
if let Some(search) = this.active_project_search.as_ref() {
search.update(cx, |this, cx| {
this.select_match(Direction::Prev, window, cx);
})
}
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go To Previous Match",
&SelectPreviousMatch,
&focus_handle,
window,
cx,
)
}
}),
)
.child(
IconButton::new("project-search-next-match", IconName::ChevronRight)
.shape(IconButtonShape::Square)
.disabled(search.active_match_index.is_none())
.on_click(cx.listener(|this, _, window, cx| {
if let Some(search) = this.active_project_search.as_ref() {
search.update(cx, |this, cx| {
this.select_match(Direction::Next, window, cx);
})
}
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Go To Next Match",
&SelectNextMatch,
&focus_handle,
window,
cx,
)
}
}),
)
.border_color(theme_colors.border_variant)
.child(render_action_button(
"project-search-nav-button",
IconName::ChevronLeft,
search.active_match_index.is_some(),
"Select Previous Match",
&SelectPreviousMatch,
query_focus.clone(),
))
.child(render_action_button(
"project-search-nav-button",
IconName::ChevronRight,
search.active_match_index.is_some(),
"Select Next Match",
&SelectNextMatch,
query_focus,
))
.child(
div()
.id("matches")
.ml_1()
.ml_2()
.min_w(rems_from_px(40.))
.child(Label::new(match_text).size(LabelSize::Small).color(
if search.active_match_index.is_some() {
Color::Default
@ -2169,63 +2087,30 @@ impl Render for ProjectSearchBar {
.child(h_flex().min_w_64().child(mode_column).child(matches_column));
let replace_line = search.replace_enabled.then(|| {
let replace_column = input_base_styles(BaseStyle::SingleInput, InputPanel::Replacement)
.child(self.render_text_input(&search.replacement_editor, cx));
let replace_column = input_base_styles(InputPanel::Replacement)
.child(render_text_input(&search.replacement_editor, None, cx));
let focus_handle = search.replacement_editor.read(cx).focus_handle(cx);
let replace_actions =
h_flex()
.min_w_64()
.gap_1()
.when(search.replace_enabled, |this| {
this.child(
IconButton::new("project-search-replace-next", IconName::ReplaceNext)
.shape(IconButtonShape::Square)
.on_click(cx.listener(|this, _, window, cx| {
if let Some(search) = this.active_project_search.as_ref() {
search.update(cx, |this, cx| {
this.replace_next(&ReplaceNext, window, cx);
})
}
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Replace Next Match",
&ReplaceNext,
&focus_handle,
window,
cx,
)
}
}),
)
.child(
IconButton::new("project-search-replace-all", IconName::ReplaceAll)
.shape(IconButtonShape::Square)
.on_click(cx.listener(|this, _, window, cx| {
if let Some(search) = this.active_project_search.as_ref() {
search.update(cx, |this, cx| {
this.replace_all(&ReplaceAll, window, cx);
})
}
}))
.tooltip({
let focus_handle = focus_handle.clone();
move |window, cx| {
Tooltip::for_action_in(
"Replace All Matches",
&ReplaceAll,
&focus_handle,
window,
cx,
)
}
}),
)
});
let replace_actions = h_flex()
.min_w_64()
.gap_1()
.child(render_action_button(
"project-search-replace-button",
IconName::ReplaceNext,
true,
"Replace Next Match",
&ReplaceNext,
focus_handle.clone(),
))
.child(render_action_button(
"project-search-replace-button",
IconName::ReplaceAll,
true,
"Replace All Matches",
&ReplaceAll,
focus_handle,
));
h_flex()
.w_full()
@ -2235,6 +2120,45 @@ impl Render for ProjectSearchBar {
});
let filter_line = search.filters_enabled.then(|| {
let include = input_base_styles(InputPanel::Include)
.on_action(cx.listener(|this, action, window, cx| {
this.previous_history_query(action, window, cx)
}))
.on_action(cx.listener(|this, action, window, cx| {
this.next_history_query(action, window, cx)
}))
.child(render_text_input(&search.included_files_editor, None, cx));
let exclude = input_base_styles(InputPanel::Exclude)
.on_action(cx.listener(|this, action, window, cx| {
this.previous_history_query(action, window, cx)
}))
.on_action(cx.listener(|this, action, window, cx| {
this.next_history_query(action, window, cx)
}))
.child(render_text_input(&search.excluded_files_editor, None, cx));
let mode_column = h_flex()
.gap_1()
.min_w_64()
.child(
IconButton::new("project-search-opened-only", IconName::FolderSearch)
.shape(IconButtonShape::Square)
.toggle_state(self.is_opened_only_enabled(cx))
.tooltip(Tooltip::text("Only Search Open Files"))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_opened_only(window, cx);
})),
)
.child(
SearchOptions::INCLUDE_IGNORED.as_button(
search
.search_options
.contains(SearchOptions::INCLUDE_IGNORED),
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, window, cx);
}),
),
);
h_flex()
.w_full()
.gap_2()
@ -2242,62 +2166,14 @@ impl Render for ProjectSearchBar {
h_flex()
.gap_2()
.w(input_width)
.child(
input_base_styles(BaseStyle::MultipleInputs, InputPanel::Include)
.on_action(cx.listener(|this, action, window, cx| {
this.previous_history_query(action, window, cx)
}))
.on_action(cx.listener(|this, action, window, cx| {
this.next_history_query(action, window, cx)
}))
.child(self.render_text_input(&search.included_files_editor, cx)),
)
.child(
input_base_styles(BaseStyle::MultipleInputs, InputPanel::Exclude)
.on_action(cx.listener(|this, action, window, cx| {
this.previous_history_query(action, window, cx)
}))
.on_action(cx.listener(|this, action, window, cx| {
this.next_history_query(action, window, cx)
}))
.child(self.render_text_input(&search.excluded_files_editor, cx)),
),
)
.child(
h_flex()
.min_w_64()
.gap_1()
.child(
IconButton::new("project-search-opened-only", IconName::FolderSearch)
.shape(IconButtonShape::Square)
.toggle_state(self.is_opened_only_enabled(cx))
.tooltip(Tooltip::text("Only Search Open Files"))
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_opened_only(window, cx);
})),
)
.child(
SearchOptions::INCLUDE_IGNORED.as_button(
search
.search_options
.contains(SearchOptions::INCLUDE_IGNORED),
focus_handle.clone(),
cx.listener(|this, _, window, cx| {
this.toggle_search_option(
SearchOptions::INCLUDE_IGNORED,
window,
cx,
);
}),
),
),
.child(include)
.child(exclude),
)
.child(mode_column)
});
let mut key_context = KeyContext::default();
key_context.add("ProjectSearchBar");
if search
.replacement_editor
.focus_handle(cx)
@ -2306,16 +2182,33 @@ impl Render for ProjectSearchBar {
key_context.add("in_replace");
}
let query_error_line = search.query_error.as_ref().map(|error| {
Label::new(error)
.size(LabelSize::Small)
.color(Color::Error)
.mt_neg_1()
.ml_2()
});
let query_error_line = search
.panels_with_errors
.get(&InputPanel::Query)
.map(|error| {
Label::new(error)
.size(LabelSize::Small)
.color(Color::Error)
.mt_neg_1()
.ml_2()
});
let filter_error_line = search
.panels_with_errors
.get(&InputPanel::Include)
.or_else(|| search.panels_with_errors.get(&InputPanel::Exclude))
.map(|error| {
Label::new(error)
.size(LabelSize::Small)
.color(Color::Error)
.mt_neg_1()
.ml_2()
});
v_flex()
.gap_2()
.py(px(1.0))
.w_full()
.key_context(key_context)
.on_action(cx.listener(|this, _: &ToggleFocus, window, cx| {
this.move_focus_to_results(window, cx)
@ -2323,14 +2216,8 @@ impl Render for ProjectSearchBar {
.on_action(cx.listener(|this, _: &ToggleFilters, window, cx| {
this.toggle_filters(window, cx);
}))
.capture_action(cx.listener(|this, action, window, cx| {
this.tab(action, window, cx);
cx.stop_propagation();
}))
.capture_action(cx.listener(|this, action, window, cx| {
this.backtab(action, window, cx);
cx.stop_propagation();
}))
.capture_action(cx.listener(Self::tab))
.capture_action(cx.listener(Self::backtab))
.on_action(cx.listener(|this, action, window, cx| this.confirm(action, window, cx)))
.on_action(cx.listener(|this, action, window, cx| {
this.toggle_replace(action, window, cx);
@ -2362,12 +2249,11 @@ impl Render for ProjectSearchBar {
})
.on_action(cx.listener(Self::select_next_match))
.on_action(cx.listener(Self::select_prev_match))
.gap_2()
.w_full()
.child(search_line)
.children(query_error_line)
.children(replace_line)
.children(filter_line)
.children(filter_error_line)
}
}

View file

@ -1,8 +1,14 @@
use gpui::{Action, FocusHandle, IntoElement};
use editor::{Editor, EditorElement, EditorStyle};
use gpui::{Action, Entity, FocusHandle, Hsla, IntoElement, TextStyle};
use settings::Settings;
use theme::ThemeSettings;
use ui::{IconButton, IconButtonShape};
use ui::{Tooltip, prelude::*};
pub(super) fn render_nav_button(
use crate::ToggleReplace;
pub(super) fn render_action_button(
id_prefix: &'static str,
icon: ui::IconName,
active: bool,
tooltip: &'static str,
@ -10,7 +16,7 @@ pub(super) fn render_nav_button(
focus_handle: FocusHandle,
) -> impl IntoElement {
IconButton::new(
SharedString::from(format!("search-nav-button-{}", action.name())),
SharedString::from(format!("{id_prefix}-{}", action.name())),
icon,
)
.shape(IconButtonShape::Square)
@ -26,3 +32,74 @@ pub(super) fn render_nav_button(
.tooltip(move |window, cx| Tooltip::for_action_in(tooltip, action, &focus_handle, window, cx))
.disabled(!active)
}
pub(crate) fn input_base_styles(border_color: Hsla, map: impl FnOnce(Div) -> Div) -> Div {
h_flex()
.min_w_32()
.map(map)
.h_8()
.pl_2()
.pr_1()
.py_1()
.border_1()
.border_color(border_color)
.rounded_lg()
}
pub(crate) fn toggle_replace_button(
id: &'static str,
focus_handle: FocusHandle,
replace_enabled: bool,
on_click: impl Fn(&gpui::ClickEvent, &mut Window, &mut App) + 'static,
) -> IconButton {
IconButton::new(id, IconName::Replace)
.shape(IconButtonShape::Square)
.style(ButtonStyle::Subtle)
.when(replace_enabled, |button| button.style(ButtonStyle::Filled))
.on_click(on_click)
.toggle_state(replace_enabled)
.tooltip({
move |window, cx| {
Tooltip::for_action_in("Toggle Replace", &ToggleReplace, &focus_handle, window, cx)
}
})
}
pub(crate) fn render_text_input(
editor: &Entity<Editor>,
color_override: Option<Color>,
app: &App,
) -> impl IntoElement {
let (color, use_syntax) = if editor.read(app).read_only(app) {
(app.theme().colors().text_disabled, false)
} else {
match color_override {
Some(color_override) => (color_override.color(app), false),
None => (app.theme().colors().text, true),
}
};
let settings = ThemeSettings::get_global(app);
let text_style = TextStyle {
color,
font_family: settings.buffer_font.family.clone(),
font_features: settings.buffer_font.features.clone(),
font_fallbacks: settings.buffer_font.fallbacks.clone(),
font_size: rems(0.875).into(),
font_weight: settings.buffer_font.weight,
line_height: relative(1.3),
..TextStyle::default()
};
let mut editor_style = EditorStyle {
background: app.theme().colors().toolbar_background,
local_player: app.theme().players().local(),
text: text_style,
..EditorStyle::default()
};
if use_syntax {
editor_style.syntax = app.theme().syntax().clone();
}
EditorElement::new(editor, editor_style)
}