Merge branch 'main' into panels

Antonio Scandurra created

Change summary

Cargo.lock                                              |   2 
assets/keymaps/default.json                             |  12 
crates/collab/src/tests/integration_tests.rs            |   5 
crates/collab/src/tests/randomized_integration_tests.rs |   5 
crates/context_menu/src/context_menu.rs                 |  25 
crates/project/Cargo.toml                               |   1 
crates/project/src/project.rs                           |  43 
crates/project/src/project_tests.rs                     | 382 ++++++++++
crates/project/src/search.rs                            | 111 +++
crates/rpc/proto/zed.proto                              |   2 
crates/search/Cargo.toml                                |   1 
crates/search/src/buffer_search.rs                      |  16 
crates/search/src/project_search.rs                     | 313 +++++++-
crates/theme/src/theme.rs                               |   3 
crates/workspace/src/toolbar.rs                         |  26 
styles/src/styleTree/search.ts                          |  21 
16 files changed, 838 insertions(+), 130 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",

assets/keymaps/default.json 🔗

@@ -199,6 +199,18 @@
       "shift-enter": "search::SelectPrevMatch"
     }
   },
+  {
+    "context": "ProjectSearchBar > Editor",
+    "bindings": {
+      "escape": "project_search::ToggleFocus"
+    }
+  },
+  {
+    "context": "ProjectSearchView > Editor",
+    "bindings": {
+      "escape": "project_search::ToggleFocus"
+    }
+  },
   {
     "context": "Pane",
     "bindings": {

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/context_menu/src/context_menu.rs 🔗

@@ -126,7 +126,6 @@ pub struct ContextMenu {
     selected_index: Option<usize>,
     visible: bool,
     previously_focused_view_id: Option<usize>,
-    clicked: bool,
     parent_view_id: usize,
     _actions_observation: Subscription,
 }
@@ -187,7 +186,6 @@ impl ContextMenu {
             selected_index: Default::default(),
             visible: Default::default(),
             previously_focused_view_id: Default::default(),
-            clicked: false,
             parent_view_id,
             _actions_observation: cx.observe_actions(Self::action_dispatched),
         }
@@ -203,18 +201,14 @@ impl ContextMenu {
             .iter()
             .position(|item| item.action_id() == Some(action_id))
         {
-            if self.clicked {
-                self.cancel(&Default::default(), cx);
-            } else {
-                self.selected_index = Some(ix);
-                cx.notify();
-                cx.spawn(|this, mut cx| async move {
-                    cx.background().timer(Duration::from_millis(50)).await;
-                    this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?;
-                    anyhow::Ok(())
-                })
-                .detach_and_log_err(cx);
-            }
+            self.selected_index = Some(ix);
+            cx.notify();
+            cx.spawn(|this, mut cx| async move {
+                cx.background().timer(Duration::from_millis(50)).await;
+                this.update(&mut cx, |this, cx| this.cancel(&Default::default(), cx))?;
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
         }
     }
 
@@ -254,7 +248,6 @@ impl ContextMenu {
         self.items.clear();
         self.visible = false;
         self.selected_index.take();
-        self.clicked = false;
         cx.notify();
     }
 
@@ -454,7 +447,7 @@ impl ContextMenu {
                             .on_up(MouseButton::Left, |_, _, _| {}) // Capture these events
                             .on_down(MouseButton::Left, |_, _, _| {}) // Capture these events
                             .on_click(MouseButton::Left, move |_, menu, cx| {
-                                menu.clicked = true;
+                                menu.cancel(&Default::default(), cx);
                                 let window_id = cx.window_id();
                                 match &action {
                                     ContextMenuItemAction::Action(action) => {

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.rs 🔗

@@ -4208,14 +4208,19 @@ impl Project {
                                                     if matching_paths_tx.is_closed() {
                                                         break;
                                                     }
-
-                                                    abs_path.clear();
-                                                    abs_path.push(&snapshot.abs_path());
-                                                    abs_path.push(&entry.path);
-                                                    let matches = if let Some(file) =
-                                                        fs.open_sync(&abs_path).await.log_err()
+                                                    let matches = if query
+                                                        .file_matches(Some(&entry.path))
                                                     {
-                                                        query.detect(file).unwrap_or(false)
+                                                        abs_path.clear();
+                                                        abs_path.push(&snapshot.abs_path());
+                                                        abs_path.push(&entry.path);
+                                                        if let Some(file) =
+                                                            fs.open_sync(&abs_path).await.log_err()
+                                                        {
+                                                            query.detect(file).unwrap_or(false)
+                                                        } else {
+                                                            false
+                                                        }
                                                     } else {
                                                         false
                                                     };
@@ -4299,15 +4304,21 @@ impl Project {
                             let mut buffers_rx = buffers_rx.clone();
                             scope.spawn(async move {
                                 while let Some((buffer, snapshot)) = buffers_rx.next().await {
-                                    let buffer_matches = query
-                                        .search(snapshot.as_rope())
-                                        .await
-                                        .iter()
-                                        .map(|range| {
-                                            snapshot.anchor_before(range.start)
-                                                ..snapshot.anchor_after(range.end)
-                                        })
-                                        .collect::<Vec<_>>();
+                                    let buffer_matches = if query.file_matches(
+                                        snapshot.file().map(|file| file.path().as_ref()),
+                                    ) {
+                                        query
+                                            .search(snapshot.as_rope())
+                                            .await
+                                            .iter()
+                                            .map(|range| {
+                                                snapshot.anchor_before(range.start)
+                                                    ..snapshot.anchor_after(range.end)
+                                            })
+                                            .collect()
+                                    } else {
+                                        Vec::new()
+                                    };
                                     if !buffer_matches.is_empty() {
                                         worker_matched_buffers
                                             .insert(buffer.clone(), buffer_matches);

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,37 +3322,361 @@ 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]),
             ("four.rs".to_string(), vec![25..28, 36..39])
         ])
     );
+}
 
-    async fn search(
-        project: &ModelHandle<Project>,
-        query: SearchQuery,
-        cx: &mut gpui::TestAppContext,
-    ) -> Result<HashMap<String, Vec<Range<usize>>>> {
-        let results = project
-            .update(cx, |project, cx| project.search(query, cx))
-            .await?;
+#[gpui::test]
+async fn test_search_with_inclusions(cx: &mut gpui::TestAppContext) {
+    let search_query = "file";
 
-        Ok(results
-            .into_iter()
-            .map(|(buffer, ranges)| {
-                buffer.read_with(cx, |buffer, _| {
-                    let path = buffer.file().unwrap().path().to_string_lossy().to_string();
-                    let ranges = ranges
-                        .into_iter()
-                        .map(|range| range.to_offset(buffer))
-                        .collect::<Vec<_>>();
-                    (path, ranges)
-                })
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "one.rs": r#"// Rust file one"#,
+            "one.ts": r#"// TypeScript file one"#,
+            "two.rs": r#"// Rust file two"#,
+            "two.ts": r#"// TypeScript file two"#,
+        }),
+    )
+    .await;
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![glob::Pattern::new("*.odd").unwrap()],
+                Vec::new()
+            ),
+            cx
+        )
+        .await
+        .unwrap()
+        .is_empty(),
+        "If no inclusions match, no files should be returned"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![glob::Pattern::new("*.rs").unwrap()],
+                Vec::new()
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.rs".to_string(), vec![8..12]),
+            ("two.rs".to_string(), vec![8..12]),
+        ]),
+        "Rust only search should give only Rust files"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap(),
+                ],
+                Vec::new()
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.ts".to_string(), vec![14..18]),
+            ("two.ts".to_string(), vec![14..18]),
+        ]),
+        "TypeScript only search should give only TypeScript files, even if other inclusions don't match anything"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![
+                    glob::Pattern::new("*.rs").unwrap(),
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap(),
+                ],
+                Vec::new()
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.rs".to_string(), vec![8..12]),
+            ("one.ts".to_string(), vec![14..18]),
+            ("two.rs".to_string(), vec![8..12]),
+            ("two.ts".to_string(), vec![14..18]),
+        ]),
+        "Rust and typescript search should give both Rust and TypeScript files, even if other inclusions don't match anything"
+    );
+}
+
+#[gpui::test]
+async fn test_search_with_exclusions(cx: &mut gpui::TestAppContext) {
+    let search_query = "file";
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "one.rs": r#"// Rust file one"#,
+            "one.ts": r#"// TypeScript file one"#,
+            "two.rs": r#"// Rust file two"#,
+            "two.ts": r#"// TypeScript file two"#,
+        }),
+    )
+    .await;
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                Vec::new(),
+                vec![glob::Pattern::new("*.odd").unwrap()],
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.rs".to_string(), vec![8..12]),
+            ("one.ts".to_string(), vec![14..18]),
+            ("two.rs".to_string(), vec![8..12]),
+            ("two.ts".to_string(), vec![14..18]),
+        ]),
+        "If no exclusions match, all files should be returned"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                Vec::new(),
+                vec![glob::Pattern::new("*.rs").unwrap()],
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.ts".to_string(), vec![14..18]),
+            ("two.ts".to_string(), vec![14..18]),
+        ]),
+        "Rust exclusion search should give only TypeScript files"
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                Vec::new(),
+                vec![
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap(),
+                ],
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.rs".to_string(), vec![8..12]),
+            ("two.rs".to_string(), vec![8..12]),
+        ]),
+        "TypeScript exclusion search should give only Rust files, even if other exclusions don't match anything"
+    );
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                Vec::new(),
+                vec![
+                    glob::Pattern::new("*.rs").unwrap(),
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap(),
+                ],
+            ),
+            cx
+        )
+        .await
+        .unwrap().is_empty(),
+        "Rust and typescript exclusion should give no files, even if other exclusions don't match anything"
+    );
+}
+
+#[gpui::test]
+async fn test_search_with_exclusions_and_inclusions(cx: &mut gpui::TestAppContext) {
+    let search_query = "file";
+
+    let fs = FakeFs::new(cx.background());
+    fs.insert_tree(
+        "/dir",
+        json!({
+            "one.rs": r#"// Rust file one"#,
+            "one.ts": r#"// TypeScript file one"#,
+            "two.rs": r#"// Rust file two"#,
+            "two.ts": r#"// TypeScript file two"#,
+        }),
+    )
+    .await;
+    let project = Project::test(fs.clone(), ["/dir".as_ref()], cx).await;
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![glob::Pattern::new("*.odd").unwrap()],
+                vec![glob::Pattern::new("*.odd").unwrap()],
+            ),
+            cx
+        )
+        .await
+        .unwrap()
+        .is_empty(),
+        "If both no exclusions and inclusions match, exclusions should win and return nothing"
+    );
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![glob::Pattern::new("*.ts").unwrap()],
+                vec![glob::Pattern::new("*.ts").unwrap()],
+            ),
+            cx
+        )
+        .await
+        .unwrap()
+        .is_empty(),
+        "If both TypeScript exclusions and inclusions match, exclusions should win and return nothing files."
+    );
+
+    assert!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap()
+                ],
+                vec![
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap()
+                ],
+            ),
+            cx
+        )
+        .await
+        .unwrap()
+        .is_empty(),
+        "Non-matching inclusions and exclusions should not change that."
+    );
+
+    assert_eq!(
+        search(
+            &project,
+            SearchQuery::text(
+                search_query,
+                false,
+                true,
+                vec![
+                    glob::Pattern::new("*.ts").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap()
+                ],
+                vec![
+                    glob::Pattern::new("*.rs").unwrap(),
+                    glob::Pattern::new("*.odd").unwrap()
+                ],
+            ),
+            cx
+        )
+        .await
+        .unwrap(),
+        HashMap::from_iter([
+            ("one.ts".to_string(), vec![14..18]),
+            ("two.ts".to_string(), vec![14..18]),
+        ]),
+        "Non-intersecting TypeScript inclusions and Rust exclusions should return TypeScript files"
+    );
+}
+
+async fn search(
+    project: &ModelHandle<Project>,
+    query: SearchQuery,
+    cx: &mut gpui::TestAppContext,
+) -> Result<HashMap<String, Vec<Range<usize>>>> {
+    let results = project
+        .update(cx, |project, cx| project.search(query, cx))
+        .await?;
+
+    Ok(results
+        .into_iter()
+        .map(|(buffer, ranges)| {
+            buffer.read_with(cx, |buffer, _| {
+                let path = buffer.file().unwrap().path().to_string_lossy().to_string();
+                let ranges = ranges
+                    .into_iter()
+                    .map(|range| range.to_offset(buffer))
+                    .collect::<Vec<_>>();
+                (path, ranges)
             })
-            .collect())
-    }
+        })
+        .collect())
 }

