From b92e6b84a38aca57bb716375c1044c1004f783a5 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Mon, 8 Dec 2025 11:23:14 +0100 Subject: [PATCH] worktree: Implement `read_only_files` worktree setting --- .zed/settings.json | 29 +-- assets/settings/default.json | 3 + crates/diagnostics/src/buffer_diagnostics.rs | 4 + crates/editor/src/items.rs | 4 + crates/project/src/buffer_store.rs | 32 ++- crates/project/src/project_tests.rs | 214 ++++++++++++++++++ .../settings/src/settings_content/project.rs | 6 + crates/settings/src/vscode_import.rs | 15 ++ crates/util/src/paths.rs | 32 ++- crates/workspace/src/item.rs | 8 + crates/workspace/src/pane.rs | 22 +- crates/worktree/src/worktree_settings.rs | 136 +++++++++++ 12 files changed, 469 insertions(+), 36 deletions(-) diff --git a/.zed/settings.json b/.zed/settings.json index 2760be95819e9340acf55f60616a9c22105ff52a..8ede01358d6b245d1d3f82ff987e9a4dba4e965f 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -2,46 +2,46 @@ "languages": { "Markdown": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "TOML": { "formatter": "prettier", - "format_on_save": "off" + "format_on_save": "off", }, "YAML": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "JSON": { "tab_size": 2, "preferred_line_length": 120, - "formatter": "prettier" + "formatter": "prettier", }, "JSONC": { "tab_size": 2, "preferred_line_length": 120, - "formatter": "prettier" + "formatter": "prettier", }, "JavaScript": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "CSS": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "Rust": { "tasks": { "variables": { - "RUST_DEFAULT_PACKAGE_RUN": "zed" - } - } - } + "RUST_DEFAULT_PACKAGE_RUN": "zed", + }, + }, + }, }, "file_types": { "Dockerfile": ["Dockerfile*[!dockerignore]"], "JSONC": ["**/assets/**/*.json", "renovate.json"], - "Git Ignore": ["dockerignore"] + "Git Ignore": ["dockerignore"], }, "hard_tabs": false, "formatter": "auto", @@ -59,6 +59,7 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" - ] + "**/.settings", + ], + "read_only_files": ["**/.rustup/**", "**/.cargo/registry/**", "**/.cargo/git/**", "target/**"], } diff --git a/assets/settings/default.json b/assets/settings/default.json index 154fe2d6e34e6573e95e7ffedbb46df8bbf10634..d98533fbe9c9c4fadc19b510437fe9ec596a1da2 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -1319,6 +1319,9 @@ // Globs to match files that will be considered "hidden". These files can be hidden from the // project panel by toggling the "hide_hidden" setting. "hidden_files": ["**/.*"], + // Globs to match files that will be opened as read-only. You can still view these files, + // but cannot edit them. This is useful for generated files or external dependencies. + "read_only_files": [], // Git gutter behavior configuration. "git": { // Global switch to enable or disable all git integration features. diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7..9a0adbd288e91ef42a01fb056d7656c338d6087d 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -763,6 +763,10 @@ impl Item for BufferDiagnosticsEditor { self.multibuffer.read(cx).is_dirty(cx) } + fn is_read_only(&self, cx: &App) -> bool { + self.multibuffer.read(cx).read_only() + } + fn navigate( &mut self, data: Box, diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index cfbb7c975c844f08d76a5568f1e02dfe3d7d74f1..ec01cf31696f23d4bacc6b368292228c49c03303 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -805,6 +805,10 @@ impl Item for Editor { self.buffer().read(cx).read(cx).is_dirty() } + fn is_read_only(&self, cx: &App) -> bool { + self.read_only(cx) + } + fn has_deleted_file(&self, cx: &App) -> bool { self.buffer().read(cx).read(cx).has_deleted_file() } diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 22106fa368904d91a5c3da4338e1a79cef7f0fd0..6ad2ca50b73a2bd0687fbe8bddc2bd5f50183613 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -22,10 +22,11 @@ use rpc::{ proto::{self}, }; +use settings::Settings; use std::{io, sync::Arc, time::Instant}; use text::{BufferId, ReplicaId}; use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath}; -use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; +use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings}; /// A set of open buffers. pub struct BufferStore { @@ -661,15 +662,28 @@ impl LocalBufferStore { this.add_buffer(buffer.clone(), cx)?; let buffer_id = buffer.read(cx).remote_id(); if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - this.path_to_buffer_id.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - buffer_id, - ); + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }; + let entry_id = file.entry_id; + + // Check if the file should be read-only based on settings + let settings = WorktreeSettings::get(Some((&project_path).into()), cx); + let is_read_only = if project_path.path.is_empty() { + settings.is_std_path_read_only(&file.full_path(cx)) + } else { + settings.is_path_read_only(&project_path.path) + }; + if is_read_only { + buffer.update(cx, |buffer, cx| { + buffer.set_capability(Capability::ReadOnly, cx); + }); + } + + this.path_to_buffer_id.insert(project_path, buffer_id); let this = this.as_local_mut().unwrap(); - if let Some(entry_id) = file.entry_id { + if let Some(entry_id) = entry_id { this.local_buffer_ids_by_entry_id .insert(entry_id, buffer_id); } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 4cebc72073cfda1bf07f028b1aff9fa7410c527d..c4ef138a395a0aca94acc5edef26f1335cfa4163 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -10922,3 +10922,217 @@ async fn test_git_worktree_remove(cx: &mut gpui::TestAppContext) { }); assert!(active_repo_path.is_none()); } + +#[gpui::test] +async fn test_read_only_files_setting(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Configure read_only_files setting + 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(), + "**/*.gen.rs".to_string(), + ]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": "fn main() {}", + "types.gen.rs": "// Generated file", + }, + "generated": { + "schema.rs": "// Auto-generated schema", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Open a regular file - should be read-write + let regular_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + regular_buffer.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "Regular file should not be read-only"); + }); + + // Open a file matching *.gen.rs pattern - should be read-only + let gen_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/types.gen.rs"), cx) + }) + .await + .unwrap(); + + gen_buffer.read_with(cx, |buffer, _| { + assert!( + buffer.read_only(), + "File matching *.gen.rs pattern should be read-only" + ); + }); + + // Open a file in generated directory - should be read-only + let generated_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/generated/schema.rs"), cx) + }) + .await + .unwrap(); + + generated_buffer.read_with(cx, |buffer, _| { + assert!( + buffer.read_only(), + "File in generated directory should be read-only" + ); + }); +} + +#[gpui::test] +async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Explicitly set read_only_files to empty (default behavior) + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(vec![]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": "fn main() {}", + }, + "generated": { + "schema.rs": "// Auto-generated schema", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // All files should be read-write when read_only_files is empty + let main_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + main_buffer.read_with(cx, |buffer, _| { + assert!( + !buffer.read_only(), + "Files should not be read-only when read_only_files is empty" + ); + }); + + let generated_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/generated/schema.rs"), cx) + }) + .await + .unwrap(); + + generated_buffer.read_with(cx, |buffer, _| { + assert!( + !buffer.read_only(), + "Generated files should not be read-only when read_only_files is empty" + ); + }); +} + +#[gpui::test] +async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Configure to make lock files read-only + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(vec![ + "**/*.lock".to_string(), + "**/package-lock.json".to_string(), + ]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "Cargo.lock": "# Lock file", + "Cargo.toml": "[package]", + "package-lock.json": "{}", + "package.json": "{}", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Cargo.lock should be read-only + let cargo_lock = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/Cargo.lock"), cx) + }) + .await + .unwrap(); + + cargo_lock.read_with(cx, |buffer, _| { + assert!(buffer.read_only(), "Cargo.lock should be read-only"); + }); + + // Cargo.toml should be read-write + let cargo_toml = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/Cargo.toml"), cx) + }) + .await + .unwrap(); + + cargo_toml.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "Cargo.toml should not be read-only"); + }); + + // package-lock.json should be read-only + let package_lock = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/package-lock.json"), cx) + }) + .await + .unwrap(); + + package_lock.read_with(cx, |buffer, _| { + assert!(buffer.read_only(), "package-lock.json should be read-only"); + }); + + // package.json should be read-write + let package_json = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/package.json"), cx) + }) + .await + .unwrap(); + + package_json.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "package.json should not be read-only"); + }); +} diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index 8e2d864149c9ecb6ca38ca73ef58205f588dc07b..d4f3412dcdfc43e6db8c74b4b9dc1c35a955a5cf 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/crates/settings/src/settings_content/project.rs @@ -97,6 +97,12 @@ pub struct WorktreeSettingsContent { /// Treat the files matching these globs as hidden files. You can hide hidden files in the project panel. /// Default: ["**/.*"] pub hidden_files: Option>, + + /// 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. + /// Default: [] + pub read_only_files: Option>, } #[with_fallible_options] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index d77754f611e8eb1746ee9061ce5b5e1dfdbdafdb..dd749221ecdf98a52e23120e8006f9759d9ed4c3 100644 --- a/crates/settings/src/vscode_import.rs +++ b/crates/settings/src/vscode_import.rs @@ -902,6 +902,21 @@ impl VsCodeSettings { .filter(|r| !r.is_empty()), private_files: None, hidden_files: None, + read_only_files: self + .read_value("files.readonlyExclude") + .and_then(|v| v.as_object()) + .map(|v| { + v.iter() + .filter_map(|(k, v)| { + if v.as_bool().unwrap_or(false) { + Some(k.to_owned()) + } else { + None + } + }) + .collect::>() + }) + .filter(|r| !r.is_empty()), } } } diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index a54f91c7a0392748cb64c984559cf1ce25c2a7d8..051795c8ff9bfac8c1bca98d4de34fca9bd7e215 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -786,13 +786,22 @@ impl PathWithPosition { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PathMatcher { sources: Vec<(String, RelPathBuf, /*trailing separator*/ bool)>, glob: GlobSet, path_style: PathStyle, } +impl std::fmt::Debug for PathMatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PathMatcher") + .field("sources", &self.sources) + .field("path_style", &self.path_style) + .finish() + } +} + impl PartialEq for PathMatcher { fn eq(&self, other: &Self) -> bool { self.sources.eq(&other.sources) @@ -844,12 +853,15 @@ impl PathMatcher { } pub fn is_match>(&self, other: P) -> bool { - if self.sources.iter().any(|(_, source, _)| { - other.as_ref().starts_with(source) || other.as_ref().ends_with(source) - }) { + let other = other.as_ref(); + if self + .sources + .iter() + .any(|(_, source, _)| other.starts_with(source) || other.ends_with(source)) + { return true; } - let other_path = other.as_ref().display(self.path_style); + let other_path = other.display(self.path_style); if self.glob.is_match(&*other_path) { return true; @@ -858,6 +870,16 @@ impl PathMatcher { self.glob .is_match(other_path.into_owned() + self.path_style.primary_separator()) } + + pub fn is_match_std_path>(&self, other: P) -> bool { + let other = other.as_ref(); + if self.sources.iter().any(|(_, source, _)| { + other.starts_with(source.as_std_path()) || other.ends_with(source.as_std_path()) + }) { + return true; + } + self.glob.is_match(other) + } } impl Default for PathMatcher { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 6e415c23454388bc7931ff9d5e499924d6b8f55d..d07a361c05d23a83a96708891f42f7bce99dde5f 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -255,6 +255,9 @@ pub trait Item: Focusable + EventEmitter + Render + Sized { fn is_dirty(&self, _: &App) -> bool { false } + fn is_read_only(&self, _: &App) -> bool { + false + } fn has_deleted_file(&self, _: &App) -> bool { false } @@ -476,6 +479,7 @@ pub trait ItemHandle: 'static + Send { fn item_id(&self) -> EntityId; fn to_any_view(&self) -> AnyView; fn is_dirty(&self, cx: &App) -> bool; + fn is_read_only(&self, cx: &App) -> bool; fn has_deleted_file(&self, cx: &App) -> bool; fn has_conflict(&self, cx: &App) -> bool; fn can_save(&self, cx: &App) -> bool; @@ -949,6 +953,10 @@ impl ItemHandle for Entity { self.read(cx).is_dirty(cx) } + fn is_read_only(&self, cx: &App) -> bool { + self.read(cx).is_read_only(cx) + } + fn has_deleted_file(&self, cx: &App) -> bool { self.read(cx).has_deleted_file(cx) } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f6256aee46b9e2b5c29c020e9ee12f6ff510210f..348a2a87de94dee4894f11b990eb8870c8a96681 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -2783,14 +2783,11 @@ impl Pane { h_flex() .gap_1() .items_center() - .children( - std::iter::once(if let Some(decorated_icon) = decorated_icon { - Some(div().child(decorated_icon.into_any_element())) - } else { - icon.map(|icon| div().child(icon.into_any_element())) - }) - .flatten(), - ) + .children(if let Some(decorated_icon) = decorated_icon { + Some(div().child(decorated_icon.into_any_element())) + } else { + icon.map(|icon| div().child(icon.into_any_element())) + }) .child(label) .id(("pane-tab-content", ix)) .map(|this| match tab_tooltip_content { @@ -2799,6 +2796,15 @@ impl Pane { this.tooltip(move |window, cx| element_fn(window, cx)) } None => this, + }) + .children(if item.is_read_only(cx) { + Some( + Icon::new(IconName::FileLock) + .color(Color::Muted) + .size(IconSize::Small), + ) + } else { + None }), ); diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index a86720184ebf6d33755decf415ad97bdcfd7fd8c..05e17e0c8f270fe53d5c7534b3b74f1e8a2149f5 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -20,6 +20,7 @@ pub struct WorktreeSettings { pub parent_dir_scan_inclusions: PathMatcher, pub private_files: PathMatcher, pub hidden_files: PathMatcher, + pub read_only_files: PathMatcher, } impl WorktreeSettings { @@ -45,6 +46,14 @@ impl WorktreeSettings { path.ancestors() .any(|ancestor| self.hidden_files.is_match(ancestor)) } + + pub fn is_path_read_only(&self, path: &RelPath) -> bool { + self.read_only_files.is_match(path) + } + + pub fn is_std_path_read_only(&self, path: &Path) -> bool { + self.read_only_files.is_match_std_path(path) + } } impl Settings for WorktreeSettings { @@ -54,6 +63,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 parsed_file_scan_inclusions: Vec = file_scan_inclusions .iter() .flat_map(|glob| { @@ -84,6 +94,9 @@ impl Settings for WorktreeSettings { 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(), } } } @@ -93,3 +106,126 @@ fn path_matchers(mut values: Vec, context: &'static str) -> anyhow::Resu PathMatcher::new(values, PathStyle::local()) .with_context(|| format!("Failed to parse globs from {}", context)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { + WorktreeSettings { + project_name: None, + prevent_sharing_in_public_channels: false, + file_scan_exclusions: PathMatcher::default(), + file_scan_inclusions: PathMatcher::default(), + parent_dir_scan_inclusions: PathMatcher::default(), + private_files: PathMatcher::default(), + hidden_files: PathMatcher::default(), + read_only_files: PathMatcher::new( + patterns.iter().map(|s| s.to_string()), + PathStyle::local(), + ) + .unwrap(), + } + } + + #[test] + fn test_is_path_read_only_with_glob_patterns() { + let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]); + + let generated_file = + RelPath::new(Path::new("src/generated/schema.rs"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&generated_file), + "Files in generated directory should be read-only" + ); + + let gen_rs_file = RelPath::new(Path::new("src/types.gen.rs"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&gen_rs_file), + "Files with .gen.rs extension should be read-only" + ); + + let regular_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(®ular_file), + "Regular files should not be read-only" + ); + + let similar_name = RelPath::new(Path::new("src/generator.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&similar_name), + "Files with 'generator' in name but not in generated dir should not be read-only" + ); + } + + #[test] + fn test_is_path_read_only_with_specific_paths() { + let settings = make_settings_with_read_only(&["vendor/**", "node_modules/**"]); + + let vendor_file = + RelPath::new(Path::new("vendor/lib/package.js"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&vendor_file), + "Files in vendor directory should be read-only" + ); + + let node_modules_file = RelPath::new( + Path::new("node_modules/lodash/index.js"), + PathStyle::local(), + ) + .unwrap(); + assert!( + settings.is_path_read_only(&node_modules_file), + "Files in node_modules should be read-only" + ); + + let src_file = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&src_file), + "Files in src should not be read-only" + ); + } + + #[test] + fn test_is_path_read_only_empty_patterns() { + let settings = make_settings_with_read_only(&[]); + + let any_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&any_file), + "No files should be read-only when patterns are empty" + ); + } + + #[test] + fn test_is_path_read_only_with_extension_pattern() { + let settings = make_settings_with_read_only(&["**/*.lock", "**/*.min.js"]); + + let lock_file = RelPath::new(Path::new("Cargo.lock"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&lock_file), + "Lock files should be read-only" + ); + + let nested_lock = + RelPath::new(Path::new("packages/app/yarn.lock"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&nested_lock), + "Nested lock files should be read-only" + ); + + let minified_js = + RelPath::new(Path::new("dist/bundle.min.js"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&minified_js), + "Minified JS files should be read-only" + ); + + let regular_js = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(®ular_js), + "Regular JS files should not be read-only" + ); + } +}