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

Lukas Wirth created

- 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

Change summary

crates/search/src/buffer_search.rs  | 597 ++++++++++++------------------
crates/search/src/project_search.rs | 528 ++++++++++----------------
crates/search/src/search_bar.rs     |  83 ++++
crates/workspace/src/workspace.rs   |   8 
4 files changed, 536 insertions(+), 680 deletions(-)

Detailed changes

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<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 mut key_context = KeyContext::new_with_defaults();
-        key_context.add("BufferSearchBar");
-        if in_replace {
-            key_context.add("in_replace");
-        }
+        let theme_colors = cx.theme().colors();
         let query_border = if self.query_error.is_some() {
             Color::Error.color(cx)
         } else {
-            cx.theme().colors().border
+            theme_colors.border
         };
-        let replacement_border = cx.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| {
-            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 input_base_styles =
+            |border_color| input_base_styles(border_color, |div| div.w(input_width));
 
-        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,
-                                        )
-                                    }
+        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)
                                 }),
-                        )
-                        .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(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)
                     })
-                    .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)
-                                })),
-                        )
+                    .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,
+                            )
+                        }
                     }),
-            );
-
-        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)
-                                })),
-                        ),
-                )
-        });
+            })
+            .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_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();
         }
     }

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<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 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(BaseStyle::SingleInput, InputPanel::Query)
+        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,
-                            )
-                        }
-                    }),
-            );
-
-        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());
+            .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 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)
     }
 }
 

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<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)
+}

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 => {