crates/project/src/search.rs 🔗

@@ -1,22 +1,26 @@
 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;
 use std::{
     io::{BufRead, BufReader, Read},
     ops::Range,
+    path::Path,
     sync::Arc,
 };
 
-#[derive(Clone)]
+#[derive(Clone, Debug)]
 pub enum SearchQuery {
     Text {
         search: Arc<AhoCorasick<usize>>,
         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 +28,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 +51,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 +84,51 @@ 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(str::trim)
+                    .filter(|glob_str| !glob_str.is_empty())
+                    .map(|glob_str| glob::Pattern::new(glob_str))
+                    .collect::<Result<_, _>>()?,
+                message
+                    .files_to_exclude
+                    .split(',')
+                    .map(str::trim)
+                    .filter(|glob_str| !glob_str.is_empty())
+                    .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(str::trim)
+                    .filter(|glob_str| !glob_str.is_empty())
+                    .map(|glob_str| glob::Pattern::new(glob_str))
+                    .collect::<Result<_, _>>()?,
+                message
+                    .files_to_exclude
+                    .split(',')
+                    .map(str::trim)
+                    .filter(|glob_str| !glob_str.is_empty())
+                    .map(|glob_str| glob::Pattern::new(glob_str))
+                    .collect::<Result<_, _>>()?,
             ))
         }
     }
