Make project search feel better (#14674)

Conrad Irwin and Marshall created

Release Notes:

- Improved UX of project search

---------

Co-authored-by: Marshall <marshall@zed.dev>

Change summary

assets/keymaps/default-linux.json   |   2 
assets/keymaps/default-macos.json   |   2 
crates/editor/src/element.rs        |   5 
crates/search/src/project_search.rs | 164 ++++++++++++++++++++----------
crates/search/src/search.rs         |  14 +-
5 files changed, 123 insertions(+), 64 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -205,7 +205,7 @@
     }
   },
   {
-    "context": "ProjectSearchBar && in_replace",
+    "context": "ProjectSearchBar && in_replace > Editor",
     "bindings": {
       "enter": "search::ReplaceNext",
       "ctrl-alt-enter": "search::ReplaceAll"

assets/keymaps/default-macos.json 🔗

@@ -255,7 +255,7 @@
     }
   },
   {
-    "context": "ProjectSearchBar && in_replace",
+    "context": "ProjectSearchBar && in_replace > Editor",
     "bindings": {
       "enter": "search::ReplaceNext",
       "cmd-enter": "search::ReplaceAll"

crates/editor/src/element.rs 🔗

@@ -2092,8 +2092,9 @@ impl EditorElement {
                                                 }),
                                         ),
                                     )
