From 2229b4e2d6fc8b7a57f14516f2bfbc4f940d1b46 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Fri, 18 Sep 2026 10:40:43 +0000 Subject: [PATCH] Add support for the `"..."` entry in `file_scan_inclusions` (#64440) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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. --- crates/file_finder/src/file_finder_tests.rs | 11 +- .../project_panel/src/project_panel_tests.rs | 5 +- crates/settings/src/vscode_import.rs | 51 +++++-- crates/settings_content/src/project.rs | 84 +++++++++- crates/worktree/src/worktree_settings.rs | 2 +- .../tests/integration/worktree_tests.rs | 143 +++++++++++++++++- docs/src/migrate/vs-code.md | 5 +- docs/src/reference/all-settings.md | 12 ++ 8 files changed, 279 insertions(+), 34 deletions(-) diff --git a/crates/file_finder/src/file_finder_tests.rs b/crates/file_finder/src/file_finder_tests.rs index 6d0184325d..18d0728d46 100644 --- a/crates/file_finder/src/file_finder_tests.rs +++ b/crates/file_finder/src/file_finder_tests.rs @@ -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::(|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::(|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()])); }); }) }); diff --git a/crates/project_panel/src/project_panel_tests.rs b/crates/project_panel/src/project_panel_tests.rs index 668b0f6d9d..607e385f98 100644 --- a/crates/project_panel/src/project_panel_tests.rs +++ b/crates/project_panel/src/project_panel_tests.rs @@ -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() diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 60836aaee8..f74c65acac 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -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::>() - }) - .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 { diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 52888ec28e..acc89ccec8 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -148,13 +148,15 @@ pub struct WorktreeSettingsContent { /// ] pub file_scan_exclusions: Option, - /// 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>, + /// 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, /// 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::>(), + )), + ..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( diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index a45fbb0b83..2c18e2175b 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -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; diff --git a/crates/worktree/tests/integration/worktree_tests.rs b/crates/worktree/tests/integration/worktree_tests.rs index ffb4da9d3e..47a841f03e 100644 --- a/crates/worktree/tests/integration/worktree_tests.rs +++ b/crates/worktree/tests/integration/worktree_tests.rs @@ -2067,10 +2067,10 @@ async fn test_file_scan_inclusions(cx: &mut TestAppContext) { cx.update_global::(|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::(|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![ + 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::(|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::>(), + 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::(|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()])); }); }); }); diff --git a/docs/src/migrate/vs-code.md b/docs/src/migrate/vs-code.md index 588f2ce46c..0ff396af15 100644 --- a/docs/src/migrate/vs-code.md +++ b/docs/src/migrate/vs-code.md @@ -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` | diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 6e298ad063..29f1162852 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -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`