@@ -86,6 +140,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 +288,43 @@ 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,
+        }
+    }
+
+    pub fn file_matches(&self, file_path: Option<&Path>) -> bool {
+        match file_path {
+            Some(file_path) => {
+                !self
+                    .files_to_exclude()
+                    .iter()
+                    .any(|exclude_glob| exclude_glob.matches_path(file_path))
+                    && (self.files_to_include().is_empty()
+                        || self
+                            .files_to_include()
+                            .iter()
+                            .any(|include_glob| include_glob.matches_path(file_path)))
+            }
+            None => self.files_to_include().is_empty(),
+        }
+    }
 }

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 🔗

@@ -22,6 +22,7 @@ use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
     borrow::Cow,
+    collections::HashSet,
     mem,
     ops::Range,
     path::PathBuf,
@@ -34,7 +35,7 @@ use workspace::{
     ItemNavHistory, Pane, ToolbarItemLocation, ToolbarItemView, Workspace, WorkspaceId,
 };
 
-actions!(project_search, [SearchInNew, ToggleFocus]);
+actions!(project_search, [SearchInNew, ToggleFocus, NextField]);
 
 #[derive(Default)]
 struct ActiveSearches(HashMap<WeakModelHandle<Project>, WeakViewHandle<ProjectSearchView>>);
