Ignore invalid file patterns individually (#64525)

## Objective

Prevent one invalid file pattern from discarding valid patterns or
causing settings loading to panic.

The pre-existing exclusion-matcher bug was reported during review of
#64492 (part of #64399).

## Solution

Ignore invalid entries individually and log their errors in
`file_scan_exclusions`, `file_scan_inclusions`, `file_types`,
`hidden_files`, `private_files`, and `read_only_files`.

Preserve valid inherited entries without changing `"..."`, replacement,
or empty list behavior. Valid brace inclusion patterns remain usable,
including when an alternative spans multiple directories.

## Testing

Reproduced matcher loss and inclusion and file type panic paths before
fixing them. Targeted tests pass on macOS for mixed, inherited,
replacement, empty, and all-invalid lists, including inherited `.git`
exclusions, private file protections, and inclusion rescans with brace
patterns.

Windows and Linux execution remains untested.

## Self-Review Checklist:

- [x] I’ve reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [x] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed invalid file patterns discarding valid exclusions, hidden file
rules, or read-only rules, or causing inclusion settings to panic. Also
fixed invalid private file patterns discarding valid protections and
invalid file type patterns causing settings loading to panic.

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Dom PoradaandKirill Bulatov authored and GitHub committed 2026-09-20 19:09:14 +00:00
1 parent 1f37291957
commit 313f63eb71
7 files changed
+561 -67

No files matched your search

+65 -5
View File
@@ -974,15 +974,27 @@ impl settings::Settings for AllLanguageSettings {
let mut builder = GlobSetBuilder::new(); let mut builder = GlobSetBuilder::new();
for pattern in &patterns.0 { for pattern in &patterns.0 {
builder.add(Glob::new(pattern).unwrap()); match Glob::new(pattern) {
Ok(glob) => {
builder.add(glob);
}
Err(error) => {
log::error!(
"Failed to parse a `file_types` pattern for {language}. Skipping this pattern:\n\n{error}"
);
}
}
} }
let matcher = builder.build().unwrap_or_else(|error| {
log::error!(
"Failed to compile `file_types` patterns for {language}. Using an empty matcher:\n\n{error}"
);
GlobSet::empty()
});
file_types.insert( file_types.insert(
language.clone(), language.clone(),
( (matcher, patterns.0.iter().cloned().collect()),
builder.build().unwrap(),
patterns.0.iter().cloned().collect(),
),
); );
} }
@@ -1036,6 +1048,54 @@ mod tests {
use settings::{LocalSettingsKind, LocalSettingsPath, WorktreeId}; use settings::{LocalSettingsKind, LocalSettingsPath, WorktreeId};
use util::rel_path::rel_path; use util::rel_path::rel_path;
#[gpui::test]
fn test_file_types_preserves_valid_patterns(cx: &mut TestAppContext) {
cx.update(|cx| {
let mut store = SettingsStore::new(cx, &settings::default_settings());
store.register_setting::<AllLanguageSettings>();
for patterns in [
vec!["*.rs", "[", "config.*"],
vec!["[", "{"],
vec![],
vec!["*.rs", "config.*"],
] {
store
.set_user_settings(
&serde_json::json!({
"file_types": {
"Python": ["*.py"],
"Rust": patterns,
},
})
.to_string(),
cx,
)
.unwrap();
let settings = store.get::<AllLanguageSettings>(None);
let (matcher, sources) = settings.file_types.get("Rust").unwrap();
assert_eq!(sources, &patterns);
assert_eq!(matcher.is_match("main.rs"), patterns.contains(&"*.rs"));
assert_eq!(
matcher.is_match("config.custom"),
patterns.contains(&"config.*")
);
assert!(!matcher.is_match("main.py"));
assert!(!matcher.is_match("unrelated.txt"));
assert!(!matcher.is_match("["));
assert!(!matcher.is_match("{"));
assert_eq!(matcher.is_empty(), !patterns.contains(&"*.rs"));
let (matcher, sources) = settings.file_types.get("Python").unwrap();
assert_eq!(sources, &["*.py"]);
assert!(matcher.is_match("main.py"));
assert!(!matcher.is_match("main.rs"));
assert!(!matcher.is_match("config.custom"));
}
});
}
#[gpui::test] #[gpui::test]
fn test_edit_predictions_enabled_for_file(cx: &mut TestAppContext) { fn test_edit_predictions_enabled_for_file(cx: &mut TestAppContext) {
use crate::TestFile; use crate::TestFile;
+31 -16
View File
@@ -1154,20 +1154,23 @@ impl VsCodeSettings {
.any(|enabled| enabled.as_bool() == Some(true)) .any(|enabled| enabled.as_bool() == Some(true))
}) })
}) })
.and_then(|v| v.as_object()) .and_then(Value::as_object)
.map(|v| { .map(|patterns| {
v.iter() patterns
.filter_map(|(k, v)| { .iter()
if v.as_bool().unwrap_or(false) { .filter(|(pattern, enabled)| {
Some(k.to_owned()) !pattern.is_empty()
} else { && pattern.as_str() != SplicingVec::REST
None && enabled.as_bool() == Some(true)
}
}) })
.map(|(pattern, _)| pattern.to_owned())
.collect::<Vec<_>>() .collect::<Vec<_>>()
}) })
.filter(|r| !r.is_empty()) .filter(|patterns| !patterns.is_empty())
.map(SplicingVec::from), .map(|mut patterns| {
patterns.push(SplicingVec::REST.to_owned());
SplicingVec::from(patterns)
}),
} }
} }
} }
@@ -1284,7 +1287,13 @@ mod tests {
}; };
let imported = VsCodeSettings::from_str( let imported = VsCodeSettings::from_str(
r#"{ r#"{
"files.readonlyInclude": {"**/*.gen.rs": true, "**/*.lock": false}, "files.readonlyInclude": {
"": true,
"**/*.gen.rs": true,
"**/*.lock": false,
"**/generated/**": true,
"...": true
},
"files.readonlyExclude": {"**/editable.gen.rs": false} "files.readonlyExclude": {"**/editable.gen.rs": false}
}"#, }"#,
VsCodeSettingsSource::VsCode, VsCodeSettingsSource::VsCode,
@@ -1292,15 +1301,21 @@ mod tests {
.worktree_settings_content(); .worktree_settings_content();
assert_eq!( assert_eq!(
serde_json::to_value(&imported.read_only_files)?, serde_json::to_value(&imported.read_only_files)?,
serde_json::json!(["**/*.gen.rs"]) serde_json::json!(["**/*.gen.rs", "**/generated/**", "..."])
);
let mut spliced = inherited.clone();
spliced.merge_from(&imported);
assert_eq!(
serde_json::to_value(&spliced.read_only_files)?,
serde_json::json!(["**/*.gen.rs", "**/generated/**", "**/*.lock"])
); );
let mut replaced = inherited.clone();
replaced.merge_from(&imported);
assert_eq!(replaced.read_only_files, imported.read_only_files);
for content in [ for content in [
r#"{"files.readonlyExclude": {"**/*.gen.rs": true}}"#, r#"{"files.readonlyExclude": {"**/*.gen.rs": true}}"#,
r#"{"files.readonlyInclude": {"**/*.gen.rs": false}}"#, r#"{"files.readonlyInclude": {"**/*.gen.rs": false}}"#,
r#"{"files.readonlyInclude": {"": true}}"#,
r#"{"files.readonlyInclude": {"...": true}}"#,
r#"{"files.readonlyInclude": ["**/*.gen.rs"]}"#,
r#"{"files.readonlyInclude": {}}"#, r#"{"files.readonlyInclude": {}}"#,
"{}", "{}",
] { ] {
+100 -1
View File
@@ -1,4 +1,4 @@
use globset::{GlobBuilder, GlobSet, GlobSetBuilder}; use globset::{Glob, GlobBuilder, GlobSet, GlobSetBuilder};
use itertools::Itertools; use itertools::Itertools;
use regex::Regex; use regex::Regex;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -768,6 +768,44 @@ impl PathMatcher {
.build() .build()
}) })
.collect::<Result<Vec<_>, _>>()?; .collect::<Result<Vec<_>, _>>()?;
Self::from_globs(globs, path_style)
}
/// Skips invalid globs, reporting each error to `on_error`.
/// If the combined set cannot be built, reports the error and matches nothing.
pub fn new_lenient(
globs: impl IntoIterator<Item = impl AsRef<str>>,
path_style: PathStyle,
mut on_error: impl FnMut(globset::Error),
) -> Self {
let globs = globs
.into_iter()
.filter_map(|pattern| {
match GlobBuilder::new(pattern.as_ref())
.backslash_escape(path_style.is_posix())
.build()
{
Ok(glob) => Some(glob),
Err(error) => {
on_error(error);
None
}
}
})
.collect();
match Self::from_globs(globs, path_style) {
Ok(matcher) => matcher,
Err(error) => {
on_error(error);
Self {
path_style,
..Self::default()
}
}
}
}
fn from_globs(globs: Vec<Glob>, path_style: PathStyle) -> Result<Self, globset::Error> {
let sources = globs let sources = globs
.iter() .iter()
.filter_map(|glob| { .filter_map(|glob| {
@@ -1471,6 +1509,67 @@ mod tests {
use super::*; use super::*;
use util_macros::perf; use util_macros::perf;
#[test]
fn test_lenient_path_matcher() {
for path_style in [PathStyle::Unix, PathStyle::Windows] {
let patterns = ["**/.git", "[", "target/**", "{"];
let mut errors = Vec::new();
let matcher =
PathMatcher::new_lenient(patterns, path_style, |error| errors.push(error));
let expected = PathMatcher::new(["**/.git", "target/**"], path_style).unwrap();
assert_eq!(matcher, expected);
assert_eq!(errors.len(), 2);
assert_eq!(errors[0].glob(), Some("["));
assert_eq!(errors[1].glob(), Some("{"));
for path in ["nested/.git", "src/file.rs", "target/file.rs"] {
let path = RelPath::from_unix_str(path).unwrap();
assert_eq!(matcher.is_match(path), expected.is_match(path));
}
if path_style == PathStyle::local() {
assert!(matcher.is_match(RelPath::from_unix_str("nested/.git").unwrap()));
assert!(matcher.is_match(RelPath::from_unix_str("target/file.rs").unwrap()));
}
assert!(PathMatcher::new(patterns, path_style).is_err());
let mut errors = Vec::new();
let matcher =
PathMatcher::new_lenient(["[", "{"], path_style, |error| errors.push(error));
assert_eq!(errors.len(), 2);
assert_eq!(matcher.path_style, path_style);
assert_eq!(matcher.sources().count(), 0);
assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap()));
assert!(!matcher.is_match_std_path("file.rs"));
let matcher = PathMatcher::new_lenient([] as [&str; 0], path_style, |_| {
panic!("empty patterns are valid")
});
assert_eq!(matcher.path_style, path_style);
assert_eq!(matcher.sources().count(), 0);
assert!(!matcher.is_match(RelPath::from_unix_str("file.rs").unwrap()));
}
}
#[test]
fn test_lenient_path_matcher_preserves_escaping() {
for path_style in [PathStyle::Unix, PathStyle::Windows] {
let patterns = [r"directory\file.rs", r"literal\*", r"literal\[name]"];
let strict = PathMatcher::new(patterns, path_style).unwrap();
let lenient = PathMatcher::new_lenient(patterns, path_style, |_| {
panic!("valid patterns are preserved")
});
assert_eq!(lenient, strict);
for path in [
"directory/file.rs",
"literal*",
"literal/file.rs",
"literal[name]",
] {
let path = RelPath::from_unix_str(path).unwrap();
assert_eq!(lenient.is_match(path), strict.is_match(path));
}
}
}
#[test] #[test]
fn test_parse_str_treats_paren_suffix_as_position() { fn test_parse_str_treats_paren_suffix_as_position() {
// This documents the behavior that causes the folder-drop bug: a name ending in // This documents the behavior that causes the folder-drop bug: a name ending in
+92 -34
View File
@@ -1,9 +1,7 @@
use std::path::Path; use std::path::Path;
use anyhow::Context as _;
use settings::{RegisterSetting, ScanSymlinksSetting, Settings}; use settings::{RegisterSetting, ScanSymlinksSetting, Settings};
use util::{ use util::{
ResultExt,
paths::{PathMatcher, PathStyle}, paths::{PathMatcher, PathStyle},
rel_path::RelPath, rel_path::RelPath,
}; };
@@ -66,46 +64,106 @@ impl Settings for WorktreeSettings {
let hidden_files = worktree.hidden_files.unwrap().0; let hidden_files = worktree.hidden_files.unwrap().0;
let read_only_files = worktree.read_only_files.unwrap_or_default().0; let read_only_files = worktree.read_only_files.unwrap_or_default().0;
let scan_symlinks = worktree.scan_symlinks.unwrap(); let scan_symlinks = worktree.scan_symlinks.unwrap();
let parsed_file_scan_inclusions: Vec<String> = file_scan_inclusions let (file_scan_inclusions, parent_dir_scan_inclusions) =
.iter() file_scan_inclusion_matchers(file_scan_inclusions);
.flat_map(|glob| {
Path::new(glob)
.ancestors()
.skip(1)
.map(|a| a.to_string_lossy().into())
})
.filter(|p: &String| !p.is_empty())
.collect();
Self { Self {
prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels, prevent_sharing_in_public_channels: worktree.prevent_sharing_in_public_channels,
file_scan_exclusions: path_matchers(file_scan_exclusions, "file_scan_exclusions") file_scan_exclusions: valid_path_matchers(file_scan_exclusions, "file_scan_exclusions"),
.log_err() parent_dir_scan_inclusions,
.unwrap_or_default(), file_scan_inclusions,
parent_dir_scan_inclusions: path_matchers( private_files: valid_path_matchers(private_files, "private_files"),
parsed_file_scan_inclusions, hidden_files: valid_path_matchers(hidden_files, "hidden_files"),
"file_scan_inclusions", read_only_files: valid_path_matchers(read_only_files, "read_only_files"),
)
.unwrap(),
file_scan_inclusions: path_matchers(file_scan_inclusions, "file_scan_inclusions")
.unwrap(),
private_files: path_matchers(private_files, "private_files")
.log_err()
.unwrap_or_default(),
hidden_files: path_matchers(hidden_files, "hidden_files")
.log_err()
.unwrap_or_default(),
read_only_files: path_matchers(read_only_files, "read_only_files")
.log_err()
.unwrap_or_default(),
scan_symlinks, scan_symlinks,
file_scan_depth: worktree.file_scan_depth.filter(|depth| *depth > 0), file_scan_depth: worktree.file_scan_depth.filter(|depth| *depth > 0),
} }
} }
} }
fn path_matchers(mut values: Vec<String>, context: &'static str) -> anyhow::Result<PathMatcher> { fn valid_path_matchers(mut values: Vec<String>, context: &'static str) -> PathMatcher {
values.sort(); values.sort();
PathMatcher::new(values, PathStyle::local()) PathMatcher::new_lenient(values, PathStyle::local(), |error| {
.with_context(|| format!("Failed to parse globs from {}", context)) log::error!("Failed to compile patterns in `{context}`: {error}");
})
}
fn file_scan_inclusion_matchers(mut values: Vec<String>) -> (PathMatcher, PathMatcher) {
values.sort();
let mut errors = Vec::new();
let inclusions = PathMatcher::new_lenient(&values, PathStyle::local(), |error| {
errors.push(error);
});
let parent_inclusions = values
.iter()
.filter(|pattern| {
!errors.iter().any(|error| {
error
.glob()
.is_none_or(|invalid| invalid == pattern.as_str())
})
})
.flat_map(|pattern| inclusion_parent_patterns(pattern, PathStyle::local()))
.collect();
for error in errors {
log::error!("Failed to compile patterns in `file_scan_inclusions`: {error}");
}
(
inclusions,
valid_path_matchers(parent_inclusions, "file_scan_inclusions"),
)
}
fn inclusion_parent_patterns(pattern: &str, path_style: PathStyle) -> Vec<String> {
let mut parents = Vec::new();
let mut open_braces = 0_usize;
let mut class_start = None;
let mut escaped = false;
for (index, character) in pattern.char_indices() {
let is_separator = path_style.separators_ch().contains(&character);
let mut split_index = index;
if class_start.is_none() {
if escaped {
escaped = false;
if !is_separator {
continue;
}
split_index -= 1;
} else if character == '\\' && path_style.is_posix() {
escaped = true;
continue;
}
}
if let Some(start) = class_start {
let first_content = start
+ 1
+ usize::from(matches!(
pattern.as_bytes().get(start + 1),
Some(b'!' | b'^')
));
if character == ']' && index > first_content {
class_start = None;
}
} else {
match character {
'[' => class_start = Some(index),
'{' => open_braces += 1,
'}' => open_braces = open_braces.saturating_sub(1),
_ => {}
}
}
if is_separator {
let closing_braces = "}".repeat(open_braces);
if let Some(start) = class_start {
parents.push(format!("{}*{closing_braces}", &pattern[..start]));
} else if open_braces > 0 {
parents.push(format!("{}{closing_braces}", &pattern[..split_index]));
} else if split_index > 0 {
parents.push(pattern[..split_index].to_string());
}
}
}
parents.sort();
parents.dedup();
parents
} }
@@ -1,7 +1,9 @@
use serde_json::json;
use settings::{MergeFromTrait as _, RootUserSettings as _, Settings, SettingsContent};
use std::path::Path; use std::path::Path;
use util::{ use util::{
paths::{PathMatcher, PathStyle}, paths::{PathMatcher, PathStyle},
rel_path::RelPath, rel_path::{RelPath, rel_path},
}; };
use worktree::*; use worktree::*;
@@ -23,6 +25,215 @@ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings {
} }
} }
fn settings_with_patterns(setting: &str, patterns: &[&str]) -> WorktreeSettings {
zlog::init_test();
let mut content = SettingsContent::parse_json_with_comments(&settings::default_settings())
.expect("default settings");
let inherited: SettingsContent =
serde_json::from_value(json!({ setting: ["**/.git"] })).expect("inherited settings");
content.merge_from(&inherited);
let overrides: SettingsContent =
serde_json::from_value(json!({ setting: patterns })).expect("override settings");
content.merge_from(&overrides);
WorktreeSettings::from_settings(&content)
}
fn assert_file_pattern_settings(setting: &str) {
for (patterns, expected) in [
(vec!["**/alpha/**", "zeta/**"], [false, true, true]),
(vec!["**/alpha/**", "[", "zeta/**"], [false, true, true]),
(vec!["..."], [true, false, false]),
(vec!["...", "["], [true, false, false]),
(
vec!["**/alpha/**", "...", "[", "zeta/**"],
[true, true, true],
),
(vec!["[", "{"], [false, false, false]),
(vec![], [false, false, false]),
] {
let settings = settings_with_patterns(setting, &patterns);
let matcher = match setting {
"file_scan_exclusions" => &settings.file_scan_exclusions,
"file_scan_inclusions" => &settings.file_scan_inclusions,
"hidden_files" => &settings.hidden_files,
"read_only_files" => &settings.read_only_files,
_ => unreachable!(),
};
for (path, expected) in [".git", "alpha/file.txt", "zeta/file.txt"]
.into_iter()
.zip(expected)
{
assert_eq!(
matcher.is_match(rel_path(path)),
expected,
"{setting}: {patterns:?} matching {path}"
);
}
assert!(!matcher.is_match(rel_path("unmatched/file.txt")));
}
}
#[test]
fn test_invalid_file_scan_exclusions() {
assert_file_pattern_settings("file_scan_exclusions");
}
#[test]
fn test_invalid_exclusion_addition_preserves_git() {
let settings = settings_with_patterns("file_scan_exclusions", &["...", "["]);
assert!(settings.is_path_excluded(rel_path(".git/config")));
assert!(settings.is_path_excluded(rel_path("nested/.git/config")));
}
#[test]
fn test_invalid_file_scan_inclusions() {
assert_file_pattern_settings("file_scan_inclusions");
}
#[test]
fn test_invalid_hidden_files() {
assert_file_pattern_settings("hidden_files");
}
#[test]
fn test_invalid_read_only_files() {
assert_file_pattern_settings("read_only_files");
}
#[test]
fn test_invalid_direct_inclusions_do_not_include_parents() {
let settings =
settings_with_patterns("file_scan_inclusions", &["ignored/[", "valid/nested/**"]);
assert!(!settings.is_path_always_included(rel_path("ignored"), true));
assert!(!settings.is_path_always_included(rel_path("ignored/file.txt"), false));
assert!(settings.is_path_always_included(rel_path("valid"), true));
assert!(settings.is_path_always_included(rel_path("valid/nested"), true));
assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false));
}
#[test]
fn test_inclusions_preserve_brace_patterns() {
for (pattern, files) in [
(
"ignored/{one/two,three}/file.rs",
["ignored/one/two/file.rs", "ignored/three/file.rs"],
),
(
"{one/two,three}/file.rs",
["one/two/file.rs", "three/file.rs"],
),
] {
assert!(PathMatcher::new([pattern], PathStyle::local()).is_ok());
let settings =
settings_with_patterns("file_scan_inclusions", &[pattern, "valid/nested/**"]);
for file in files {
assert!(settings.is_path_always_included(rel_path(file), false));
for parent in rel_path(file)
.ancestors()
.skip(1)
.filter(|path| !path.is_empty())
{
assert!(
settings.is_path_always_included(parent, true),
"{pattern}: missing ancestor {parent:?}"
);
}
}
assert!(settings.is_path_always_included(rel_path("valid"), true));
assert!(settings.is_path_always_included(rel_path("valid/nested/file.txt"), false));
assert!(!settings.is_path_always_included(rel_path("unmatched/file.txt"), false));
assert!(
!settings.is_path_always_included(rel_path("unmatched"), true),
"{pattern}: unexpected ancestor"
);
}
}
#[test]
fn test_inclusion_parents_close_nested_braces() {
let settings = settings_with_patterns("file_scan_inclusions", &["{a/{b,c},d}/file.rs"]);
for directory in ["a", "a/b", "a/c", "d"] {
assert!(
settings.is_path_always_included(rel_path(directory), true),
"missing ancestor {directory}"
);
}
for directory in ["b", "c", "unmatched"] {
assert!(
!settings.is_path_always_included(rel_path(directory), true),
"unexpected ancestor {directory}"
);
}
assert!(settings.is_path_always_included(rel_path("a/b/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("d/file.rs"), false));
}
#[test]
fn test_inclusion_parents_close_braces_around_character_classes() {
let settings = settings_with_patterns("file_scan_inclusions", &["{a,[x/y]z}/file.rs"]);
assert!(settings.is_path_always_included(rel_path("a"), true));
assert!(settings.is_path_always_included(rel_path("xz"), true));
assert!(settings.is_path_always_included(rel_path("a/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("xz/file.rs"), false));
}
#[test]
fn test_inclusion_parents_preserve_literal_braces() {
let mut patterns = vec!["ignored/[{]one/file.rs"];
if PathStyle::local().is_posix() {
patterns.push(r"ignored/\{one/file.rs");
}
for pattern in patterns {
let settings = settings_with_patterns("file_scan_inclusions", &[pattern]);
assert!(settings.is_path_always_included(rel_path("ignored/{one/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("ignored/{one"), true));
assert!(settings.is_path_always_included(rel_path("ignored"), true));
assert!(!settings.is_path_always_included(rel_path("ignored/other/file.rs"), false));
}
}
#[cfg(not(windows))]
#[test]
fn test_inclusion_parents_preserve_escaping() {
let settings = settings_with_patterns("file_scan_inclusions", &[r"{one\/two,three}/file.rs"]);
assert!(settings.is_path_always_included(rel_path("one/two/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("one/two"), true));
assert!(settings.is_path_always_included(rel_path("one"), true));
let settings = settings_with_patterns("file_scan_inclusions", &[r"ignored\/nested/file.rs"]);
assert!(settings.is_path_always_included(rel_path("ignored/nested/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("ignored/nested"), true));
assert!(settings.is_path_always_included(rel_path("ignored"), true));
}
#[cfg(not(windows))]
#[test]
fn test_inclusion_parents_preserve_character_classes() {
let settings = settings_with_patterns("file_scan_inclusions", &[r"[a\]cache/file.rs"]);
assert!(settings.is_path_always_included(rel_path("acache/file.rs"), false));
assert!(settings.is_path_always_included(rel_path("acache"), true));
assert!(!settings.is_path_always_included(rel_path("unmatched"), true));
}
#[test]
fn test_private_files_preserves_valid_patterns() {
let settings = settings_with_patterns("private_files", &["**/private/**"]);
assert!(settings.is_path_private(rel_path("private/file.txt")));
assert!(settings.is_path_private(rel_path(".env.local")));
let settings = settings_with_patterns("private_files", &["**/private/**", "["]);
assert!(settings.is_path_private(rel_path("private/file.txt")));
assert!(settings.is_path_private(rel_path(".env.local")));
assert!(!settings.is_path_private(rel_path("src/main.rs")));
assert!(PathMatcher::new(["**/private/**", "["], PathStyle::local()).is_err());
for patterns in [&["["][..], &[][..]] {
let settings = settings_with_patterns("private_files", patterns);
assert!(settings.is_path_private(rel_path(".env.local")));
assert!(!settings.is_path_private(rel_path("private/file.txt")));
}
}
#[test] #[test]
fn test_is_path_read_only_with_glob_patterns() { fn test_is_path_read_only_with_glob_patterns() {
let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]); let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]);
@@ -2117,6 +2117,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
store.update_user_settings(cx, |settings| { store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![ settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![
"...".to_string(), "...".to_string(),
"[".to_string(),
"ignored/**/excluded.project".to_string(), "ignored/**/excluded.project".to_string(),
])); ]));
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![ settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
@@ -2142,7 +2143,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
json!({ json!({
".env.local": "", ".env.local": "",
".git": {}, ".git": {},
".gitignore": ".env*\n/ignored/\nunmatched.txt\n", ".gitignore": ".env*\n/ignored/\n/one/\n/three/\nunmatched.txt\n",
"ignored": { "ignored": {
"nested": { "nested": {
"excluded.project": "", "excluded.project": "",
@@ -2151,6 +2152,8 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
"unmatched.txt": "" "unmatched.txt": ""
} }
}, },
"one": { "two": { "file.rs": "" } },
"three": { "file.rs": "" },
"unmatched.txt": "" "unmatched.txt": ""
}), }),
) )
@@ -2178,6 +2181,7 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
"expected {path} to remain ignored" "expected {path} to remain ignored"
); );
} }
assert_eq!(tree.entry_for_path(rel_path(".git")), None);
assert_eq!( assert_eq!(
tree.entry_for_path(rel_path("ignored/nested/excluded.project")), tree.entry_for_path(rel_path("ignored/nested/excluded.project")),
None None
@@ -2195,17 +2199,54 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
); );
}); });
for (settings, included_paths) in [ let ignored_directories = ["ignored", "ignored/nested", "one", "one/two", "three"];
for (settings, included_paths, always_included_directories) in [
(
r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project", "{one/two,three}/file.rs", "invalid/["] }"#,
vec![
rel_path(".env.local"),
rel_path(".gitignore"),
rel_path("ignored/nested/included.project"),
rel_path("ignored/nested/included.user"),
rel_path("one/two/file.rs"),
rel_path("three/file.rs"),
],
&ignored_directories[..],
),
(
r#"{ "file_scan_inclusions": ["{one/two,three}/file.rs"] }"#,
vec![
rel_path(".gitignore"),
rel_path("one/two/file.rs"),
rel_path("three/file.rs"),
],
&["one", "one/two", "three"][..],
),
(
r#"{ "file_scan_inclusions": ["ignored/{nested,missing}/included.project"] }"#,
vec![
rel_path(".gitignore"),
rel_path("ignored/nested/included.project"),
],
&["ignored", "ignored/nested"][..],
),
( (
r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#, r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#,
vec![ vec![
rel_path(".gitignore"), rel_path(".gitignore"),
rel_path("ignored/nested/included.project"), rel_path("ignored/nested/included.project"),
], ],
&["ignored", "ignored/nested"][..],
),
(
r#"{ "file_scan_inclusions": ["invalid/["] }"#,
vec![rel_path(".gitignore")],
&[][..],
), ),
( (
r#"{ "file_scan_inclusions": [] }"#, r#"{ "file_scan_inclusions": [] }"#,
vec![rel_path(".gitignore")], vec![rel_path(".gitignore")],
&[][..],
), ),
] { ] {
cx.update(|cx| { cx.update(|cx| {
@@ -2227,6 +2268,14 @@ async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext
}) })
.await; .await;
tree.read_with(cx, |tree, _| { tree.read_with(cx, |tree, _| {
for path in ignored_directories {
assert_eq!(
tree.entry_for_path(rel_path(path))
.is_some_and(|entry| entry.is_always_included),
always_included_directories.contains(&path),
"ancestor inclusion for {path} with {settings}"
);
}
assert_eq!( assert_eq!(
tree.files(false, 0) tree.files(false, 0)
.map(|entry| entry.path.as_ref()) .map(|entry| entry.path.as_ref())
+10 -8
View File
@@ -88,6 +88,7 @@ The following VS Code settings are automatically imported when you use **Import
| `files.autoSave` | `autosave` | | `files.autoSave` | `autosave` |
| `files.autoSaveDelay` | `autosave.milliseconds` | | `files.autoSaveDelay` | `autosave.milliseconds` |
| `files.insertFinalNewline` | `ensure_final_newline_on_save` | | `files.insertFinalNewline` | `ensure_final_newline_on_save` |
| `files.readonlyInclude` | `read_only_files` |
| `files.simpleDialog.enable` | `use_system_path_prompts` | | `files.simpleDialog.enable` | `use_system_path_prompts` |
| `files.watcherExclude` | `file_scan_exclusions` | | `files.watcherExclude` | `file_scan_exclusions` |
| `search.smartCase` | `use_smartcase_search` | | `search.smartCase` | `use_smartcase_search` |
@@ -173,14 +174,15 @@ Zed doesn’t import extensions or keybindings, but this import gets core editor
You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly. You can configure most settings in the Settings Editor ({#kb zed::OpenSettings}). For advanced settings, run {#action zed::OpenSettingsFile} from the Command Palette to edit your settings file directly.
Here’s how common VS Code settings translate: Here’s how common VS Code settings translate:
| VS Code | Zed | Notes |
| --- | --- | --- | | VS Code | Zed | Notes |
| editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default | | ------------------- | ------------------ | ----------------------------- |
| editor.fontSize | buffer_font_size | Set in pixels | | editor.fontFamily | buffer_font_family | Zed uses Zed Mono by default |
| editor.tabSize | tab_size | Can override per language | | editor.fontSize | buffer_font_size | Set in pixels |
| editor.insertSpaces | insert_spaces | Boolean | | editor.tabSize | tab_size | Can override per language |
| editor.formatOnSave | format_on_save | Works with formatter enabled | | editor.insertSpaces | insert_spaces | Boolean |
| editor.wordWrap | soft_wrap | Supports optional wrap column | | editor.formatOnSave | format_on_save | Works with formatter enabled |
| editor.wordWrap | soft_wrap | Supports optional wrap column |
Zed also supports per-project settings. You can find these in the Settings Editor as well. Zed also supports per-project settings. You can find these in the Settings Editor as well.