mirror of
https://github.com/zed-industries/zed.git
synced 2024-09-18 18:08:07 +03:00
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:
parent
84b34677e2
commit
09b216cf5e
@ -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"
|
||||||
|
@ -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"
|
||||||
|
@ -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",
|
||||||
|
@ -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,7 +238,10 @@ 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, _| {
|
||||||
|
this.no_results = Some(false);
|
||||||
|
this.match_ranges.push(range)
|
||||||
|
})
|
||||||
.ok()?;
|
.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);
|
||||||
|
if search_view.model.read(cx).active_query.is_some() {
|
||||||
search_view.search(cx);
|
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);
|
||||||
|
if limit_reached {
|
||||||
|
Some(format!("{index}/{match_quantity}+").to_string())
|
||||||
|
} else {
|
||||||
Some(format!("{index}/{match_quantity}").to_string())
|
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, |this| {
|
.when(limit_reached, |el| {
|
||||||
this.child(
|
el.tooltip(|cx| {
|
||||||
Label::new("Search limit reached")
|
Tooltip::text("Search limits reached.\nTry narrowing your search.", cx)
|
||||||
.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,15 +1563,31 @@ 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(
|
||||||
|
h_flex()
|
||||||
|
.flex_1()
|
||||||
|
.min_w(rems(10.25))
|
||||||
|
.max_w(rems(20.))
|
||||||
|
.h_8()
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.border_1()
|
||||||
|
.border_color(search.border_color_for(InputPanel::Exclude, cx))
|
||||||
|
.rounded_lg()
|
||||||
|
.child(self.render_text_input(&search.excluded_files_editor, cx)),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
SearchOptions::INCLUDE_IGNORED.as_button(
|
SearchOptions::INCLUDE_IGNORED.as_button(
|
||||||
search
|
search
|
||||||
@ -1530,25 +1597,16 @@ impl Render for ProjectSearchBar {
|
|||||||
this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
|
this.toggle_search_option(SearchOptions::INCLUDE_IGNORED, cx);
|
||||||
}),
|
}),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
)
|
|
||||||
.child(
|
|
||||||
h_flex()
|
|
||||||
.flex_1()
|
|
||||||
.min_w(rems(MIN_INPUT_WIDTH_REMS))
|
|
||||||
.max_w(rems(MAX_INPUT_WIDTH_REMS))
|
|
||||||
.h_8()
|
|
||||||
.px_2()
|
|
||||||
.py_1()
|
|
||||||
.border_1()
|
|
||||||
.border_color(search.border_color_for(InputPanel::Exclude, cx))
|
|
||||||
.rounded_lg()
|
|
||||||
.child(self.render_text_input(&search.excluded_files_editor, 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);
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user