@@ -48,6 +49,7 @@ pub fn init(cx: &mut AppContext) {
     cx.add_action(ProjectSearchBar::select_prev_match);
     cx.add_action(ProjectSearchBar::toggle_focus);
     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);
@@ -75,6 +77,13 @@ struct ProjectSearch {
     search_id: usize,
 }
 
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+enum InputPanel {
+    Query,
+    Exclude,
+    Include,
+}
+
 pub struct ProjectSearchView {
     model: ModelHandle<ProjectSearch>,
     query_editor: ViewHandle<Editor>,
@@ -82,10 +91,12 @@ pub struct ProjectSearchView {
     case_sensitive: bool,
     whole_word: bool,
     regex: bool,
-    query_contains_error: bool,
+    panels_with_errors: HashSet<InputPanel>,
     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 {
@@ -425,7 +436,7 @@ impl ProjectSearchView {
             editor.set_text(query_text, cx);
             editor
         });
-        // Subcribe to query_editor in order to reraise editor events for workspace item activation purposes
+        // Subscribe to query_editor in order to reraise editor events for workspace item activation purposes
         cx.subscribe(&query_editor, |_, _, event, cx| {
             cx.emit(ViewEvent::EditorEvent(event.clone()))
         })
@@ -448,6 +459,40 @@ impl ProjectSearchView {
         })
         .detach();
 
+        let included_files_editor = cx.add_view(|cx| {
+            let mut editor = Editor::single_line(
+                Some(Arc::new(|theme| {
+                    theme.search.include_exclude_editor.input.clone()
+                })),
+                cx,
+            );
+            editor.set_placeholder_text("Include: crates/**/*.toml", cx);
+
+            editor
+        });
+        // Subscribe 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 mut editor = Editor::single_line(
+                Some(Arc::new(|theme| {
+                    theme.search.include_exclude_editor.input.clone()
+                })),
+                cx,
+            );
+            editor.set_placeholder_text("Exclude: vendor/*, *.lock", cx);
+
+            editor
+        });
+        // Subscribe 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,
@@ -456,9 +501,11 @@ impl ProjectSearchView {
             case_sensitive,
             whole_word,
             regex,
-            query_contains_error: false,
+            panels_with_errors: HashSet::new(),
             active_match_index: None,
             query_editor_was_focused: false,
+            included_files_editor,
+            excluded_files_editor,
         };
         this.model_changed(cx);
         this
