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