Make project search feel better (#14674)

Release Notes:

- Improved UX of project search

---------

Co-authored-by: Marshall <marshall@zed.dev>
This commit is contained in:
Conrad Irwin 2024-07-17 14:14:46 -06:00 committed by GitHub
parent 84b34677e2
commit 09b216cf5e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 123 additions and 64 deletions

View File

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

View File

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

View File

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

View File

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

View File

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