@@ -525,11 +572,60 @@ 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 = match self
+            .included_files_editor
+            .read(cx)
+            .text(cx)
+            .split(',')
+            .map(str::trim)
+            .filter(|glob_str| !glob_str.is_empty())
+            .map(|glob_str| glob::Pattern::new(glob_str))
+            .collect::<Result<_, _>>()
+        {
+            Ok(included_files) => {
+                self.panels_with_errors.remove(&InputPanel::Include);
+                included_files
+            }
+            Err(_e) => {
+                self.panels_with_errors.insert(InputPanel::Include);
+                cx.notify();
+                return None;
+            }
+        };
+        let excluded_files = match self
+            .excluded_files_editor
+            .read(cx)
+            .text(cx)
+            .split(',')
+            .map(str::trim)
+            .filter(|glob_str| !glob_str.is_empty())
+            .map(|glob_str| glob::Pattern::new(glob_str))
+            .collect::<Result<_, _>>()
+        {
+            Ok(excluded_files) => {
+                self.panels_with_errors.remove(&InputPanel::Exclude);
+                excluded_files
+            }
+            Err(_e) => {
+                self.panels_with_errors.insert(InputPanel::Exclude);
+                cx.notify();
+                return None;
+            }
+        };
         if self.regex {
-            match SearchQuery::regex(text, self.whole_word, self.case_sensitive) {
-                Ok(query) => Some(query),
-                Err(_) => {
-                    self.query_contains_error = true;
+            match SearchQuery::regex(
+                text,
+                self.whole_word,
+                self.case_sensitive,
+                included_files,
+                excluded_files,
+            ) {
+                Ok(query) => {
+                    self.panels_with_errors.remove(&InputPanel::Query);
+                    Some(query)
+                }
+                Err(_e) => {
+                    self.panels_with_errors.insert(InputPanel::Query);
                     cx.notify();
                     None
                 }
@@ -539,6 +635,8 @@ impl ProjectSearchView {
                 text,
                 self.whole_word,
                 self.case_sensitive,
+                included_files,
+                excluded_files,
             ))
         }
     }
@@ -723,19 +821,50 @@ impl ProjectSearchBar {
     }
 
     fn tab(&mut self, _: &editor::Tab, cx: &mut ViewContext<Self>) {
-        if let Some(search_view) = self.active_project_search.as_ref() {
-            search_view.update(cx, |search_view, cx| {
-                if search_view.query_editor.is_focused(cx) {
-                    if !search_view.model.read(cx).match_ranges.is_empty() {
-                        search_view.focus_results_editor(cx);
-                    }
-                } else {
+        self.cycle_field(Direction::Next, cx);
+    }
+
+    fn tab_previous(&mut self, _: &editor::TabPrev, cx: &mut ViewContext<Self>) {
+        self.cycle_field(Direction::Prev, cx);
+    }
+
+    fn cycle_field(&mut self, direction: Direction, cx: &mut ViewContext<Self>) {
+        let active_project_search = match &self.active_project_search {
+            Some(active_project_search) => active_project_search,
+
+            None => {
+                cx.propagate_action();
+                return;
+            }
+        };
+
+        active_project_search.update(cx, |project_view, cx| {
+            let views = &[
+                &project_view.query_editor,
+                &project_view.included_files_editor,
+                &project_view.excluded_files_editor,
+            ];
+
+            let current_index = match views
+                .iter()
+                .enumerate()
+                .find(|(_, view)| view.is_focused(cx))
+            {
+                Some((index, _)) => index,
+
+                None => {
                     cx.propagate_action();
+                    return;
                 }
-            });
-        } else {
-            cx.propagate_action();
-        }
+            };
+
+            let new_index = match direction {
+                Direction::Next => (current_index + 1) % views.len(),
+                Direction::Prev if current_index == 0 => views.len() - 1,
+                Direction::Prev => (current_index - 1) % views.len(),
+            };
+            cx.focus(views[new_index]);
+        });
     }
 
     fn toggle_search_option(&mut self, option: SearchOption, cx: &mut ViewContext<Self>) -> bool {
@@ -864,59 +993,121 @@ impl View for ProjectSearchBar {
         if let Some(search) = self.active_project_search.as_ref() {
             let search = search.read(cx);
             let theme = cx.global::<Settings>().theme.clone();
-            let editor_container = if search.query_contains_error {
+            let query_container_style = if search.panels_with_errors.contains(&InputPanel::Query) {
                 theme.search.invalid_editor
             } else {
                 theme.search.editor.input.container
             };
-            Flex::row()
+            let include_container_style =
+                if search.panels_with_errors.contains(&InputPanel::Include) {
+                    theme.search.invalid_include_exclude_editor
+                } else {
+                    theme.search.include_exclude_editor.input.container
+                };
+            let exclude_container_style =
+                if search.panels_with_errors.contains(&InputPanel::Exclude) {
+                    theme.search.invalid_include_exclude_editor
+                } else {
+                    theme.search.include_exclude_editor.input.container
+                };
+
+            let included_files_view = ChildView::new(&search.included_files_editor, cx)
+                .aligned()
+                .left()
+                .flex(1.0, true);
+            let excluded_files_view = ChildView::new(&search.excluded_files_editor, cx)
+                .aligned()
+                .right()
+                .flex(1.0, true);
+
+            let row_spacing = theme.workspace.toolbar.container.padding.bottom;
+
+            Flex::column()
                 .with_child(
                     Flex::row()
                         .with_child(
-                            ChildView::new(&search.query_editor, cx)
+                            Flex::row()
+                                .with_child(
+                                    ChildView::new(&search.query_editor, cx)
+                                        .aligned()
+                                        .left()
+                                        .flex(1., true),
+                                )
+                                .with_children(search.active_match_index.map(|match_ix| {
+                                    Label::new(
+                                        format!(
+                                            "{}/{}",
+                                            match_ix + 1,
+                                            search.model.read(cx).match_ranges.len()
+                                        ),
+                                        theme.search.match_index.text.clone(),
+                                    )
+                                    .contained()
+                                    .with_style(theme.search.match_index.container)
+                                    .aligned()
+                                }))
+                                .contained()
+                                .with_style(query_container_style)
                                 .aligned()
-                                .left()
-                                .flex(1., true),
+                                .constrained()
+                                .with_min_width(theme.search.editor.min_width)
+                                .with_max_width(theme.search.editor.max_width)
+                                .flex(1., false),
+                        )
+                        .with_child(
+                            Flex::row()
+                                .with_child(self.render_nav_button("<", Direction::Prev, cx))
+                                .with_child(self.render_nav_button(">", Direction::Next, cx))
+                                .aligned(),
+                        )
+                        .with_child(
+                            Flex::row()
+                                .with_child(self.render_option_button(
+                                    "Case",
+                                    SearchOption::CaseSensitive,
+                                    cx,
+                                ))
+                                .with_child(self.render_option_button(
+                                    "Word",
+                                    SearchOption::WholeWord,
+                                    cx,
+                                ))
+                                .with_child(self.render_option_button(
+                                    "Regex",
+                                    SearchOption::Regex,
+                                    cx,
+                                ))
+                                .contained()
+                                .with_style(theme.search.option_button_group)
+                                .aligned(),
                         )
-                        .with_children(search.active_match_index.map(|match_ix| {
-                            Label::new(
-                                format!(
-                                    "{}/{}",
-                                    match_ix + 1,
-                                    search.model.read(cx).match_ranges.len()
-                                ),
-                                theme.search.match_index.text.clone(),
-                            )
-                            .contained()
-                            .with_style(theme.search.match_index.container)
-                            .aligned()
-                        }))
                         .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),
+                        .with_margin_bottom(row_spacing),
                 )
                 .with_child(
                     Flex::row()
-                        .with_child(self.render_nav_button("<", Direction::Prev, cx))
-                        .with_child(self.render_nav_button(">", Direction::Next, cx))
-                        .aligned(),
-                )
-                .with_child(
-                    Flex::row()
-                        .with_child(self.render_option_button(
-                            "Case",
-                            SearchOption::CaseSensitive,
-                            cx,
-                        ))
-                        .with_child(self.render_option_button("Word", SearchOption::WholeWord, cx))
-                        .with_child(self.render_option_button("Regex", SearchOption::Regex, cx))
-                        .contained()
-                        .with_style(theme.search.option_button_group)
-                        .aligned(),
+                        .with_child(
+                            Flex::row()
+                                .with_child(included_files_view)
+                                .contained()
+                                .with_style(include_container_style)
+                                .aligned()
+                                .constrained()
+                                .with_min_width(theme.search.include_exclude_editor.min_width)
+                                .with_max_width(theme.search.include_exclude_editor.max_width)
+                                .flex(1., false),
+                        )
+                        .with_child(
+                            Flex::row()
+                                .with_child(excluded_files_view)
+                                .contained()
+                                .with_style(exclude_container_style)
+                                .aligned()
+                                .constrained()
+                                .with_min_width(theme.search.include_exclude_editor.min_width)
+                                .with_max_width(theme.search.include_exclude_editor.max_width)
+                                .flex(1., false),
+                        ),
                 )
                 .contained()
                 .with_style(theme.search.container)
@@ -948,6 +1139,10 @@ impl ToolbarItemView for ProjectSearchBar {
             ToolbarItemLocation::Hidden
         }
     }
+
+    fn row_count(&self) -> usize {
+        2
+    }
 }
 
 #[cfg(test)]

crates/theme/src/theme.rs 🔗

@@ -309,6 +309,9 @@ pub struct Search {
     pub editor: FindEditor,
     pub invalid_editor: ContainerStyle,
     pub option_button_group: ContainerStyle,
+    pub include_exclude_editor: FindEditor,
+    pub invalid_include_exclude_editor: ContainerStyle,
+    pub include_exclude_inputs: ContainedText,
     pub option_button: Interactive<ContainedText>,
     pub match_background: Color,
     pub match_index: ContainedText,

crates/workspace/src/toolbar.rs 🔗

@@ -22,6 +22,13 @@ pub trait ToolbarItemView: View {
     }
 
     fn pane_focus_update(&mut self, _pane_focused: bool, _cx: &mut ViewContext<Self>) {}
+
+    /// Number of times toolbar's height will be repeated to get the effective height.
+    /// Useful when multiple rows one under each other are needed.
+    /// The rows have the same width and act as a whole when reacting to resizes and similar events.
+    fn row_count(&self) -> usize {
+        1
+    }
 }
 
 trait ToolbarItemViewHandle {
@@ -33,6 +40,7 @@ trait ToolbarItemViewHandle {
         cx: &mut WindowContext,
     ) -> ToolbarItemLocation;
     fn pane_focus_update(&mut self, pane_focused: bool, cx: &mut WindowContext);
+    fn row_count(&self, cx: &WindowContext) -> usize;
 }
 
 #[derive(Copy, Clone, Debug, PartialEq)]
@@ -66,12 +74,14 @@ impl View for Toolbar {
         let mut primary_right_items = Vec::new();
         let mut secondary_item = None;
         let spacing = theme.item_spacing;
+        let mut primary_items_row_count = 1;
 
         for (item, position) in &self.items {
             match *position {
                 ToolbarItemLocation::Hidden => {}
 
                 ToolbarItemLocation::PrimaryLeft { flex } => {
+                    primary_items_row_count = primary_items_row_count.max(item.row_count(cx));
                     let left_item = ChildView::new(item.as_any(), cx)
                         .aligned()
                         .contained()
@@ -84,6 +94,7 @@ impl View for Toolbar {
                 }
 
                 ToolbarItemLocation::PrimaryRight { flex } => {
+                    primary_items_row_count = primary_items_row_count.max(item.row_count(cx));
                     let right_item = ChildView::new(item.as_any(), cx)
                         .aligned()
                         .contained()
@@ -100,7 +111,7 @@ impl View for Toolbar {
                     secondary_item = Some(
                         ChildView::new(item.as_any(), cx)
                             .constrained()
-                            .with_height(theme.height)
+                            .with_height(theme.height * item.row_count(cx) as f32)
                             .into_any(),
                     );
                 }
@@ -117,7 +128,8 @@ impl View for Toolbar {
         }
 
         let container_style = theme.container;
-        let height = theme.height;
+        let height = theme.height * primary_items_row_count as f32;
+        let nav_button_height = theme.height;
         let button_style = theme.nav_button;
         let tooltip_style = cx.global::<Settings>().theme.tooltip.clone();
 
@@ -127,6 +139,7 @@ impl View for Toolbar {
                     .with_child(nav_button(
                         "icons/arrow_left_16.svg",
                         button_style,
+                        nav_button_height,
                         tooltip_style.clone(),
                         enable_go_backward,
                         spacing,
@@ -155,6 +168,7 @@ impl View for Toolbar {
                     .with_child(nav_button(
                         "icons/arrow_right_16.svg",
                         button_style,
+                        nav_button_height,
                         tooltip_style,
                         enable_go_forward,
                         spacing,
@@ -196,6 +210,7 @@ impl View for Toolbar {
 fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>)>(
     svg_path: &'static str,
     style: theme::Interactive<theme::IconButton>,
+    nav_button_height: f32,
     tooltip_style: TooltipStyle,
     enabled: bool,
     spacing: f32,
@@ -219,8 +234,9 @@ fn nav_button<A: Action, F: 'static + Fn(&mut Toolbar, &mut ViewContext<Toolbar>
             .with_style(style.container)
             .constrained()
             .with_width(style.button_width)
-            .with_height(style.button_width)
+            .with_height(nav_button_height)
             .aligned()
+            .top()
     })
     .with_cursor_style(if enabled {
         CursorStyle::PointingHand
@@ -338,6 +354,10 @@ impl<T: ToolbarItemView> ToolbarItemViewHandle for ViewHandle<T> {
             cx.notify();
         });
     }
+
+    fn row_count(&self, cx: &WindowContext) -> usize {
+        self.read(cx).row_count()
+    }
 }
 
 impl From<&dyn ToolbarItemViewHandle> for AnyViewHandle {

styles/src/styleTree/search.ts 🔗

@@ -26,6 +26,12 @@ export default function search(colorScheme: ColorScheme) {
         },
     }
 
+    const includeExcludeEditor = {
+        ...editor,
+        minWidth: 100,
+        maxWidth: 250,
+    };
+
     return {
         // TODO: Add an activeMatchBackground on the rust side to differenciate between active and inactive
         matchBackground: withOpacity(foreground(layer, "accent"), 0.4),
@@ -64,9 +70,16 @@ export default function search(colorScheme: ColorScheme) {
             ...editor,
             border: border(layer, "negative"),
         },
+        includeExcludeEditor,
+        invalidIncludeExcludeEditor: {
+            ...includeExcludeEditor,
+            border: border(layer, "negative"),
+        },
         matchIndex: {
             ...text(layer, "mono", "variant"),
-            padding: 6,
+            padding: {
+                left: 6,
+            },
         },
         optionButtonGroup: {
             padding: {
@@ -74,6 +87,12 @@ export default function search(colorScheme: ColorScheme) {
                 right: 12,
             },
         },
+        includeExcludeInputs: {
+            ...text(layer, "mono", "variant"),
+            padding: {
+                right: 6,
+            },
+        },
         resultsStatus: {
             ...text(layer, "mono", "on"),
             size: 18,