From 09b216cf5eeb189781b6e0beb880fd8f1f3a58c0 Mon Sep 17 00:00:00 2001 From: Conrad Irwin Date: Wed, 17 Jul 2024 14:14:46 -0600 Subject: [PATCH] Make project search feel better (#14674) Release Notes: - Improved UX of project search --------- Co-authored-by: Marshall --- 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(-) diff --git a/assets/keymaps/default-linux.json b/assets/keymaps/default-linux.json index 56e3267d54..8be6d12390 100644 --- a/assets/keymaps/default-linux.json +++ b/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" diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index 4411d66c57..1527e3e943 100644 --- a/assets/keymaps/default-macos.json +++ b/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" diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 60960918eb..08ce02a679 100644 --- a/crates/editor/src/element.rs +++ b/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", diff --git a/crates/search/src/project_search.rs b/crates/search/src/project_search.rs index df79986ee5..90fe261ba1 100644 --- a/crates/search/src/project_search.rs +++ b/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 = if let Some(no_results) = model.no_results { + let minor_text: Option = 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) -> 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); diff --git a/crates/search/src/search.rs b/crates/search/src/search.rs index 6a6c60fd07..0466930f90 100644 --- a/crates/search/src/search.rs +++ b/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) }) } }