search: Support brace syntax in project search include/exclude patterns (#47860)

Dream created

Closes #47527
## Summary
The include/exclude filters in Project Search now support standard glob
brace syntax like `{a,b}`.
**Before:** `crates/{search,project}/**/file.rs` would error with
"unclosed alternate group"
**After:** Pattern correctly matches files in both `crates/search/` and
`crates/project/`
## Implementation
Added `split_glob_patterns()` function that splits by comma only when
not inside braces, preserving the `{a,b}` syntax that `globset` natively
supports.

Release Notes:

- Added support for `{a,b}` glob syntax in project search
include/exclude filters

Here is the screenshot of before and after.

Before:
<img width="1414" height="408"
alt="{0233D673-E876-4CFC-81BC-E0DE778CA382}"
src="https://github.com/user-attachments/assets/f30170a8-6cb5-4ee6-9c30-fb21b2c18be5"
/>

After:
<img width="1271" height="635"
alt="{321F7C80-13A0-4478-BCE9-530F1824A9E2}"
src="https://github.com/user-attachments/assets/0bd70a01-d576-438f-9286-01aebb08aeaf"
/>

Change summary

crates/search/src/project_search.rs | 53 +++++++++++++++++++++++++++++-
1 file changed, 51 insertions(+), 2 deletions(-)

Detailed changes

crates/search/src/project_search.rs 🔗

@@ -71,6 +71,32 @@ actions!(
     ]
 );
 
+fn split_glob_patterns(text: &str) -> Vec<&str> {
+    let mut patterns = Vec::new();
+    let mut pattern_start = 0;
+    let mut brace_depth: usize = 0;
+    let mut escaped = false;
+
+    for (index, character) in text.char_indices() {
+        if escaped {
+            escaped = false;
+            continue;
+        }
+        match character {
+            '\\' => escaped = true,
+            '{' => brace_depth += 1,
+            '}' => brace_depth = brace_depth.saturating_sub(1),
+            ',' if brace_depth == 0 => {
+                patterns.push(&text[pattern_start..index]);
+                pattern_start = index + 1;
+            }
+            _ => {}
+        }
+    }
+    patterns.push(&text[pattern_start..]);
+    patterns
+}
+
 #[derive(Default)]
 struct ActiveSettings(HashMap<WeakEntity<Project>, ProjectSearchSettings>);
 
@@ -1381,8 +1407,8 @@ impl ProjectSearchView {
 
     fn parse_path_matches(&self, text: String, cx: &App) -> anyhow::Result<PathMatcher> {
         let path_style = self.entity.read(cx).project.read(cx).path_style(cx);
-        let queries = text
-            .split(',')
+        let queries = split_glob_patterns(&text)
+            .into_iter()
             .map(str::trim)
             .filter(|maybe_glob_str| !maybe_glob_str.is_empty())
             .map(str::to_owned)
@@ -2517,6 +2543,29 @@ pub mod tests {
     use util_macros::perf;
     use workspace::{DeploySearch, MultiWorkspace};
 
+    #[test]
+    fn test_split_glob_patterns() {
+        assert_eq!(split_glob_patterns("a,b,c"), vec!["a", "b", "c"]);
+        assert_eq!(split_glob_patterns("a, b, c"), vec!["a", " b", " c"]);
+        assert_eq!(
+            split_glob_patterns("src/{a,b}/**/*.rs"),
+            vec!["src/{a,b}/**/*.rs"]
+        );
+        assert_eq!(
+            split_glob_patterns("src/{a,b}/*.rs, tests/**/*.rs"),
+            vec!["src/{a,b}/*.rs", " tests/**/*.rs"]
+        );
+        assert_eq!(split_glob_patterns("{a,b},{c,d}"), vec!["{a,b}", "{c,d}"]);
+        assert_eq!(split_glob_patterns("{{a,b},{c,d}}"), vec!["{{a,b},{c,d}}"]);
+        assert_eq!(split_glob_patterns(""), vec![""]);
+        assert_eq!(split_glob_patterns("a"), vec!["a"]);
+        // Escaped characters should not be treated as special
+        assert_eq!(split_glob_patterns(r"a\,b,c"), vec![r"a\,b", "c"]);
+        assert_eq!(split_glob_patterns(r"\{a,b\}"), vec![r"\{a", r"b\}"]);
+        assert_eq!(split_glob_patterns(r"a\\,b"), vec![r"a\\", "b"]);
+        assert_eq!(split_glob_patterns(r"a\\\,b"), vec![r"a\\\,b"]);
+    }
+
     #[perf]
     #[gpui::test]
     async fn test_project_search(cx: &mut TestAppContext) {