-                                    .when_some(jump_data.clone(), |this, jump_data| {
-                                        this.cursor_pointer()
+                                    .when_some(jump_data.clone(), |el, jump_data| {
+                                        el.child(Icon::new(IconName::ArrowUpRight))
+                                            .cursor_pointer()
                                             .tooltip(|cx| {
                                                 Tooltip::for_action(
                                                     "Jump to File",

crates/search/src/project_search.rs 🔗

@@ -14,9 +14,9 @@ use editor::{
 use gpui::{
     actions, div, Action, AnyElement, AnyView, AppContext, Context as _, Element, EntityId,
     EventEmitter, FocusHandle, FocusableView, FontStyle, Global, Hsla, InteractiveElement,
-    IntoElement, Model, ModelContext, ParentElement, Point, Render, SharedString, Styled,
-    Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, VisualContext, WeakModel,
-    WhiteSpace, WindowContext,
+    IntoElement, KeyContext, Model, ModelContext, ParentElement, Point, Render, SharedString,
+    Styled, Subscription, Task, TextStyle, UpdateGlobal, View, ViewContext, VisualContext,
+    WeakModel, WhiteSpace, WindowContext,
 };
 use menu::Confirm;
 use project::{search::SearchQuery, search_history::SearchHistoryCursor, Project, ProjectPath};
@@ -30,8 +30,8 @@ use std::{
 };
 use theme::ThemeSettings;
 use ui::{
-    h_flex, prelude::*, v_flex, Icon, IconButton, IconName, Label, LabelCommon, LabelSize,
-    Selectable, Tooltip,
+    h_flex, prelude::*, v_flex, Icon, IconButton, IconName, KeyBinding, Label, LabelCommon,
+    LabelSize, Selectable, Tooltip,
 };
 use util::paths::PathMatcher;
 use workspace::{
@@ -226,7 +226,6 @@ impl ProjectSearch {
                     project::SearchResult::Buffer { buffer, ranges } => {
                         let mut match_ranges = this
                             .update(&mut cx, |this, cx| {
-                                this.no_results = Some(false);
                                 this.excerpts.update(cx, |excerpts, cx| {
                                     excerpts.stream_excerpts_with_context_lines(
                                         buffer,
@@ -239,8 +238,11 @@ impl ProjectSearch {
                             .ok()?;
 
                         while let Some(range) = match_ranges.next().await {
-                            this.update(&mut cx, |this, _| this.match_ranges.push(range))
-                                .ok()?;
+                            this.update(&mut cx, |this, _| {
+                                this.no_results = Some(false);
+                                this.match_ranges.push(range)
+                            })
+                            .ok()?;
                         }
                         this.update(&mut cx, |_, cx| cx.notify()).ok()?;
                     }
@@ -286,30 +288,32 @@ impl Render for ProjectSearchView {
             let has_no_results = model.no_results.unwrap_or(false);
             let is_search_underway = model.pending_search.is_some();
             let major_text = if is_search_underway {
-                Label::new("Searching...")
+                "Searching..."
             } else if has_no_results {
-                Label::new("No results")
+                "No results"
             } else {
-                Label::new("Search all files")
+                "Search all files"
             };
 
-            let major_text = div().justify_center().max_w_96().child(major_text);
+            let major_text = div()
+                .justify_center()
+                .max_w_96()
+                .child(Label::new(major_text).size(LabelSize::Large));
 
-            let minor_text: Option<SharedString> = if let Some(no_results) = model.no_results {
+            let minor_text: Option<AnyElement> = if let Some(no_results) = model.no_results {
                 if model.pending_search.is_none() && no_results {
-                    Some("No results found in this project for the provided query".into())
+                    Some(
+                        Label::new("No results found in this project for the provided query")
+                            .size(LabelSize::Small)
+                            .into_any_element(),
+                    )
                 } else {
                     None
                 }
             } else {
-                Some(self.landing_text_minor())
+                Some(self.landing_text_minor(cx).into_any_element())
             };
-            let minor_text = minor_text.map(|text| {
-                div()
-                    .items_center()
-                    .max_w_96()
-                    .child(Label::new(text).size(LabelSize::Small))
-            });
+            let minor_text = minor_text.map(|text| div().items_center().max_w_96().child(text));
             v_flex()
                 .flex_1()
                 .size_full()
@@ -321,7 +325,7 @@ impl Render for ProjectSearchView {
                         .size_full()
                         .justify_center()
                         .child(h_flex().flex_1())
-                        .child(v_flex().child(major_text).children(minor_text))
+                        .child(v_flex().gap_1().child(major_text).children(minor_text))
                         .child(h_flex().flex_1()),
                 )
         }
@@ -1053,8 +1057,50 @@ impl ProjectSearchView {
         self.active_match_index.is_some()
     }
 
-    fn landing_text_minor(&self) -> SharedString {
-        "Include/exclude specific paths with the filter option. Matching exact word and/or casing is available too.".into()
+    fn landing_text_minor(&self, cx: &mut ViewContext<Self>) -> impl IntoElement {
+        v_flex()
+            .gap_1()
+            .child(Label::new("Hit enter to search. For more options:"))
+            .child(
+                Button::new("filter-paths", "Include/exclude specific paths")
+                    .icon(IconName::Filter)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .key_binding(KeyBinding::for_action(&ToggleFilters, cx))
+                    .on_click(|_event, cx| cx.dispatch_action(ToggleFilters.boxed_clone())),
+            )
+            .child(
+                Button::new("find-replace", "Find and replace")
+                    .icon(IconName::Replace)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .key_binding(KeyBinding::for_action(&ToggleReplace, cx))
+                    .on_click(|_event, cx| cx.dispatch_action(ToggleReplace.boxed_clone())),
+            )
+            .child(
+                Button::new("regex", "Match with regex")
+                    .icon(IconName::Regex)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .key_binding(KeyBinding::for_action(&ToggleRegex, cx))
+                    .on_click(|_event, cx| cx.dispatch_action(ToggleRegex.boxed_clone())),
+            )
+            .child(
+                Button::new("match-case", "Match case")
+                    .icon(IconName::CaseSensitive)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .key_binding(KeyBinding::for_action(&ToggleCaseSensitive, cx))
+                    .on_click(|_event, cx| cx.dispatch_action(ToggleCaseSensitive.boxed_clone())),
+            )
+            .child(
+                Button::new("match-whole-words", "Match whole words")
+                    .icon(IconName::WholeWord)
+                    .icon_position(IconPosition::Start)
+                    .icon_size(IconSize::Small)
+                    .key_binding(KeyBinding::for_action(&ToggleWholeWord, cx))
+                    .on_click(|_event, cx| cx.dispatch_action(ToggleWholeWord.boxed_clone())),
+            )
     }
 
     fn border_color_for(&self, panel: InputPanel, cx: &WindowContext) -> Hsla {
@@ -1158,7 +1204,9 @@ impl ProjectSearchBar {
         if let Some(search_view) = self.active_project_search.as_ref() {
             search_view.update(cx, |search_view, cx| {
                 search_view.toggle_search_option(option, cx);
-                search_view.search(cx);
+                if search_view.model.read(cx).active_query.is_some() {
+                    search_view.search(cx);
+                }
             });
 
             cx.notify();
@@ -1395,6 +1443,7 @@ impl Render for ProjectSearchBar {
                 ),
         );
 
+        let limit_reached = search.model.read(cx).limit_reached;
         let match_text = search
             .active_match_index
             .and_then(|index| {
@@ -1402,15 +1451,17 @@ impl Render for ProjectSearchBar {
                 let match_quantity = search.model.read(cx).match_ranges.len();
                 if match_quantity > 0 {
                     debug_assert!(match_quantity >= index);
-                    Some(format!("{index}/{match_quantity}").to_string())
+                    if limit_reached {
+                        Some(format!("{index}/{match_quantity}+").to_string())
+                    } else {
+                        Some(format!("{index}/{match_quantity}").to_string())
+                    }
                 } else {
                     None
                 }
             })
             .unwrap_or_else(|| "0/0".to_string());
 
-        let limit_reached = search.model.read(cx).limit_reached;
-
         let matches_column = h_flex()
             .child(
                 IconButton::new("project-search-prev-match", IconName::ChevronLeft)
@@ -1440,6 +1491,7 @@ impl Render for ProjectSearchBar {
             )
             .child(
                 h_flex()
+                    .id("matches")
                     .min_w(rems_from_px(40.))
                     .child(
                         Label::new(match_text).color(if search.active_match_index.is_some() {
@@ -1447,15 +1499,13 @@ impl Render for ProjectSearchBar {
                         } else {
                             Color::Disabled
                         }),
-                    ),
-            )
-            .when(limit_reached, |this| {
-                this.child(
-                    Label::new("Search limit reached")
-                        .ml_2()
-                        .color(Color::Warning),
-                )
-            });
+                    )
+                    .when(limit_reached, |el| {
+                        el.tooltip(|cx| {
+                            Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
+                        })
+                    }),
+            );
 
         let search_line = h_flex()
             .flex_1()
@@ -1500,6 +1550,7 @@ impl Render for ProjectSearchBar {
                 )
             });
             h_flex()
+                .pr(rems(5.5))
                 .gap_2()
                 .child(replace_column)
                 .child(replace_actions)
@@ -1512,31 +1563,23 @@ impl Render for ProjectSearchBar {
                 .child(
                     h_flex()
                         .flex_1()
-                        .min_w(rems(MIN_INPUT_WIDTH_REMS))
-                        .max_w(rems(MAX_INPUT_WIDTH_REMS))
+                        // chosen so the total width of the search bar line
+                        // is about the same as the include/exclude line
+                        .min_w(rems(10.25))
+                        .max_w(rems(20.))
                         .h_8()
                         .px_2()
                         .py_1()
                         .border_1()
                         .border_color(search.border_color_for(InputPanel::Include, cx))
                         .rounded_lg()
-                        .child(self.render_text_input(&search.included_files_editor, cx))
-                        .child(
-                            SearchOptions::INCLUDE_IGNORED.as_button(
-                                search
-                                    .search_options
-                                    .contains(SearchOptions::INCLUDE_IGNORED),
-                                cx.listener(|this, _, cx| {
-                                    this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
-                                }),
-                            ),
-                        ),
+                        .child(self.render_text_input(&search.included_files_editor, cx)),
                 )
                 .child(
                     h_flex()
                         .flex_1()
-                        .min_w(rems(MIN_INPUT_WIDTH_REMS))
-                        .max_w(rems(MAX_INPUT_WIDTH_REMS))
+                        .min_w(rems(10.25))
+                        .max_w(rems(20.))
                         .h_8()
                         .px_2()
                         .py_1()
@@ -1545,10 +1588,25 @@ impl Render for ProjectSearchBar {
                         .rounded_lg()
                         .child(self.render_text_input(&search.excluded_files_editor, cx)),
                 )
+                .child(
+                    SearchOptions::INCLUDE_IGNORED.as_button(
+                        search
+                            .search_options
+                            .contains(SearchOptions::INCLUDE_IGNORED),
+                        cx.listener(|this, _, cx| {
+                            this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
+                        }),
+                    ),
+                )
         });
+        let mut key_context = KeyContext::default();
+        key_context.add("ProjectSearchBar");
+        if search.replacement_editor.focus_handle(cx).is_focused(cx) {
+            key_context.add("in_replace");
+        }
 
         v_flex()
-            .key_context("ProjectSearchBar")
+            .key_context(key_context)
             .on_action(cx.listener(|this, _: &ToggleFocus, cx| this.move_focus_to_results(cx)))
             .on_action(cx.listener(|this, _: &ToggleFilters, cx| {
                 this.toggle_filters(cx);

crates/search/src/search.rs 🔗

@@ -52,10 +52,10 @@ bitflags! {
 impl SearchOptions {
     pub fn label(&self) -> &'static str {
         match *self {
-            SearchOptions::WHOLE_WORD => "whole word",
-            SearchOptions::CASE_SENSITIVE => "match case",
-            SearchOptions::INCLUDE_IGNORED => "include Ignored",
-            SearchOptions::REGEX => "regular expression",
+            SearchOptions::WHOLE_WORD => "Match whole words",
+            SearchOptions::CASE_SENSITIVE => "Match case sensitively",
+            SearchOptions::INCLUDE_IGNORED => "Also search files ignored by configuration",
+            SearchOptions::REGEX => "Use regular expressions",
             _ => panic!("{:?} is not a named SearchOption", self),
         }
     }
@@ -64,7 +64,7 @@ impl SearchOptions {
         match *self {
             SearchOptions::WHOLE_WORD => ui::IconName::WholeWord,
             SearchOptions::CASE_SENSITIVE => ui::IconName::CaseSensitive,
-            SearchOptions::INCLUDE_IGNORED => ui::IconName::FileGit,
+            SearchOptions::INCLUDE_IGNORED => ui::IconName::Sliders,
             SearchOptions::REGEX => ui::IconName::Regex,
             _ => panic!("{:?} is not a named SearchOption", self),
         }
@@ -104,8 +104,8 @@ impl SearchOptions {
             .selected(active)
             .tooltip({
                 let action = self.to_toggle_action();
-                let label: SharedString = format!("Toggle {}", self.label()).into();
-                move |cx| Tooltip::for_action(label.clone(), &*action, cx)
+                let label = self.label();
+                move |cx| Tooltip::for_action(label, &*action, cx)
             })
     }
 }