From 593d6a4d66a918f60ff4e11400c0a53c01b0b7a2 Mon Sep 17 00:00:00 2001 From: Dom Porada Date: Tue, 15 Sep 2026 11:49:56 +0000 Subject: [PATCH] Add support for the `"..."` entry in `read_only_files` (#64222) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Objective Let projects add read-only file patterns without repeating the patterns inherited from user settings. ## Solution Extend the `"..."` behavior introduced in #62769 to `read_only_files`. A `"..."` entry includes the inherited patterns. Omitting it replaces the list, and `[]` clears it. ## Testing Tested on macOS with settings merge, VS Code import, and project integration tests. The project test verifies that user and project patterns both apply while unrelated files remain editable. To check manually, configure different user and project patterns, include `"..."` in the project list, and open matching files. Repeat without `"..."` after restarting Zed to verify replacement. Linux, Windows, and manual app testing were not performed. ## 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 `read_only_files` so project settings can extend inherited read-only patterns. --------- Co-authored-by: Kirill Bulatov --- .../tests/integration/project_tests.rs | 62 ++++++++++-- crates/settings/src/vscode_import.rs | 83 +++++++++++++++- crates/settings_content/src/project.rs | 98 ++++++++++++++++--- .../settings_content/src/settings_content.rs | 8 +- crates/worktree/src/worktree_settings.rs | 2 +- docs/src/reference/all-settings.md | 18 ++++ 6 files changed, 243 insertions(+), 28 deletions(-) diff --git a/crates/project/tests/integration/project_tests.rs b/crates/project/tests/integration/project_tests.rs index 7fd83b2e62..9dbb7b67db 100644 --- a/crates/project/tests/integration/project_tests.rs +++ b/crates/project/tests/integration/project_tests.rs @@ -20493,10 +20493,10 @@ async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![ - "**/generated/**".to_string(), + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ "**/*.gen.rs".to_string(), - ]); + "**/generated/**".to_string(), + ])); }); }); }); @@ -20561,6 +20561,56 @@ async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { }); } +#[gpui::test] +async fn test_read_only_files_splice_project_settings(cx: &mut gpui::TestAppContext) { + init_test(cx); + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ + SplicingVec::REST.to_string(), + "**/*.lock".to_string(), + ])); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + ".zed": { + "settings.json": r#"{"read_only_files": ["**/generated/**", "..."]}"#, + }, + "generated": {"schema.rs": ""}, + "src": {"main.rs": ""}, + "yarn.lock": "", + }), + ) + .await; + let project = Project::test(fs, [path!("/root").as_ref()], cx).await; + cx.executor().run_until_parked(); + + for (relative_path, expected_read_only) in [ + ("generated/schema.rs", true), + ("src/main.rs", false), + ("yarn.lock", true), + ] { + let full_path = Path::new(path!("/root")).join(relative_path); + let result = project + .update(cx, |project, cx| project.open_local_buffer(&full_path, cx)) + .await; + match result { + Ok(buffer) => assert_eq!( + buffer.read_with(cx, |buffer, _| buffer.read_only()), + expected_read_only, + "{relative_path}" + ), + Err(error) => panic!("could not open {relative_path}: {error}"), + } + } +} + #[gpui::test] async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { init_test(cx); @@ -20569,7 +20619,7 @@ async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![]); + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![])); }); }); }); @@ -20661,10 +20711,10 @@ async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { cx.update(|cx| { cx.update_global::(|store, cx| { store.update_user_settings(cx, |settings| { - settings.project.worktree.read_only_files = Some(vec![ + settings.project.worktree.read_only_files = Some(SplicingVec::from(vec![ "**/*.lock".to_string(), "**/package-lock.json".to_string(), - ]); + ])); }); }); }); diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 997625f915..60836aaee8 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -1148,8 +1148,19 @@ impl VsCodeSettings { scan_symlinks: None, private_files: None, hidden_files: None, + // Zed cannot represent the writable exceptions in `files.readonlyExclude` read_only_files: self - .read_value("files.readonlyExclude") + .read_value("files.readonlyInclude") + .filter(|_| { + !self + .read_value("files.readonlyExclude") + .and_then(Value::as_object) + .is_some_and(|patterns| { + patterns + .values() + .any(|enabled| enabled.as_bool() == Some(true)) + }) + }) .and_then(|v| v.as_object()) .map(|v| { v.iter() @@ -1162,7 +1173,8 @@ impl VsCodeSettings { }) .collect::>() }) - .filter(|r| !r.is_empty()), + .filter(|r| !r.is_empty()) + .map(SplicingVec::from), } } } @@ -1222,6 +1234,7 @@ fn skip_default(value: T) -> Option { #[cfg(test)] mod tests { use super::*; + use crate::settings_content::merge_from::MergeFrom; fn imported_reduce_motion(content: &str) -> Option { VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode) @@ -1230,6 +1243,72 @@ mod tests { .reduce_motion } + #[test] + fn test_import_read_only_files() -> Result<()> { + let inherited = WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from(vec!["**/*.lock".to_string()])), + ..Default::default() + }; + let imported = VsCodeSettings::from_str( + r#"{ + "files.readonlyInclude": {"**/*.gen.rs": true, "**/*.lock": false}, + "files.readonlyExclude": {"**/editable.gen.rs": false} + }"#, + VsCodeSettingsSource::VsCode, + )? + .worktree_settings_content(); + assert_eq!( + serde_json::to_value(&imported.read_only_files)?, + serde_json::json!(["**/*.gen.rs"]) + ); + let mut replaced = inherited.clone(); + replaced.merge_from(&imported); + assert_eq!(replaced.read_only_files, imported.read_only_files); + + for content in [ + r#"{"files.readonlyExclude": {"**/*.gen.rs": true}}"#, + r#"{"files.readonlyInclude": {"**/*.gen.rs": false}}"#, + r#"{"files.readonlyInclude": {}}"#, + "{}", + ] { + let imported = VsCodeSettings::from_str(content, VsCodeSettingsSource::VsCode)? + .worktree_settings_content(); + assert_eq!(imported.read_only_files, None); + let mut unchanged = inherited.clone(); + unchanged.merge_from(&imported); + assert_eq!(unchanged.read_only_files, inherited.read_only_files); + } + Ok(()) + } + + #[test] + fn test_import_read_only_files_with_exclusions() -> Result<()> { + let imported = VsCodeSettings::from_str( + r#"{ + "files.readonlyExclude": {"**/*.lock": true, "**/editable.gen.rs": true}, + "files.readonlyInclude": {"**/*.gen.rs": true}, + "editor.tabSize": 8 + }"#, + VsCodeSettingsSource::VsCode, + )? + .settings_content(); + assert_eq!(imported.project.worktree.read_only_files, None); + assert_eq!( + imported.project.all_languages.defaults.tab_size, + NonZeroU32::new(8) + ); + let mut inherited = WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from(vec![String::from("**/*.lock")])), + ..WorktreeSettingsContent::default() + }; + inherited.merge_from(&imported.project.worktree); + assert_eq!( + serde_json::to_value(&inherited.read_only_files)?, + serde_json::json!(["**/*.lock"]) + ); + Ok(()) + } + #[test] fn test_import_reduce_motion() { assert_eq!( diff --git a/crates/settings_content/src/project.rs b/crates/settings_content/src/project.rs index 0335a26499..52888ec28e 100644 --- a/crates/settings_content/src/project.rs +++ b/crates/settings_content/src/project.rs @@ -183,8 +183,12 @@ pub struct WorktreeSettingsContent { /// Treat the files matching these globs as read-only. These files can be opened and viewed, /// but cannot be edited. This is useful for generated files, build outputs, or files from /// external dependencies that should not be modified directly. + /// + /// A "..." entry expands to the value being overridden. Leave "..." out + /// to replace the inherited globs, or use an empty list to clear them. + /// /// Default: [] - pub read_only_files: Option>, + pub read_only_files: Option, } #[with_fallible_options] @@ -955,7 +959,7 @@ pub enum GitHostingProviderKind { #[cfg(test)] mod tests { use super::*; - use crate::{REST_OF_FILE_SCAN_EXCLUSIONS, merge_from::MergeFrom}; + use crate::merge_from::MergeFrom; fn exclusions(globs: &[&str]) -> WorktreeSettingsContent { WorktreeSettingsContent { @@ -974,10 +978,7 @@ mod tests { let defaults = exclusions(&["**/.git", "**/.DS_Store"]); let mut extended = defaults.clone(); - extended.merge_from(&exclusions(&[ - "**/node_modules", - REST_OF_FILE_SCAN_EXCLUSIONS, - ])); + extended.merge_from(&exclusions(&["**/node_modules", SplicingVec::REST])); assert_eq!( extended.file_scan_exclusions.unwrap().0, vec!["**/node_modules", "**/.git", "**/.DS_Store"] @@ -994,8 +995,8 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_each_layer() { let mut settings = exclusions(&["**/.git"]); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"])); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/dist"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/target"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/dist"])); assert_eq!( settings.file_scan_exclusions.unwrap().0, @@ -1006,14 +1007,11 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_edge_cases() { let mut repeated = exclusions(&["**/.git"]); - repeated.merge_from(&exclusions(&[ - REST_OF_FILE_SCAN_EXCLUSIONS, - REST_OF_FILE_SCAN_EXCLUSIONS, - ])); + repeated.merge_from(&exclusions(&[SplicingVec::REST, SplicingVec::REST])); assert_eq!(repeated.file_scan_exclusions.unwrap().0, vec!["**/.git"]); let mut relisted = exclusions(&["**/.git", "**/.DS_Store"]); - relisted.merge_from(&exclusions(&["**/.git", REST_OF_FILE_SCAN_EXCLUSIONS])); + relisted.merge_from(&exclusions(&["**/.git", SplicingVec::REST])); assert_eq!( relisted.file_scan_exclusions.unwrap().0, vec!["**/.git", "**/.DS_Store"] @@ -1024,7 +1022,7 @@ mod tests { assert!(cleared.file_scan_exclusions.unwrap().0.is_empty()); let mut unchanged = exclusions(&["**/.git", "**/.DS_Store"]); - unchanged.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS])); + unchanged.merge_from(&exclusions(&[SplicingVec::REST])); assert_eq!( unchanged.file_scan_exclusions.unwrap().0, vec!["**/.git", "**/.DS_Store"] @@ -1034,7 +1032,7 @@ mod tests { #[test] fn test_file_scan_exclusions_splice_without_a_base_layer() { let mut settings = WorktreeSettingsContent::default(); - settings.merge_from(&exclusions(&[REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"])); + settings.merge_from(&exclusions(&[SplicingVec::REST, "**/target"])); // `Option::merge_from` replaces a `None` base outright rather than // splicing, so the sentinel survives here. `assets/settings/default.json` @@ -1042,7 +1040,75 @@ mod tests { // unwraps it, so no glob is ever compiled from this state. assert_eq!( settings.file_scan_exclusions.unwrap().0, - vec![REST_OF_FILE_SCAN_EXCLUSIONS, "**/target"] + vec![SplicingVec::REST, "**/target"] + ); + } + + fn read_only_files(globs: &[&str]) -> WorktreeSettingsContent { + WorktreeSettingsContent { + read_only_files: Some(SplicingVec::from( + globs + .iter() + .map(|glob| glob.to_string()) + .collect::>(), + )), + ..Default::default() + } + } + + #[test] + fn test_read_only_files_splice_rest_of_list() { + let inherited = read_only_files(&["**/*.lock"]); + let mut extended = inherited.clone(); + extended.merge_from(&read_only_files(&["**/generated/**", SplicingVec::REST])); + assert_eq!(extended, read_only_files(&["**/generated/**", "**/*.lock"])); + + let mut replaced = inherited; + replaced.merge_from(&read_only_files(&["**/generated/**"])); + assert_eq!(replaced, read_only_files(&["**/generated/**"])); + } + + #[test] + fn test_read_only_files_splice_each_layer() { + let mut settings = read_only_files(&[]); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/*.lock"])); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/generated/**"])); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/vendor/**"])); + assert_eq!( + settings, + read_only_files(&["**/*.lock", "**/generated/**", "**/vendor/**"]) + ); + } + + #[test] + fn test_read_only_files_splice_edge_cases() { + let inherited = read_only_files(&["**/*.gen.rs", "**/*.lock"]); + let mut repeated = inherited.clone(); + repeated.merge_from(&read_only_files(&[SplicingVec::REST, SplicingVec::REST])); + assert_eq!(repeated, inherited); + + let mut relisted = inherited.clone(); + relisted.merge_from(&read_only_files(&["**/*.lock", SplicingVec::REST])); + assert_eq!(relisted, read_only_files(&["**/*.lock", "**/*.gen.rs"])); + + let mut cleared = inherited.clone(); + cleared.merge_from(&read_only_files(&[])); + assert_eq!(cleared, read_only_files(&[])); + + let mut unchanged = inherited.clone(); + unchanged.merge_from(&read_only_files(&[SplicingVec::REST])); + assert_eq!(unchanged, inherited); + unchanged.merge_from(&WorktreeSettingsContent::default()); + assert_eq!(unchanged, inherited); + } + + #[test] + fn test_read_only_files_splice_without_a_base_layer() { + let mut settings = WorktreeSettingsContent::default(); + settings.merge_from(&read_only_files(&[SplicingVec::REST, "**/generated/**"])); + assert_eq!( + settings, + read_only_files(&[SplicingVec::REST, "**/generated/**"]) ); } diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index 8db6f134d7..290a14c0d9 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1507,8 +1507,6 @@ impl merge_from::MergeFrom for ExtendingVec { } } -pub const REST_OF_FILE_SCAN_EXCLUSIONS: &str = "..."; - // A SplicingVec in the settings replaces the value it merges over, except that // a `...` entry expands to that previous value. // @@ -1522,6 +1520,10 @@ pub const REST_OF_FILE_SCAN_EXCLUSIONS: &str = "..."; #[derive(Default, Debug, Clone, PartialEq, Serialize, Deserialize, JsonSchema)] pub struct SplicingVec(pub Vec); +impl SplicingVec { + pub const REST: &str = "..."; +} + impl From> for SplicingVec { fn from(vec: Vec) -> Self { SplicingVec(vec) @@ -1535,7 +1537,7 @@ impl merge_from::MergeFrom for SplicingVec { .0 .iter() .flat_map(|entry| { - if entry == REST_OF_FILE_SCAN_EXCLUSIONS { + if entry == Self::REST { inherited.clone() } else { vec![entry.clone()] diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index 7a439125bb..a45fbb0b83 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -64,7 +64,7 @@ impl Settings for WorktreeSettings { let file_scan_inclusions = worktree.file_scan_inclusions.unwrap(); 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(); + let read_only_files = worktree.read_only_files.unwrap_or_default().0; let scan_symlinks = worktree.scan_symlinks.unwrap(); let parsed_file_scan_inclusions: Vec = file_scan_inclusions .iter() diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index 40c909c526..d501e39217 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3790,6 +3790,24 @@ Examples: List of `string` glob patterns +## Read-Only Files {#read-only-files} + +- Default: `[]` +- Description: Glob patterns for files you can open and view but cannot edit, such as generated files or external dependencies. +- Setting: `read_only_files` + +Add this to your project’s `.zed/settings.json`: + +```json [settings] +{ + "read_only_files": ["**/generated/**", "..."] +} +``` + +Like [File Scan Exclusions](#file-scan-exclusions), `"..."` expands to the list inherited from the preceding settings layer. This example adds generated files to the read-only patterns from your user settings without repeating them. Duplicate entries collapse to their first occurrence. + +Omit `"..."` to replace the inherited list, or use `[]` to clear it. These patterns apply when you open a file. + ## Read SSH Config - Description: Whether to read SSH configuration files