Detailed changes
@@ -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/**"],
}
@@ -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.
@@ -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<dyn Any>,
@@ -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()
}
@@ -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);
}
@@ -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::<SettingsStore, _>(|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::<SettingsStore, _>(|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::<SettingsStore, _>(|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");
+ });
+}
@@ -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<Vec<String>>,
+
+ /// 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<Vec<String>>,
}
#[with_fallible_options]
@@ -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::<Vec<_>>()
+ })
+ .filter(|r| !r.is_empty()),
}
}
}
@@ -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<P: AsRef<RelPath>>(&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<P: AsRef<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 {
@@ -255,6 +255,9 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + 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<T: Item> ItemHandle for Entity<T> {
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)
}
@@ -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
}),
);
@@ -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<String> = 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<String>, 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"
+ );
+ }
+}