worktree: Implement `read_only_files` worktree setting

Lukas Wirth created

Change summary

.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 +++++++++++++++++++
crates/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(-)

Detailed changes

.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/**"],
 }

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.

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<dyn Any>,

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()
     }

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);
                     }

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::<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");
+    });
+}

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<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]

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::<Vec<_>>()
+                })
+                .filter(|r| !r.is_empty()),
         }
     }
 }

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<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 {

crates/workspace/src/item.rs 🔗

@@ -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)
     }

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
                     }),
             );
 

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<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(&regular_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(&regular_js),
+            "Regular JS files should not be read-only"
+        );
+    }
+}