catchup with main

This commit is contained in:
KCaverly
2023-07-26 09:50:38 -04:00
112 changed files with 4590 additions and 1029 deletions
+295 -115
View File
@@ -1,15 +1,17 @@
use crate::{
SearchOption, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
SearchOptions, SelectAllMatches, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive,
ToggleRegex, ToggleWholeWord,
};
use collections::HashMap;
use editor::Editor;
use futures::channel::oneshot;
use gpui::{
actions,
elements::*,
impl_actions,
platform::{CursorStyle, MouseButton},
Action, AnyViewHandle, AppContext, Entity, Subscription, Task, View, ViewContext, ViewHandle,
WindowContext,
};
use project::search::SearchQuery;
use serde::Deserialize;
@@ -44,20 +46,19 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(BufferSearchBar::select_prev_match_on_pane);
cx.add_action(BufferSearchBar::select_all_matches_on_pane);
cx.add_action(BufferSearchBar::handle_editor_cancel);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
}
fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
if search_bar.update(cx, |search_bar, cx| search_bar.show(false, false, cx)) {
search_bar.update(cx, |search_bar, cx| {
search_bar.update(cx, |search_bar, cx| {
if search_bar.show(cx) {
search_bar.toggle_search_option(option, cx);
});
return;
}
}
});
}
cx.propagate_action();
});
@@ -71,9 +72,8 @@ pub struct BufferSearchBar {
searchable_items_with_matches:
HashMap<Box<dyn WeakSearchableItemHandle>, Vec<Box<dyn Any + Send>>>,
pending_search: Option<Task<()>>,
case_sensitive: bool,
whole_word: bool,
regex: bool,
search_options: SearchOptions,
default_options: SearchOptions,
query_contains_error: bool,
dismissed: bool,
}
@@ -156,19 +156,19 @@ impl View for BufferSearchBar {
.with_children(self.render_search_option(
supported_options.case,
"Case",
SearchOption::CaseSensitive,
SearchOptions::CASE_SENSITIVE,
cx,
))
.with_children(self.render_search_option(
supported_options.word,
"Word",
SearchOption::WholeWord,
SearchOptions::WHOLE_WORD,
cx,
))
.with_children(self.render_search_option(
supported_options.regex,
"Regex",
SearchOption::Regex,
SearchOptions::REGEX,
cx,
))
.contained()
@@ -212,7 +212,7 @@ impl ToolbarItemView for BufferSearchBar {
));
self.active_searchable_item = Some(searchable_item_handle);
self.update_matches(false, cx);
let _ = self.update_matches(cx);
if !self.dismissed {
return ToolbarItemLocation::Secondary;
}
@@ -253,9 +253,8 @@ impl BufferSearchBar {
active_searchable_item_subscription: None,
active_match_index: None,
searchable_items_with_matches: Default::default(),
case_sensitive: false,
whole_word: false,
regex: false,
default_options: SearchOptions::NONE,
search_options: SearchOptions::NONE,
pending_search: None,
query_contains_error: false,
dismissed: true,
@@ -282,48 +281,86 @@ impl BufferSearchBar {
cx.notify();
}
pub fn show(&mut self, focus: bool, suggest_query: bool, cx: &mut ViewContext<Self>) -> bool {
let searchable_item = if let Some(searchable_item) = &self.active_searchable_item {
SearchableItemHandle::boxed_clone(searchable_item.as_ref())
} else {
pub fn show(&mut self, cx: &mut ViewContext<Self>) -> bool {
if self.active_searchable_item.is_none() {
return false;
};
if suggest_query {
let text = searchable_item.query_suggestion(cx);
if !text.is_empty() {
self.set_query(&text, cx);
}
}
if focus {
let query_editor = self.query_editor.clone();
query_editor.update(cx, |query_editor, cx| {
query_editor.select_all(&editor::SelectAll, cx);
});
cx.focus_self();
}
self.dismissed = false;
cx.notify();
cx.emit(Event::UpdateLocation);
true
}
fn set_query(&mut self, query: &str, cx: &mut ViewContext<Self>) {
pub fn search_suggested(&mut self, cx: &mut ViewContext<Self>) {
let search = self
.query_suggestion(cx)
.map(|suggestion| self.search(&suggestion, Some(self.default_options), cx));
if let Some(search) = search {
cx.spawn(|this, mut cx| async move {
search.await?;
this.update(&mut cx, |this, cx| this.activate_current_match(cx))
})
.detach_and_log_err(cx);
}
}
pub fn activate_current_match(&mut self, cx: &mut ViewContext<Self>) {
if let Some(match_ix) = self.active_match_index {
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&active_searchable_item.downgrade())
{
active_searchable_item.activate_match(match_ix, matches, cx)
}
}
}
}
pub fn select_query(&mut self, cx: &mut ViewContext<Self>) {
self.query_editor.update(cx, |query_editor, cx| {
query_editor.buffer().update(cx, |query_buffer, cx| {
let len = query_buffer.len(cx);
query_buffer.edit([(0..len, query)], None, cx);
});
query_editor.select_all(&Default::default(), cx);
});
}
pub fn query(&self, cx: &WindowContext) -> String {
self.query_editor.read(cx).text(cx)
}
pub fn query_suggestion(&mut self, cx: &mut ViewContext<Self>) -> Option<String> {
self.active_searchable_item
.as_ref()
.map(|searchable_item| searchable_item.query_suggestion(cx))
}
pub fn search(
&mut self,
query: &str,
options: Option<SearchOptions>,
cx: &mut ViewContext<Self>,
) -> oneshot::Receiver<()> {
let options = options.unwrap_or(self.default_options);
if query != self.query_editor.read(cx).text(cx) || self.search_options != options {
self.query_editor.update(cx, |query_editor, cx| {
query_editor.buffer().update(cx, |query_buffer, cx| {
let len = query_buffer.len(cx);
query_buffer.edit([(0..len, query)], None, cx);
});
});
self.search_options = options;
self.query_contains_error = false;
self.clear_matches(cx);
cx.notify();
}
self.update_matches(cx)
}
fn render_search_option(
&self,
option_supported: bool,
icon: &'static str,
option: SearchOption,
option: SearchOptions,
cx: &mut ViewContext<Self>,
) -> Option<AnyElement<Self>> {
if !option_supported {
@@ -331,9 +368,9 @@ impl BufferSearchBar {
}
let tooltip_style = theme::current(cx).tooltip.clone();
let is_active = self.is_search_option_enabled(option);
let is_active = self.search_options.contains(option);
Some(
MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.search
@@ -349,7 +386,7 @@ impl BufferSearchBar {
})
.with_cursor_style(CursorStyle::PointingHand)
.with_tooltip::<Self>(
option as usize,
option.bits as usize,
format!("Toggle {}", option.label()),
Some(option.to_toggle_action()),
tooltip_style,
@@ -471,12 +508,23 @@ impl BufferSearchBar {
}
fn deploy(pane: &mut Pane, action: &Deploy, cx: &mut ViewContext<Pane>) {
let mut propagate_action = true;
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<BufferSearchBar>() {
if search_bar.update(cx, |search_bar, cx| search_bar.show(action.focus, true, cx)) {
return;
}
search_bar.update(cx, |search_bar, cx| {
if search_bar.show(cx) {
search_bar.search_suggested(cx);
if action.focus {
search_bar.select_query(cx);
cx.focus_self();
}
propagate_action = false;
}
});
}
if propagate_action {
cx.propagate_action();
}
cx.propagate_action();
}
fn handle_editor_cancel(pane: &mut Pane, _: &editor::Cancel, cx: &mut ViewContext<Pane>) {
@@ -489,41 +537,38 @@ impl BufferSearchBar {
cx.propagate_action();
}
fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
pub fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext<Self>) {
if let Some(active_editor) = self.active_searchable_item.as_ref() {
cx.focus(active_editor.as_any());
}
}
fn is_search_option_enabled(&self, search_option: SearchOption) -> bool {
match search_option {
SearchOption::WholeWord => self.whole_word,
SearchOption::CaseSensitive => self.case_sensitive,
SearchOption::Regex => self.regex,
}
fn toggle_search_option(&mut self, search_option: SearchOptions, cx: &mut ViewContext<Self>) {
self.search_options.toggle(search_option);
self.default_options = self.search_options;
let _ = self.update_matches(cx);
cx.notify();
}
fn toggle_search_option(&mut self, search_option: SearchOption, cx: &mut ViewContext<Self>) {
let value = match search_option {
SearchOption::WholeWord => &mut self.whole_word,
SearchOption::CaseSensitive => &mut self.case_sensitive,
SearchOption::Regex => &mut self.regex,
};
*value = !*value;
self.update_matches(false, cx);
pub fn set_search_options(
&mut self,
search_options: SearchOptions,
cx: &mut ViewContext<Self>,
) {
self.search_options = search_options;
cx.notify();
}
fn select_next_match(&mut self, _: &SelectNextMatch, cx: &mut ViewContext<Self>) {
self.select_match(Direction::Next, cx);
self.select_match(Direction::Next, 1, cx);
}
fn select_prev_match(&mut self, _: &SelectPrevMatch, cx: &mut ViewContext<Self>) {
self.select_match(Direction::Prev, cx);
self.select_match(Direction::Prev, 1, cx);
}
fn select_all_matches(&mut self, _: &SelectAllMatches, cx: &mut ViewContext<Self>) {
if !self.dismissed {
if !self.dismissed && self.active_match_index.is_some() {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
@@ -536,15 +581,15 @@ impl BufferSearchBar {
}
}
pub fn select_match(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
pub fn select_match(&mut self, direction: Direction, count: usize, cx: &mut ViewContext<Self>) {
if let Some(index) = self.active_match_index {
if let Some(searchable_item) = self.active_searchable_item.as_ref() {
if let Some(matches) = self
.searchable_items_with_matches
.get(&searchable_item.downgrade())
{
let new_match_index =
searchable_item.match_index_for_direction(matches, index, direction, cx);
let new_match_index = searchable_item
.match_index_for_direction(matches, index, direction, count, cx);
searchable_item.update_matches(matches, cx);
searchable_item.activate_match(new_match_index, matches, cx);
}
@@ -588,17 +633,23 @@ impl BufferSearchBar {
event: &editor::Event,
cx: &mut ViewContext<Self>,
) {
if let editor::Event::BufferEdited { .. } = event {
if let editor::Event::Edited { .. } = event {
self.query_contains_error = false;
self.clear_matches(cx);
self.update_matches(true, cx);
cx.notify();
let search = self.update_matches(cx);
cx.spawn(|this, mut cx| async move {
search.await?;
this.update(&mut cx, |this, cx| this.activate_current_match(cx))
})
.detach_and_log_err(cx);
}
}
fn on_active_searchable_item_event(&mut self, event: SearchEvent, cx: &mut ViewContext<Self>) {
match event {
SearchEvent::MatchesInvalidated => self.update_matches(false, cx),
SearchEvent::MatchesInvalidated => {
let _ = self.update_matches(cx);
}
SearchEvent::ActiveMatchChanged => self.update_match_index(cx),
}
}
@@ -621,19 +672,21 @@ impl BufferSearchBar {
.extend(active_item_matches);
}
fn update_matches(&mut self, select_closest_match: bool, cx: &mut ViewContext<Self>) {
fn update_matches(&mut self, cx: &mut ViewContext<Self>) -> oneshot::Receiver<()> {
let (done_tx, done_rx) = oneshot::channel();
let query = self.query_editor.read(cx).text(cx);
self.pending_search.take();
if let Some(active_searchable_item) = self.active_searchable_item.as_ref() {
if query.is_empty() {
self.active_match_index.take();
active_searchable_item.clear_matches(cx);
let _ = done_tx.send(());
} else {
let query = if self.regex {
let query = if self.search_options.contains(SearchOptions::REGEX) {
match SearchQuery::regex(
query,
self.whole_word,
self.case_sensitive,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
Vec::new(),
Vec::new(),
) {
@@ -641,14 +694,14 @@ impl BufferSearchBar {
Err(_) => {
self.query_contains_error = true;
cx.notify();
return;
return done_rx;
}
}
} else {
SearchQuery::text(
query,
self.whole_word,
self.case_sensitive,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
Vec::new(),
Vec::new(),
)
@@ -673,12 +726,7 @@ impl BufferSearchBar {
.get(&active_searchable_item.downgrade())
.unwrap();
active_searchable_item.update_matches(matches, cx);
if select_closest_match {
if let Some(match_ix) = this.active_match_index {
active_searchable_item
.activate_match(match_ix, matches, cx);
}
}
let _ = done_tx.send(());
}
cx.notify();
}
@@ -687,6 +735,7 @@ impl BufferSearchBar {
}));
}
}
done_rx
}
fn update_match_index(&mut self, cx: &mut ViewContext<Self>) {
@@ -714,8 +763,7 @@ mod tests {
use language::Buffer;
use unindent::Unindent as _;
#[gpui::test]
async fn test_search_simple(cx: &mut TestAppContext) {
fn init_test(cx: &mut TestAppContext) -> (ViewHandle<Editor>, ViewHandle<BufferSearchBar>) {
crate::project_search::tests::init_test(cx);
let buffer = cx.add_model(|cx| {
@@ -738,16 +786,23 @@ mod tests {
let search_bar = cx.add_view(window_id, |cx| {
let mut search_bar = BufferSearchBar::new(cx);
search_bar.set_active_pane_item(Some(&editor), cx);
search_bar.show(false, true, cx);
search_bar.show(cx);
search_bar
});
(editor, search_bar)
}
#[gpui::test]
async fn test_search_simple(cx: &mut TestAppContext) {
let (editor, search_bar) = init_test(cx);
// Search for a string that appears with different casing.
// By default, search is case-insensitive.
search_bar.update(cx, |search_bar, cx| {
search_bar.set_query("us", cx);
});
editor.next_notification(cx).await;
search_bar
.update(cx, |search_bar, cx| search_bar.search("us", None, cx))
.await
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(
editor.all_background_highlights(cx),
@@ -766,7 +821,7 @@ mod tests {
// Switch to a case sensitive search.
search_bar.update(cx, |search_bar, cx| {
search_bar.toggle_search_option(SearchOption::CaseSensitive, cx);
search_bar.toggle_search_option(SearchOptions::CASE_SENSITIVE, cx);
});
editor.next_notification(cx).await;
editor.update(cx, |editor, cx| {
@@ -781,10 +836,10 @@ mod tests {
// Search for a string that appears both as a whole word and
// within other words. By default, all results are found.
search_bar.update(cx, |search_bar, cx| {
search_bar.set_query("or", cx);
});
editor.next_notification(cx).await;
search_bar
.update(cx, |search_bar, cx| search_bar.search("or", None, cx))
.await
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(
editor.all_background_highlights(cx),
@@ -823,7 +878,7 @@ mod tests {
// Switch to a whole word search.
search_bar.update(cx, |search_bar, cx| {
search_bar.toggle_search_option(SearchOption::WholeWord, cx);
search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx);
});
editor.next_notification(cx).await;
editor.update(cx, |editor, cx| {
@@ -1025,6 +1080,65 @@ mod tests {
});
}
#[gpui::test]
async fn test_search_option_handling(cx: &mut TestAppContext) {
let (editor, search_bar) = init_test(cx);
// show with options should make current search case sensitive
search_bar
.update(cx, |search_bar, cx| {
search_bar.show(cx);
search_bar.search("us", Some(SearchOptions::CASE_SENSITIVE), cx)
})
.await
.unwrap();
editor.update(cx, |editor, cx| {
assert_eq!(
editor.all_background_highlights(cx),
&[(
DisplayPoint::new(2, 43)..DisplayPoint::new(2, 45),
Color::red(),
)]
);
});
// search_suggested should restore default options
search_bar.update(cx, |search_bar, cx| {
search_bar.search_suggested(cx);
assert_eq!(search_bar.search_options, SearchOptions::NONE)
});
// toggling a search option should update the defaults
search_bar
.update(cx, |search_bar, cx| {
search_bar.search("regex", Some(SearchOptions::CASE_SENSITIVE), cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.toggle_search_option(SearchOptions::WHOLE_WORD, cx)
});
editor.next_notification(cx).await;
editor.update(cx, |editor, cx| {
assert_eq!(
editor.all_background_highlights(cx),
&[(
DisplayPoint::new(0, 35)..DisplayPoint::new(0, 40),
Color::red(),
),]
);
});
// defaults should still include whole word
search_bar.update(cx, |search_bar, cx| {
search_bar.search_suggested(cx);
assert_eq!(
search_bar.search_options,
SearchOptions::CASE_SENSITIVE | SearchOptions::WHOLE_WORD
)
});
}
#[gpui::test]
async fn test_search_select_all_matches(cx: &mut TestAppContext) {
crate::project_search::tests::init_test(cx);
@@ -1052,15 +1166,25 @@ mod tests {
let search_bar = cx.add_view(window_id, |cx| {
let mut search_bar = BufferSearchBar::new(cx);
search_bar.set_active_pane_item(Some(&editor), cx);
search_bar.show(false, true, cx);
search_bar.show(cx);
search_bar
});
search_bar
.update(cx, |search_bar, cx| search_bar.search("a", None, cx))
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.set_query("a", cx);
cx.focus(search_bar.query_editor.as_any());
search_bar.activate_current_match(cx);
});
editor.next_notification(cx).await;
cx.read_window(window_id, |cx| {
assert!(
!editor.is_focused(cx),
"Initially, the editor should not be focused"
);
});
let initial_selections = editor.update(cx, |editor, cx| {
let initial_selections = editor.selections.display_ranges(cx);
assert_eq!(
@@ -1074,7 +1198,16 @@ mod tests {
});
search_bar.update(cx, |search_bar, cx| {
cx.focus(search_bar.query_editor.as_any());
search_bar.select_all_matches(&SelectAllMatches, cx);
});
cx.read_window(window_id, |cx| {
assert!(
editor.is_focused(cx),
"Should focus editor after successful SelectAllMatches"
);
});
search_bar.update(cx, |search_bar, cx| {
let all_selections =
editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
assert_eq!(
@@ -1082,8 +1215,6 @@ mod tests {
expected_query_matches_count,
"Should select all `a` characters in the buffer, but got: {all_selections:?}"
);
});
search_bar.update(cx, |search_bar, _| {
assert_eq!(
search_bar.active_match_index,
Some(0),
@@ -1093,6 +1224,14 @@ mod tests {
search_bar.update(cx, |search_bar, cx| {
search_bar.select_next_match(&SelectNextMatch, cx);
});
cx.read_window(window_id, |cx| {
assert!(
editor.is_focused(cx),
"Should still have editor focused after SelectNextMatch"
);
});
search_bar.update(cx, |search_bar, cx| {
let all_selections =
editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
assert_eq!(
@@ -1104,8 +1243,6 @@ mod tests {
all_selections, initial_selections,
"Next match should be different from the first selection"
);
});
search_bar.update(cx, |search_bar, _| {
assert_eq!(
search_bar.active_match_index,
Some(1),
@@ -1114,7 +1251,16 @@ mod tests {
});
search_bar.update(cx, |search_bar, cx| {
cx.focus(search_bar.query_editor.as_any());
search_bar.select_all_matches(&SelectAllMatches, cx);
});
cx.read_window(window_id, |cx| {
assert!(
editor.is_focused(cx),
"Should focus editor after successful SelectAllMatches"
);
});
search_bar.update(cx, |search_bar, cx| {
let all_selections =
editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
assert_eq!(
@@ -1122,8 +1268,6 @@ mod tests {
expected_query_matches_count,
"Should select all `a` characters in the buffer, but got: {all_selections:?}"
);
});
search_bar.update(cx, |search_bar, _| {
assert_eq!(
search_bar.active_match_index,
Some(1),
@@ -1133,6 +1277,14 @@ mod tests {
search_bar.update(cx, |search_bar, cx| {
search_bar.select_prev_match(&SelectPrevMatch, cx);
});
cx.read_window(window_id, |cx| {
assert!(
editor.is_focused(cx),
"Should still have editor focused after SelectPrevMatch"
);
});
let last_match_selections = search_bar.update(cx, |search_bar, cx| {
let all_selections =
editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
assert_eq!(
@@ -1144,13 +1296,41 @@ mod tests {
all_selections, initial_selections,
"Previous match should be the same as the first selection"
);
});
search_bar.update(cx, |search_bar, _| {
assert_eq!(
search_bar.active_match_index,
Some(0),
"Match index should be updated to the previous one"
);
all_selections
});
search_bar
.update(cx, |search_bar, cx| {
cx.focus(search_bar.query_editor.as_any());
search_bar.search("abas_nonexistent_match", None, cx)
})
.await
.unwrap();
search_bar.update(cx, |search_bar, cx| {
search_bar.select_all_matches(&SelectAllMatches, cx);
});
cx.read_window(window_id, |cx| {
assert!(
!editor.is_focused(cx),
"Should not switch focus to editor if SelectAllMatches does not find any matches"
);
});
search_bar.update(cx, |search_bar, cx| {
let all_selections =
editor.update(cx, |editor, cx| editor.selections.display_ranges(cx));
assert_eq!(
all_selections, last_match_selections,
"Should not select anything new if there are no matches"
);
assert!(
search_bar.active_match_index.is_none(),
"For no matches, there should be no active match index"
);
});
}
}
+179 -55
View File
@@ -1,5 +1,5 @@
use crate::{
SearchOption, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
SearchOptions, SelectNextMatch, SelectPrevMatch, ToggleCaseSensitive, ToggleRegex,
ToggleWholeWord,
};
use anyhow::Result;
@@ -19,7 +19,7 @@ use gpui::{
};
use menu::Confirm;
use postage::stream::Stream;
use project::{search::SearchQuery, Project};
use project::{search::SearchQuery, Entry, Project};
use semantic_index::SemanticIndex;
use smallvec::SmallVec;
use std::{
@@ -56,12 +56,12 @@ pub fn init(cx: &mut AppContext) {
cx.add_action(ProjectSearchBar::select_prev_match);
cx.capture_action(ProjectSearchBar::tab);
cx.capture_action(ProjectSearchBar::tab_previous);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOption::CaseSensitive, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOption::WholeWord, cx);
add_toggle_option_action::<ToggleRegex>(SearchOption::Regex, cx);
add_toggle_option_action::<ToggleCaseSensitive>(SearchOptions::CASE_SENSITIVE, cx);
add_toggle_option_action::<ToggleWholeWord>(SearchOptions::WHOLE_WORD, cx);
add_toggle_option_action::<ToggleRegex>(SearchOptions::REGEX, cx);
}
fn add_toggle_option_action<A: Action>(option: SearchOption, cx: &mut AppContext) {
fn add_toggle_option_action<A: Action>(option: SearchOptions, cx: &mut AppContext) {
cx.add_action(move |pane: &mut Pane, _: &A, cx: &mut ViewContext<Pane>| {
if let Some(search_bar) = pane.toolbar().read(cx).item_of_type::<ProjectSearchBar>() {
if search_bar.update(cx, |search_bar, cx| {
@@ -94,10 +94,8 @@ pub struct ProjectSearchView {
model: ModelHandle<ProjectSearch>,
query_editor: ViewHandle<Editor>,
results_editor: ViewHandle<Editor>,
case_sensitive: bool,
whole_word: bool,
regex: bool,
semantic: Option<SemanticSearchState>,
search_options: SearchOptions,
panels_with_errors: HashSet<InputPanel>,
active_match_index: Option<usize>,
search_id: usize,
@@ -488,9 +486,7 @@ impl ProjectSearchView {
let project;
let excerpts;
let mut query_text = String::new();
let mut regex = false;
let mut case_sensitive = false;
let mut whole_word = false;
let mut options = SearchOptions::NONE;
{
let model = model.read(cx);
@@ -498,9 +494,7 @@ impl ProjectSearchView {
excerpts = model.excerpts.clone();
if let Some(active_query) = model.active_query.as_ref() {
query_text = active_query.as_str().to_string();
regex = active_query.is_regex();
case_sensitive = active_query.case_sensitive();
whole_word = active_query.whole_word();
options = SearchOptions::from_query(active_query);
}
}
cx.observe(&model, |this, _, cx| this.model_changed(cx))
@@ -576,10 +570,8 @@ impl ProjectSearchView {
model,
query_editor,
results_editor,
case_sensitive,
whole_word,
regex,
semantic: None,
search_options: options,
panels_with_errors: HashSet::new(),
active_match_index: None,
query_editor_was_focused: false,
@@ -590,6 +582,28 @@ impl ProjectSearchView {
this
}
pub fn new_search_in_directory(
workspace: &mut Workspace,
dir_entry: &Entry,
cx: &mut ViewContext<Workspace>,
) {
if !dir_entry.is_dir() {
return;
}
let filter_path = dir_entry.path.join("**");
let Some(filter_str) = filter_path.to_str() else { return; };
let model = cx.add_model(|cx| ProjectSearch::new(workspace.project().clone(), cx));
let search = cx.add_view(|cx| ProjectSearchView::new(model, cx));
workspace.add_item(Box::new(search.clone()), cx);
search.update(cx, |search, cx| {
search
.included_files_editor
.update(cx, |editor, cx| editor.set_text(filter_str, cx));
search.focus_query_editor(cx)
});
}
// Re-activate the most recently activated search or the most recent if it has been closed.
// If no search exists in the workspace, create a new one.
fn deploy(
@@ -723,11 +737,11 @@ impl ProjectSearchView {
return None;
}
};
if self.regex {
if self.search_options.contains(SearchOptions::REGEX) {
match SearchQuery::regex(
text,
self.whole_word,
self.case_sensitive,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
included_files,
excluded_files,
) {
@@ -744,8 +758,8 @@ impl ProjectSearchView {
} else {
Some(SearchQuery::text(
text,
self.whole_word,
self.case_sensitive,
self.search_options.contains(SearchOptions::WHOLE_WORD),
self.search_options.contains(SearchOptions::CASE_SENSITIVE),
included_files,
excluded_files,
))
@@ -764,7 +778,7 @@ impl ProjectSearchView {
if let Some(index) = self.active_match_index {
let match_ranges = self.model.read(cx).match_ranges.clone();
let new_index = self.results_editor.update(cx, |editor, cx| {
editor.match_index_for_direction(&match_ranges, index, direction, cx)
editor.match_index_for_direction(&match_ranges, index, direction, 1, cx)
});
let range_to_select = match_ranges[new_index].clone();
@@ -805,7 +819,6 @@ impl ProjectSearchView {
self.active_match_index = None;
} else {
self.active_match_index = Some(0);
self.select_match(Direction::Next, cx);
self.update_match_index(cx);
let prev_search_id = mem::replace(&mut self.search_id, self.model.read(cx).search_id);
let is_new_search = self.search_id != prev_search_id;
@@ -897,9 +910,7 @@ impl ProjectSearchBar {
search_view.query_editor.update(cx, |editor, cx| {
editor.set_text(old_query.as_str(), cx);
});
search_view.regex = old_query.is_regex();
search_view.whole_word = old_query.whole_word();
search_view.case_sensitive = old_query.case_sensitive();
search_view.search_options = SearchOptions::from_query(&old_query);
}
}
new_query
@@ -987,19 +998,11 @@ impl ProjectSearchBar {
});
}
fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
fn toggle_search_option(&mut self, option: SearchOptions, cx: &mut ViewContext<Self>) -> bool {
if let Some(search_view) = self.active_project_search.as_ref() {
search_view.update(cx, |search_view, cx| {
let value = match option {
SearchOption::WholeWord => &mut search_view.whole_word,
SearchOption::CaseSensitive => &mut search_view.case_sensitive,
SearchOption::Regex => &mut search_view.regex,
};
*value = !*value;
if value.clone() {
search_view.semantic = None;
}
search_view.search_options.toggle(option);
search_view.semantic = None;
search_view.search(cx);
});
cx.notify();
@@ -1016,9 +1019,7 @@ impl ProjectSearchBar {
search_view.semantic = None;
} else if let Some(semantic_index) = SemanticIndex::global(cx) {
// TODO: confirm that it's ok to send this project
search_view.regex = false;
search_view.case_sensitive = false;
search_view.whole_word = false;
search_view.search_options = SearchOptions::none();
let project = search_view.model.read(cx).project.clone();
let index_task = semantic_index.update(cx, |semantic_index, cx| {
@@ -1113,12 +1114,12 @@ impl ProjectSearchBar {
fn render_option_button(
&self,
icon: &'static str,
option: SearchOption,
option: SearchOptions,
cx: &mut ViewContext<Self>,
) -> AnyElement<Self> {
let tooltip_style = theme::current(cx).tooltip.clone();
let is_active = self.is_option_enabled(option, cx);
MouseEventHandler::<Self, _>::new(option as usize, cx, |state, cx| {
MouseEventHandler::<Self, _>::new(option.bits as usize, cx, |state, cx| {
let theme = theme::current(cx);
let style = theme
.search
@@ -1134,7 +1135,7 @@ impl ProjectSearchBar {
})
.with_cursor_style(CursorStyle::PointingHand)
.with_tooltip::<Self>(
option as usize,
option.bits as usize,
format!("Toggle {}", option.label()),
Some(option.to_toggle_action()),
tooltip_style,
@@ -1179,14 +1180,9 @@ impl ProjectSearchBar {
.into_any()
}
fn is_option_enabled(&self, option: SearchOption, cx: &AppContext) -> bool {
fn is_option_enabled(&self, option: SearchOptions, cx: &AppContext) -> bool {
if let Some(search) = self.active_project_search.as_ref() {
let search = search.read(cx);
match option {
SearchOption::WholeWord => search.whole_word,
SearchOption::CaseSensitive => search.case_sensitive,
SearchOption::Regex => search.regex,
}
search.read(cx).search_options.contains(option)
} else {
false
}
@@ -1283,17 +1279,17 @@ impl View for ProjectSearchBar {
let row = row
.with_child(self.render_option_button(
"Case",
SearchOption::CaseSensitive,
SearchOptions::CASE_SENSITIVE,
cx,
))
.with_child(self.render_option_button(
"Word",
SearchOption::WholeWord,
SearchOptions::WHOLE_WORD,
cx,
))
.with_child(self.render_option_button(
"Regex",
SearchOption::Regex,
SearchOptions::REGEX,
cx,
))
.contained()
@@ -1669,6 +1665,134 @@ pub mod tests {
});
}
#[gpui::test]
async fn test_new_project_search_in_directory(
deterministic: Arc<Deterministic>,
cx: &mut TestAppContext,
) {
init_test(cx);
let fs = FakeFs::new(cx.background());
fs.insert_tree(
"/dir",
json!({
"a": {
"one.rs": "const ONE: usize = 1;",
"two.rs": "const TWO: usize = one::ONE + one::ONE;",
},
"b": {
"three.rs": "const THREE: usize = one::ONE + two::TWO;",
"four.rs": "const FOUR: usize = one::ONE + three::THREE;",
},
}),
)
.await;
let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
let worktree_id = project.read_with(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let (_, workspace) = cx.add_window(|cx| Workspace::test_new(project, cx));
let active_item = cx.read(|cx| {
workspace
.read(cx)
.active_pane()
.read(cx)
.active_item()
.and_then(|item| item.downcast::<ProjectSearchView>())
});
assert!(
active_item.is_none(),
"Expected no search panel to be active, but got: {active_item:?}"
);
let one_file_entry = cx.update(|cx| {
workspace
.read(cx)
.project()
.read(cx)
.entry_for_path(&(worktree_id, "a/one.rs").into(), cx)
.expect("no entry for /a/one.rs file")
});
assert!(one_file_entry.is_file());
workspace.update(cx, |workspace, cx| {
ProjectSearchView::new_search_in_directory(workspace, &one_file_entry, cx)
});
let active_search_entry = cx.read(|cx| {
workspace
.read(cx)
.active_pane()
.read(cx)
.active_item()
.and_then(|item| item.downcast::<ProjectSearchView>())
});
assert!(
active_search_entry.is_none(),
"Expected no search panel to be active for file entry"
);
let a_dir_entry = cx.update(|cx| {
workspace
.read(cx)
.project()
.read(cx)
.entry_for_path(&(worktree_id, "a").into(), cx)
.expect("no entry for /a/ directory")
});
assert!(a_dir_entry.is_dir());
workspace.update(cx, |workspace, cx| {
ProjectSearchView::new_search_in_directory(workspace, &a_dir_entry, cx)
});
let Some(search_view) = cx.read(|cx| {
workspace
.read(cx)
.active_pane()
.read(cx)
.active_item()
.and_then(|item| item.downcast::<ProjectSearchView>())
}) else {
panic!("Search view expected to appear after new search in directory event trigger")
};
deterministic.run_until_parked();
search_view.update(cx, |search_view, cx| {
assert!(
search_view.query_editor.is_focused(cx),
"On new search in directory, focus should be moved into query editor"
);
search_view.excluded_files_editor.update(cx, |editor, cx| {
assert!(
editor.display_text(cx).is_empty(),
"New search in directory should not have any excluded files"
);
});
search_view.included_files_editor.update(cx, |editor, cx| {
assert_eq!(
editor.display_text(cx),
a_dir_entry.path.join("**").display().to_string(),
"New search in directory should have included dir entry path"
);
});
});
search_view.update(cx, |search_view, cx| {
search_view
.query_editor
.update(cx, |query_editor, cx| query_editor.set_text("const", cx));
search_view.search(cx);
});
deterministic.run_until_parked();
search_view.update(cx, |search_view, cx| {
assert_eq!(
search_view
.results_editor
.update(cx, |editor, cx| editor.display_text(cx)),
"\n\nconst ONE: usize = 1;\n\n\nconst TWO: usize = one::ONE + one::ONE;",
"New search in directory should have a filter that matches a certain directory"
);
});
}
pub fn init_test(cx: &mut TestAppContext) {
cx.foreground().forbid_parking();
let fonts = cx.font_cache();
+33 -14
View File
@@ -1,5 +1,7 @@
use bitflags::bitflags;
pub use buffer_search::BufferSearchBar;
use gpui::{actions, Action, AppContext};
use project::search::SearchQuery;
pub use project_search::{ProjectSearchBar, ProjectSearchView};
pub mod buffer_search;
@@ -22,27 +24,44 @@ actions!(
]
);
#[derive(Clone, Copy, PartialEq)]
pub enum SearchOption {
WholeWord,
CaseSensitive,
Regex,
bitflags! {
#[derive(Default)]
pub struct SearchOptions: u8 {
const NONE = 0b000;
const WHOLE_WORD = 0b001;
const CASE_SENSITIVE = 0b010;
const REGEX = 0b100;
}
}
impl SearchOption {
impl SearchOptions {
pub fn label(&self) -> &'static str {
match self {
SearchOption::WholeWord => "Match Whole Word",
SearchOption::CaseSensitive => "Match Case",
SearchOption::Regex => "Use Regular Expression",
match *self {
SearchOptions::WHOLE_WORD => "Match Whole Word",
SearchOptions::CASE_SENSITIVE => "Match Case",
SearchOptions::REGEX => "Use Regular Expression",
_ => panic!("{:?} is not a named SearchOption", self),
}
}
pub fn to_toggle_action(&self) -> Box<dyn Action> {
match self {
SearchOption::WholeWord => Box::new(ToggleWholeWord),
SearchOption::CaseSensitive => Box::new(ToggleCaseSensitive),
SearchOption::Regex => Box::new(ToggleRegex),
match *self {
SearchOptions::WHOLE_WORD => Box::new(ToggleWholeWord),
SearchOptions::CASE_SENSITIVE => Box::new(ToggleCaseSensitive),
SearchOptions::REGEX => Box::new(ToggleRegex),
_ => panic!("{:?} is not a named SearchOption", self),
}
}
pub fn none() -> SearchOptions {
SearchOptions::NONE
}
pub fn from_query(query: &SearchQuery) -> SearchOptions {
let mut options = SearchOptions::NONE;
options.set(SearchOptions::WHOLE_WORD, query.whole_word());
options.set(SearchOptions::CASE_SENSITIVE, query.case_sensitive());
options.set(SearchOptions::REGEX, query.is_regex());
options
}
}