Add support for the "..." entry in file_scan_inclusions (#64440)

## Objective

Let projects add inclusion patterns without repeating defaults or
patterns inherited from user settings.

Part of #64399.

## Solution

Extend the `"..."` behavior from #62769 and #64222 to
`file_scan_inclusions` using the shared merge implementation. `"..."`
includes the resolved inherited list, omitting it replaces the list, and
`[]` clears it. Duplicate entries retain their first occurrence.

Stop importing VS Code’s `files.watcherInclude` as inclusion patterns
because it configures additional watch roots rather than overriding Git
ignores.

## Testing

Tested on macOS with settings merge, VS Code import, and worktree
integration tests. Coverage includes preservation of the `.env*`
default, layered inheritance, replacement, clearing, and deduplication.
The worktree test verifies that inherited and project-specific patterns
include Git-ignored files while exclusions still take precedence.

To check manually, configure different user and project inclusion
patterns and add ignored files matching each. Include `"..."` to retain
inherited patterns, then repeat without it and with `[]` to verify
replacement and clearing. Confirm that excluded files remain absent.

## 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:

- Added support for `"..."` in `file_scan_inclusions` so project
settings can extend inherited inclusion patterns.
This commit is contained in:
Dom Porada authored and GitHub committed 2026-09-18 10:40:43 +00:00
1 parent 18960e899a
commit 2229b4e2d6
8 files changed
+279 -34

No files matched your search

+6 -5
View File
@@ -7,7 +7,7 @@ use menu::{Cancel, Confirm, SelectNext, SelectPrevious};
use pretty_assertions::{assert_eq, assert_matches};
use project::{FS_WATCH_LATENCY, RemoveOptions};
use serde_json::json;
use settings::SettingsStore;
use settings::{SettingsStore, SplicingVec};
use util::{path, rel_path::rel_path};
use workspace::{
AppState, CloseActiveItem, Item, MultiWorkspace, OpenOptions, ToggleFileFinder, Workspace,
@@ -1003,10 +1003,10 @@ async fn test_ignored_root_with_file_inclusions(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_inclusions = Some(vec![
"height_demo/**/hi_bonjour".to_string(),
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
"**/height_1".to_string(),
]);
"height_demo/**/hi_bonjour".to_string(),
]));
});
})
});
@@ -1097,7 +1097,8 @@ async fn test_ignored_root_with_file_inclusions_repro(cx: &mut TestAppContext) {
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_inclusions = Some(vec!["**/.env".to_string()]);
settings.project.worktree.file_scan_inclusions =
Some(SplicingVec::from(vec!["**/.env".to_string()]));
});
})
});
@@ -6206,8 +6206,9 @@ async fn test_gitignored_and_always_included(cx: &mut gpui::TestAppContext) {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions =
Some(SplicingVec::from(Vec::new()));
settings.project.worktree.file_scan_inclusions =
Some(vec!["always_included_but_ignored_dir/*".to_string()]);
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
"always_included_but_ignored_dir/*".to_string(),
]));
settings
.project_panel
.get_or_insert_default()
+42 -9
View File
@@ -1136,15 +1136,8 @@ impl VsCodeSettings {
})
.filter(|r| !r.is_empty())
.map(SplicingVec::from),
file_scan_inclusions: self
.read_value("files.watcherInclude")
.and_then(|v| v.as_array())
.map(|v| {
v.iter()
.filter_map(|n| n.as_str().map(str::to_owned))
.collect::<Vec<_>>()
})
.filter(|r| !r.is_empty()),
// `files.watcherInclude` adds watch roots, not Git-ignore overrides
file_scan_inclusions: None,
scan_symlinks: None,
private_files: None,
hidden_files: None,
@@ -1243,6 +1236,46 @@ mod tests {
.reduce_motion
}
#[test]
fn test_import_watcher_include_preserves_file_scan_inclusions() -> Result<()> {
let inherited = WorktreeSettingsContent {
file_scan_inclusions: Some(SplicingVec::from(vec![
".env*".to_string(),
"**/*.local".to_string(),
])),
..Default::default()
};
for content in [
r#"{}"#,
r#"{"files.watcherInclude": []}"#,
r#"{"files.watcherInclude": ["linked-folder"]}"#,
r#"{"files.watcherInclude": ["..."]}"#,
r#"{"files.watcherInclude": ["linked-folder", false]}"#,
r#"{"files.watcherInclude": {"linked-folder": true}}"#,
] {
let mut content: Value = serde_json::from_str(content)?;
content["editor.tabSize"] = serde_json::json!(8);
let imported = VsCodeSettings::from_str(
&serde_json::to_string(&content)?,
VsCodeSettingsSource::VsCode,
)?
.settings_content();
assert_eq!(imported.project.worktree.file_scan_inclusions, None);
assert_eq!(
imported.project.all_languages.defaults.tab_size,
NonZeroU32::new(8)
);
let mut unchanged = inherited.clone();
unchanged.merge_from(&imported.project.worktree);
assert_eq!(
unchanged.file_scan_inclusions,
inherited.file_scan_inclusions
);
}
Ok(())
}
#[test]
fn test_import_read_only_files() -> Result<()> {
let inherited = WorktreeSettingsContent {
+77 -7
View File
@@ -148,13 +148,15 @@ pub struct WorktreeSettingsContent {
/// ]
pub file_scan_exclusions: Option<SplicingVec>,
/// Always include files that match these globs when scanning for files, even if they're
/// ignored by git. This setting is overridden by `file_scan_exclusions`.
/// Default: [
/// ".env*",
/// "docker-compose.*.yml",
/// ]
pub file_scan_inclusions: Option<Vec<String>>,
/// Always include files that match these globs when scanning for files, even
/// if they’re ignored by Git. This setting is overridden by
/// `file_scan_exclusions`.
///
/// A "..." entry expands to the value being overridden. Leave "..." out to
/// replace the inherited globs, or use an empty list to clear them.
///
/// Default: [".env*"]
pub file_scan_inclusions: Option<SplicingVec>,
/// When to scan content of linked directories.
///
@@ -1044,6 +1046,74 @@ mod tests {
);
}
fn inclusions(globs: &[&str]) -> WorktreeSettingsContent {
WorktreeSettingsContent {
file_scan_inclusions: Some(SplicingVec::from(
globs
.iter()
.map(|glob| glob.to_string())
.collect::<Vec<_>>(),
)),
..Default::default()
}
}
#[test]
fn test_file_scan_inclusions_splice_each_layer() {
let mut settings = inclusions(&[".env*"]);
settings.merge_from(&inclusions(&[SplicingVec::REST, "**/*.user"]));
assert_eq!(settings, inclusions(&[".env*", "**/*.user"]));
settings.merge_from(&inclusions(&["**/*.project", SplicingVec::REST]));
assert_eq!(
settings,
inclusions(&["**/*.project", ".env*", "**/*.user"])
);
}
#[test]
fn test_file_scan_inclusions_replace_and_clear() {
let mut settings = inclusions(&[".env*"]);
settings.merge_from(&inclusions(&["**/*.user"]));
assert_eq!(settings, inclusions(&["**/*.user"]));
settings.merge_from(&inclusions(&["**/*.project"]));
assert_eq!(settings, inclusions(&["**/*.project"]));
settings.merge_from(&inclusions(&[]));
assert_eq!(settings, inclusions(&[]));
settings.merge_from(&inclusions(&[SplicingVec::REST, "**/*.next"]));
assert_eq!(settings, inclusions(&["**/*.next"]));
}
#[test]
fn test_file_scan_inclusions_splice_preserves_first_occurrence() {
let inherited = inclusions(&[".env*", "**/*.user"]);
let mut settings = inherited.clone();
settings.merge_from(&WorktreeSettingsContent::default());
assert_eq!(settings, inherited);
settings.merge_from(&inclusions(&[SplicingVec::REST, SplicingVec::REST]));
assert_eq!(settings, inherited);
settings.merge_from(&inclusions(&[
"**/*.user",
SplicingVec::REST,
".env*",
"**/*.project",
SplicingVec::REST,
"**/*.project",
]));
assert_eq!(
settings,
inclusions(&["**/*.user", ".env*", "**/*.project"])
);
settings.merge_from(&inclusions(&["**/*.project", "**/*.user", "**/*.project"]));
assert_eq!(settings, inclusions(&["**/*.project", "**/*.user"]));
}
fn read_only_files(globs: &[&str]) -> WorktreeSettingsContent {
WorktreeSettingsContent {
read_only_files: Some(SplicingVec::from(
+1 -1
View File
@@ -61,7 +61,7 @@ impl Settings for WorktreeSettings {
fn from_settings(content: &settings::SettingsContent) -> Self {
let worktree = content.project.worktree.clone();
let file_scan_exclusions = worktree.file_scan_exclusions.unwrap().0;
let file_scan_inclusions = worktree.file_scan_inclusions.unwrap();
let file_scan_inclusions = worktree.file_scan_inclusions.unwrap().0;
let private_files = worktree.private_files.unwrap().0;
let hidden_files = worktree.hidden_files.unwrap();
let read_only_files = worktree.read_only_files.unwrap_or_default().0;
@@ -2067,10 +2067,10 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![]));
settings.project.worktree.file_scan_inclusions = Some(vec![
"node_modules/**/package.json".to_string(),
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
"**/.DS_Store".to_string(),
]);
"node_modules/**/package.json".to_string(),
]));
});
});
});
@@ -2108,6 +2108,135 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) {
});
}
#[gpui::test]
async fn test_file_scan_inclusions_from_project_settings(cx: &mut TestAppContext) {
init_test(cx);
let worktree_id = WorktreeId::from_proto(0);
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![
"...".to_string(),
"ignored/**/excluded.project".to_string(),
]));
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![
"...".to_string(),
"ignored/**/*.user".to_string(),
]));
});
store
.set_local_settings(
worktree_id,
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
LocalSettingsKind::Settings,
Some(r#"{ "file_scan_inclusions": ["...", "ignored/**/*.project"] }"#),
cx,
)
.expect("valid project settings");
});
});
let fs = FakeFs::new(cx.background_executor.clone());
fs.insert_tree(
path!("/root"),
json!({
".env.local": "",
".git": {},
".gitignore": ".env*\n/ignored/\nunmatched.txt\n",
"ignored": {
"nested": {
"excluded.project": "",
"included.project": "",
"included.user": "",
"unmatched.txt": ""
}
},
"unmatched.txt": ""
}),
)
.await;
let tree = build_worktree(fs, path!("/root"), cx).await;
tree.read_with(cx, |tree, _| {
for path in [
".env.local",
"ignored",
"ignored/nested",
"ignored/nested/included.project",
"ignored/nested/included.user",
] {
assert!(
tree.entry_for_path(rel_path(path))
.is_some_and(|entry| entry.is_ignored && entry.is_always_included),
"expected {path} to be ignored by Git and included by settings"
);
}
for path in ["ignored/nested/unmatched.txt", "unmatched.txt"] {
assert!(
tree.entry_for_path(rel_path(path))
.is_some_and(|entry| entry.is_ignored && !entry.is_always_included),
"expected {path} to remain ignored"
);
}
assert_eq!(
tree.entry_for_path(rel_path("ignored/nested/excluded.project")),
None
);
assert_eq!(
tree.files(false, 0)
.map(|entry| entry.path.as_ref())
.collect::<Vec<_>>(),
vec![
rel_path(".env.local"),
rel_path(".gitignore"),
rel_path("ignored/nested/included.project"),
rel_path("ignored/nested/included.user"),
]
);
});
for (settings, included_paths) in [
(
r#"{ "file_scan_inclusions": ["ignored/**/*.project"] }"#,
vec![
rel_path(".gitignore"),
rel_path("ignored/nested/included.project"),
],
),
(
r#"{ "file_scan_inclusions": [] }"#,
vec![rel_path(".gitignore")],
),
] {
cx.update(|cx| {
cx.update_global::<SettingsStore, _>(|store, cx| {
store
.set_local_settings(
worktree_id,
LocalSettingsPath::InWorktree(Arc::from(RelPath::empty())),
LocalSettingsKind::Settings,
Some(settings),
cx,
)
.expect("valid project settings");
});
});
cx.run_until_parked();
tree.read_with(cx, |tree, _| {
tree.as_local().expect("local worktree").scan_complete()
})
.await;
tree.read_with(cx, |tree, _| {
assert_eq!(
tree.files(false, 0)
.map(|entry| entry.path.as_ref())
.collect::<Vec<_>>(),
included_paths
);
});
}
}
#[gpui::test]
async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext) {
init_test(cx);
@@ -2139,7 +2268,7 @@ async fn test_file_scan_exclusions_overrules_inclusions(cx: &mut TestAppContext)
settings.project.worktree.file_scan_exclusions =
Some(SplicingVec::from(vec!["**/.DS_Store".to_string()]));
settings.project.worktree.file_scan_inclusions =
Some(vec!["**/.DS_Store".to_string()]);
Some(SplicingVec::from(vec!["**/.DS_Store".to_string()]));
});
});
});
@@ -2202,7 +2331,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![]));
settings.project.worktree.file_scan_inclusions =
Some(vec!["node_modules/**".to_string()]);
Some(SplicingVec::from(vec!["node_modules/**".to_string()]));
});
});
});
@@ -2236,7 +2365,7 @@ async fn test_file_scan_inclusions_reindexes_on_setting_change(cx: &mut TestAppC
cx.update_global::<SettingsStore, _>(|store, cx| {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_exclusions = Some(SplicingVec::from(vec![]));
settings.project.worktree.file_scan_inclusions = Some(vec![]);
settings.project.worktree.file_scan_inclusions = Some(SplicingVec::from(vec![]));
});
});
});
@@ -6818,7 +6947,7 @@ async fn test_file_scan_depth_pierced_by_inclusions(cx: &mut TestAppContext) {
store.update_user_settings(cx, |settings| {
settings.project.worktree.file_scan_depth = Some(1);
settings.project.worktree.file_scan_inclusions =
Some(vec!["junk/a/b/c/deep.txt".to_string()]);
Some(SplicingVec::from(vec!["junk/a/b/c/deep.txt".to_string()]));
});
});
});
+2 -3
View File
@@ -84,13 +84,12 @@ The following VS Code settings are automatically imported when you use **Import
| VS Code Setting | Zed Setting |
| --------------------------- | ------------------------------ |
| `files.associations` | `file_types` |
| `files.autoSave` | `autosave` |
| `files.autoSaveDelay` | `autosave.milliseconds` |
| `files.insertFinalNewline` | `ensure_final_newline_on_save` |
| `files.associations` | `file_types` |
| `files.watcherExclude` | `file_scan_exclusions` |
| `files.watcherInclude` | `file_scan_inclusions` |
| `files.simpleDialog.enable` | `use_system_path_prompts` |
| `files.watcherExclude` | `file_scan_exclusions` |
| `search.smartCase` | `use_smartcase_search` |
| `search.useIgnoreFiles` | `search.include_ignored` |
+12
View File
@@ -2383,6 +2383,18 @@ The `"..."` entry expands to the list you are overriding, so the example above e
}
```
Use `"..."` to extend the inherited list instead of replacing it. In your user settings, it expands to Zed’s defaults. In a project’s `.zed/settings.json`, it expands to the resolved list from your user settings, including any defaults you kept.
For example, add this to your settings.json to include generated files without repeating inherited patterns:
```json [settings]
{
"file_scan_inclusions": ["...", "generated/**"]
}
```
With the default user settings, this keeps `.env*` and adds `generated/**`. Omit `"..."` to replace the inherited list, or use `[]` to clear it. Duplicate patterns keep their first occurrence, even if `"..."` appears more than once. `file_scan_exclusions` still takes precedence.
## File Scan Depth
- Setting: `file_scan_depth`