From 8d6982e78f2493bb3ef2a23010f38dab141dc76a Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Fri, 15 Aug 2025 09:56:47 +0200 Subject: [PATCH] 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 --- crates/search/src/buffer_search.rs | 617 ++++++++++++---------------- crates/search/src/project_search.rs | 526 ++++++++++-------------- crates/search/src/search_bar.rs | 83 +++- crates/workspace/src/workspace.rs | 8 +- 4 files changed, 545 insertions(+), 689 deletions(-) diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index 14703be7a2..ccef198f04 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -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, - color_override: Option, - cx: &mut Context, - ) -> 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.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( - &self, - option: SearchOptions, - focus_handle: FocusHandle, - action: Action, - ) -> impl IntoElement + use { - 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) { 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) { - // 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) { - // 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) { + 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) { - 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(); } } diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index 96194cdad2..9e8afa4392 100644 --- a/crates/search/src/project_search.rs +++ b/crates/search/src/project_search.rs @@ -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, results_editor: Entity, search_options: SearchOptions, - panels_with_errors: HashSet, + panels_with_errors: HashMap, active_match_index: Option, search_id: usize, included_files_editor: Entity, @@ -218,7 +222,6 @@ pub struct ProjectSearchView { included_opened_only: bool, regex_language: Option>, _subscriptions: Vec, - query_error: Option, } #[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) { + fn tab(&mut self, _: &Tab, window: &mut Window, cx: &mut Context) { self.cycle_field(Direction::Next, window, cx); } - fn backtab( - &mut self, - _: &editor::actions::Backtab, - window: &mut Window, - cx: &mut Context, - ) { + fn backtab(&mut self, _: &Backtab, window: &mut Window, cx: &mut Context) { 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) { 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, cx: &Context) -> 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) } } diff --git a/crates/search/src/search_bar.rs b/crates/search/src/search_bar.rs index 805664c794..2805b0c62d 100644 --- a/crates/search/src/search_bar.rs +++ b/crates/search/src/search_bar.rs @@ -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, + color_override: Option, + 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) +} diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ba9e3bbb8a..ca98404194 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -3878,9 +3878,7 @@ impl Workspace { local, focus_changed, } => { - cx.on_next_frame(window, |_, window, _| { - window.invalidate_character_coordinates(); - }); + window.invalidate_character_coordinates(); pane.update(cx, |pane, _| { pane.track_alternate_file_items(); @@ -3921,9 +3919,7 @@ impl Workspace { } } pane::Event::Focus => { - cx.on_next_frame(window, |_, window, _| { - window.invalidate_character_coordinates(); - }); + window.invalidate_character_coordinates(); self.handle_pane_focused(pane.clone(), window, cx); } pane::Event::ZoomIn => {