Add initial include/exclude project search UI

This commit is contained in:
Kirill Bulatov 2023-05-07 22:17:26 +03:00 committed by Kirill Bulatov
parent 3115c8381d
commit 915154b047
10 changed files with 212 additions and 14 deletions

2
Cargo.lock generated
View file

@ -4719,6 +4719,7 @@ dependencies = [
"glob",
"gpui",
"ignore",
"itertools",
"language",
"lazy_static",
"log",
@ -5771,6 +5772,7 @@ dependencies = [
"collections",
"editor",
"futures 0.3.25",
"glob",
"gpui",
"language",
"log",

View file

@ -4548,7 +4548,10 @@ async fn test_project_search(
// Perform a search as the guest.
let results = project_b
.update(cx_b, |project, cx| {
project.search(SearchQuery::text("world", false, false), cx)
project.search(
SearchQuery::text("world", false, false, Vec::new(), Vec::new()),
cx,
)
})
.await
.unwrap();

View file

@ -716,7 +716,10 @@ async fn apply_client_operation(
);
let search = project.update(cx, |project, cx| {
project.search(SearchQuery::text(query, false, false), cx)
project.search(
SearchQuery::text(query, false, false, Vec::new(), Vec::new()),
cx,
)
});
drop(project);
let search = cx.background().spawn(async move {

View file

@ -58,6 +58,7 @@ similar = "1.3"
smol.workspace = true
thiserror.workspace = true
toml = "0.5"
itertools = "0.10"
[dev-dependencies]
ctor.workspace = true

View file

@ -3297,9 +3297,13 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
.await;
let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
assert_eq!(
search(&project, SearchQuery::text("TWO", false, true), cx)
.await
.unwrap(),
search(
&project,
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
cx
)
.await
.unwrap(),
HashMap::from_iter([
("two.rs".to_string(), vec![6..9]),
("three.rs".to_string(), vec![37..40])
@ -3318,9 +3322,13 @@ async fn test_search(cx: &mut gpui::TestAppContext) {
});
assert_eq!(
search(&project, SearchQuery::text("TWO", false, true), cx)
.await
.unwrap(),
search(
&project,
SearchQuery::text("TWO", false, true, Vec::new(), Vec::new()),
cx
)
.await
.unwrap(),
HashMap::from_iter([
("two.rs".to_string(), vec![6..9]),
("three.rs".to_string(), vec![37..40]),

View file

@ -1,6 +1,7 @@
use aho_corasick::{AhoCorasick, AhoCorasickBuilder};
use anyhow::Result;
use client::proto;
use itertools::Itertools;
use language::{char_kind, Rope};
use regex::{Regex, RegexBuilder};
use smol::future::yield_now;
@ -17,6 +18,8 @@ pub enum SearchQuery {
query: Arc<str>,
whole_word: bool,
case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
},
Regex {
regex: Regex,
@ -24,11 +27,19 @@ pub enum SearchQuery {
multiline: bool,
whole_word: bool,
case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
},
}
impl SearchQuery {
pub fn text(query: impl ToString, whole_word: bool, case_sensitive: bool) -> Self {
pub fn text(
query: impl ToString,
whole_word: bool,
case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
) -> Self {
let query = query.to_string();
let search = AhoCorasickBuilder::new()
.auto_configure(&[&query])
@ -39,10 +50,18 @@ impl SearchQuery {
query: Arc::from(query),
whole_word,
case_sensitive,
files_to_include,
files_to_exclude,
}
}
pub fn regex(query: impl ToString, whole_word: bool, case_sensitive: bool) -> Result<Self> {
pub fn regex(
query: impl ToString,
whole_word: bool,
case_sensitive: bool,
files_to_include: Vec<glob::Pattern>,
files_to_exclude: Vec<glob::Pattern>,
) -> Result<Self> {
let mut query = query.to_string();
let initial_query = Arc::from(query.as_str());
if whole_word {
@ -64,17 +83,43 @@ impl SearchQuery {
multiline,
whole_word,
case_sensitive,
files_to_include,
files_to_exclude,
})
}
pub fn from_proto(message: proto::SearchProject) -> Result<Self> {
if message.regex {
Self::regex(message.query, message.whole_word, message.case_sensitive)
Self::regex(
message.query,
message.whole_word,
message.case_sensitive,
message
.files_to_include
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()?,
message
.files_to_exclude
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()?,
)
} else {
Ok(Self::text(
message.query,
message.whole_word,
message.case_sensitive,
message
.files_to_include
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()?,
message
.files_to_exclude
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()?,
))
}
}
@ -86,6 +131,16 @@ impl SearchQuery {
regex: self.is_regex(),
whole_word: self.whole_word(),
case_sensitive: self.case_sensitive(),
files_to_include: self
.files_to_include()
.iter()
.map(ToString::to_string)
.join(","),
files_to_exclude: self
.files_to_exclude()
.iter()
.map(ToString::to_string)
.join(","),
}
}
@ -224,4 +279,25 @@ impl SearchQuery {
pub fn is_regex(&self) -> bool {
matches!(self, Self::Regex { .. })
}
pub fn files_to_include(&self) -> &[glob::Pattern] {
match self {
Self::Text {
files_to_include, ..
} => files_to_include,
Self::Regex {
files_to_include, ..
} => files_to_include,
}
}
pub fn files_to_exclude(&self) -> &[glob::Pattern] {
match self {
Self::Text {
files_to_exclude, ..
} => files_to_exclude,
Self::Regex {
files_to_exclude, ..
} => files_to_exclude,
}
}
}

View file

@ -680,6 +680,8 @@ message SearchProject {
bool regex = 3;
bool whole_word = 4;
bool case_sensitive = 5;
string files_to_include = 6;
string files_to_exclude = 7;
}
message SearchProjectResponse {

View file

@ -27,6 +27,7 @@ serde.workspace = true
serde_derive.workspace = true
smallvec.workspace = true
smol.workspace = true
glob.workspace = true
[dev-dependencies]
editor = { path = "../editor", features = ["test-support"] }

View file

@ -573,7 +573,13 @@ impl BufferSearchBar {
active_searchable_item.clear_matches(cx);
} else {
let query = if self.regex {
match SearchQuery::regex(query, self.whole_word, self.case_sensitive) {
match SearchQuery::regex(
query,
self.whole_word,
self.case_sensitive,
Vec::new(),
Vec::new(),
) {
Ok(query) => query,
Err(_) => {
self.query_contains_error = true;
@ -582,7 +588,13 @@ impl BufferSearchBar {
}
}
} else {
SearchQuery::text(query, self.whole_word, self.case_sensitive)
SearchQuery::text(
query,
self.whole_word,
self.case_sensitive,
Vec::new(),
Vec::new(),
)
};
let matches = active_searchable_item.find_matches(query, cx);

View file

@ -86,6 +86,8 @@ pub struct ProjectSearchView {
active_match_index: Option<usize>,
search_id: usize,
query_editor_was_focused: bool,
included_files_editor: ViewHandle<Editor>,
excluded_files_editor: ViewHandle<Editor>,
}
pub struct ProjectSearchBar {
@ -448,6 +450,32 @@ impl ProjectSearchView {
})
.detach();
let included_files_editor = cx.add_view(|cx| {
let editor = Editor::single_line(
Some(Arc::new(|theme| theme.search.editor.input.clone())),
cx,
);
editor
});
// Subcribe to include_files_editor in order to reraise editor events for workspace item activation purposes
cx.subscribe(&included_files_editor, |_, _, event, cx| {
cx.emit(ViewEvent::EditorEvent(event.clone()))
})
.detach();
let excluded_files_editor = cx.add_view(|cx| {
let editor = Editor::single_line(
Some(Arc::new(|theme| theme.search.editor.input.clone())),
cx,
);
editor
});
// Subcribe to excluded_files_editor in order to reraise editor events for workspace item activation purposes
cx.subscribe(&excluded_files_editor, |_, _, event, cx| {
cx.emit(ViewEvent::EditorEvent(event.clone()))
})
.detach();
let mut this = ProjectSearchView {
search_id: model.read(cx).search_id,
model,
@ -459,6 +487,8 @@ impl ProjectSearchView {
query_contains_error: false,
active_match_index: None,
query_editor_was_focused: false,
included_files_editor,
excluded_files_editor,
};
this.model_changed(cx);
this
@ -525,8 +555,31 @@ impl ProjectSearchView {
fn build_search_query(&mut self, cx: &mut ViewContext<Self>) -> Option<SearchQuery> {
let text = self.query_editor.read(cx).text(cx);
let included_files = self
.included_files_editor
.read(cx)
.text(cx)
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()
// TODO kb validation
.unwrap_or_default();
let excluded_files = self
.excluded_files_editor
.read(cx)
.text(cx)
.split(',')
.map(|glob_str| glob::Pattern::new(glob_str))
.collect::<Result<_, _>>()
.unwrap_or_default();
if self.regex {
match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
match SearchQuery::regex(
text,
self.whole_word,
self.case_sensitive,
included_files,
excluded_files,
) {
Ok(query) => Some(query),
Err(_) => {
self.query_contains_error = true;
@ -539,6 +592,8 @@ impl ProjectSearchView {
text,
self.whole_word,
self.case_sensitive,
included_files,
excluded_files,
))
}
}
@ -869,6 +924,16 @@ impl View for ProjectSearchBar {
} else {
theme.search.editor.input.container
};
let included_files_view = ChildView::new(&search.included_files_editor, cx)
.aligned()
.left()
.flex(1., true);
let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
.aligned()
.left()
.flex(1., true);
Flex::row()
.with_child(
Flex::row()
@ -918,6 +983,31 @@ impl View for ProjectSearchBar {
.with_style(theme.search.option_button_group)
.aligned(),
)
.with_child(
// TODO kb better layout
Flex::row()
.with_child(
Label::new("Include files:", theme.search.match_index.text.clone())
.contained()
.with_style(theme.search.match_index.container)
.aligned(),
)
.with_child(included_files_view)
.with_child(
Label::new("Exclude files:", theme.search.match_index.text.clone())
.contained()
.with_style(theme.search.match_index.container)
.aligned(),
)
.with_child(excluded_files_view)
.contained()
.with_style(editor_container)
.aligned()
.constrained()
.with_min_width(theme.search.editor.min_width)
.with_max_width(theme.search.editor.max_width)
.flex(1., false),
)
.contained()
.with_style(theme.search.container)
.aligned()