Add initial include/exclude project search UI

Kirill Bulatov created

Change summary

Cargo.lock                                              |  2 
crates/collab/src/tests/integration_tests.rs            |  5 
crates/collab/src/tests/randomized_integration_tests.rs |  5 
crates/project/Cargo.toml                               |  1 
crates/project/src/project_tests.rs                     | 20 +
crates/project/src/search.rs                            | 82 +++++++++
crates/rpc/proto/zed.proto                              |  2 
crates/search/Cargo.toml                                |  1 
crates/search/src/buffer_search.rs                      | 16 +
crates/search/src/project_search.rs                     | 92 ++++++++++
10 files changed, 212 insertions(+), 14 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -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",

crates/collab/src/tests/integration_tests.rs 🔗

@@ -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();

crates/collab/src/tests/randomized_integration_tests.rs 🔗

@@ -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 {

crates/project/Cargo.toml 🔗

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

crates/project/src/project_tests.rs 🔗

@@ -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]),

crates/project/src/search.rs 🔗

@@ -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,
+        }
+    }
 }

crates/rpc/proto/zed.proto 🔗

@@ -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 {

crates/search/Cargo.toml 🔗

@@ -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"] }

crates/search/src/buffer_search.rs 🔗

@@ -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);

crates/search/src/project_search.rs 🔗

@@ -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()