worktree: Implement `read_only_files` worktree setting (#44376)

Lukas Wirth and Danilo Leal created

This mimics VSCode's `files.readonlyExclude` setting, to allow setting
specific path matches as readonly locations like lockfiles and generated
sources etc.

Also renders a lock icon to the right side of the path names for
readonly files now.
This does a couple more things for completion sake:
- Tabs of readonly buffers now render a file lock icon
- Multibuffer buffer headers now render a file lock icon if the excerpts
buffer is readonly
- ReadWrite multibuffers now no longer allow edits to read only buffers
contained within

Release Notes:

- Added `read_only_files` setting to allow specifying glob patterns of
files that should not be editable by default

---------

Co-authored-by: Danilo Leal <daniloleal09@gmail.com>

Change summary

.zed/settings.json                              |  29 +-
assets/settings/default.json                    |   3 
crates/diagnostics/src/buffer_diagnostics.rs    |   4 
crates/editor/src/actions.rs                    |   2 
crates/editor/src/editor.rs                     |  47 ++++
crates/editor/src/element.rs                    |   4 
crates/editor/src/items.rs                      |  27 ++
crates/language/src/buffer.rs                   |  17 +
crates/multi_buffer/src/multi_buffer.rs         |  36 --
crates/project/src/buffer_store.rs              |  32 ++
crates/project/src/lsp_store.rs                 |  66 ++++-
crates/project/src/project.rs                   |   2 
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                    |  18 +
crates/workspace/src/pane.rs                    |  74 +++++-
crates/workspace/src/workspace.rs               |  10 
crates/worktree/src/worktree_settings.rs        | 136 ++++++++++++
20 files changed, 684 insertions(+), 90 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/**/*.rs", "**/*.lock"],
 }

assets/settings/default.json 🔗

@@ -1323,6 +1323,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/actions.rs 🔗

@@ -844,7 +844,7 @@ actions!(
         /// from the current selections.
         UnwrapSyntaxNode,
         /// Wraps selections in tag specified by language.
-        WrapSelectionsInTag
+        WrapSelectionsInTag,
     ]
 );
 

crates/editor/src/editor.rs 🔗

@@ -3356,7 +3356,12 @@ impl Editor {
             {
                 let start_offset = selection_start.to_offset(buffer);
                 let position_matches = start_offset == completion_position.to_offset(buffer);
-                let continue_showing = if position_matches {
+                let continue_showing = if let Some((snap, ..)) =
+                    buffer.point_to_buffer_offset(completion_position)
+                    && !snap.capability.editable()
+                {
+                    false
+                } else if position_matches {
                     if self.snippet_stack.is_empty() {
                         buffer.char_kind_before(start_offset, Some(CharScopeContext::Completion))
                             == Some(CharKind::Word)
@@ -4315,10 +4320,26 @@ impl Editor {
         let mut new_autoclose_regions = Vec::new();
         let snapshot = self.buffer.read(cx).read(cx);
         let mut clear_linked_edit_ranges = false;
+        let mut all_selections_read_only = true;
 
         for (selection, autoclose_region) in
             self.selections_with_autoclose_regions(selections, &snapshot)
         {
+            if snapshot
+                .point_to_buffer_point(selection.head())
+                .is_none_or(|(snapshot, ..)| !snapshot.capability.editable())
+            {
+                continue;
+            }
+            if snapshot
+                .point_to_buffer_point(selection.tail())
+                .is_none_or(|(snapshot, ..)| !snapshot.capability.editable())
+            {
+                // note, ideally we'd clip the tail to the closest writeable region towards the head
+                continue;
+            }
+            all_selections_read_only = false;
+
             if let Some(scope) = snapshot.language_scope_at(selection.head()) {
                 // Determine if the inserted text matches the opening or closing
                 // bracket of any of this language's bracket pairs.
@@ -4598,6 +4619,10 @@ impl Editor {
             edits.push((selection.start..selection.end, text.clone()));
         }
 
+        if all_selections_read_only {
+            return;
+        }
+
         drop(snapshot);
 
         self.transact(window, cx, |this, window, cx| {
@@ -11067,6 +11092,26 @@ impl Editor {
         });
     }
 
+    pub fn toggle_read_only(
+        &mut self,
+        _: &workspace::ToggleReadOnlyFile,
+        _: &mut Window,
+        cx: &mut Context<Self>,
+    ) {
+        if let Some(buffer) = self.buffer.read(cx).as_singleton() {
+            buffer.update(cx, |buffer, cx| {
+                buffer.set_capability(
+                    match buffer.capability() {
+                        Capability::ReadWrite => Capability::Read,
+                        Capability::Read => Capability::ReadWrite,
+                        Capability::ReadOnly => Capability::ReadOnly,
+                    },
+                    cx,
+                );
+            })
+        }
+    }
+
     pub fn reload_file(&mut self, _: &ReloadFile, window: &mut Window, cx: &mut Context<Self>) {
         let Some(project) = self.project.clone() else {
             return;

crates/editor/src/element.rs 🔗

@@ -613,6 +613,7 @@ impl EditorElement {
         register_action(editor, window, Editor::edit_log_breakpoint);
         register_action(editor, window, Editor::enable_breakpoint);
         register_action(editor, window, Editor::disable_breakpoint);
+        register_action(editor, window, Editor::toggle_read_only);
         if editor.read(cx).enable_wrap_selections_in_tag(cx) {
             register_action(editor, window, Editor::wrap_selections_in_tag);
         }
@@ -4072,6 +4073,9 @@ impl EditorElement {
                                                 }
                                             })),
                                     )
+                                    .when(!for_excerpt.buffer.capability.editable(), |el| {
+                                        el.child(Icon::new(IconName::FileLock).color(Color::Muted))
+                                    })
                                     .when_some(parent_path, |then, path| {
                                         then.child(Label::new(path).truncate().color(
                                             if file_status.is_some_and(FileStatus::is_deleted) {

crates/editor/src/items.rs 🔗

@@ -1,6 +1,6 @@
 use crate::{
-    Anchor, Autoscroll, BufferSerialization, Editor, EditorEvent, EditorSettings, ExcerptId,
-    ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData,
+    Anchor, Autoscroll, BufferSerialization, Capability, Editor, EditorEvent, EditorSettings,
+    ExcerptId, ExcerptRange, FormatTarget, MultiBuffer, MultiBufferSnapshot, NavigationData,
     ReportEditorEvent, SearchWithinRange, SelectionEffects, ToPoint as _,
     display_map::HighlightKey,
     editor_settings::SeedQuerySetting,
@@ -805,6 +805,29 @@ impl Item for Editor {
         self.buffer().read(cx).read(cx).is_dirty()
     }
 
+    fn is_read_only(&self, cx: &App) -> bool {
+        self.read_only(cx)
+    }
+
+    // Note: this mirrors the logic in `Editor::toggle_read_only`, but is reachable
+    // without relying on focus-based action dispatch.
+    fn toggle_read_only(&mut self, window: &mut Window, cx: &mut Context<Self>) {
+        if let Some(buffer) = self.buffer.read(cx).as_singleton() {
+            buffer.update(cx, |buffer, cx| {
+                buffer.set_capability(
+                    match buffer.capability() {
+                        Capability::ReadWrite => Capability::Read,
+                        Capability::Read => Capability::ReadWrite,
+                        Capability::ReadOnly => Capability::ReadOnly,
+                    },
+                    cx,
+                );
+            });
+        }
+        cx.notify();
+        window.refresh();
+    }
+
     fn has_deleted_file(&self, cx: &App) -> bool {
         self.buffer().read(cx).read(cx).has_deleted_file()
     }

crates/language/src/buffer.rs 🔗

@@ -85,10 +85,19 @@ pub static BUFFER_DIFF_TASK: LazyLock<TaskLabel> = LazyLock::new(TaskLabel::new)
 pub enum Capability {
     /// The buffer is a mutable replica.
     ReadWrite,
+    /// The buffer is a mutable replica, but toggled to read-only.
+    Read,
     /// The buffer is a read-only replica.
     ReadOnly,
 }
 
+impl Capability {
+    /// Returns `true` if the capability is `ReadWrite`.
+    pub fn editable(self) -> bool {
+        matches!(self, Capability::ReadWrite)
+    }
+}
+
 pub type BufferRow = u32;
 
 /// An in-memory representation of a source code file, including its text,
@@ -188,6 +197,7 @@ pub struct BufferSnapshot {
     language: Option<Arc<Language>>,
     non_text_state_update_count: usize,
     tree_sitter_data: Arc<TreeSitterData>,
+    pub capability: Capability,
 }
 
 /// The kind and amount of indentation in a particular line. For now,
@@ -1090,7 +1100,7 @@ impl Buffer {
 
     /// Whether this buffer can only be read.
     pub fn read_only(&self) -> bool {
-        self.capability == Capability::ReadOnly
+        !self.capability.editable()
     }
 
     /// Builds a [`Buffer`] with the given underlying [`TextBuffer`], diff base, [`File`] and [`Capability`].
@@ -1163,6 +1173,7 @@ impl Buffer {
                 tree_sitter_data: Arc::new(tree_sitter_data),
                 language,
                 non_text_state_update_count: 0,
+                capability: Capability::ReadOnly,
             }
         }
     }
@@ -1188,6 +1199,7 @@ impl Buffer {
             remote_selections: Default::default(),
             language: None,
             non_text_state_update_count: 0,
+            capability: Capability::ReadOnly,
         }
     }
 
@@ -1217,6 +1229,7 @@ impl Buffer {
             remote_selections: Default::default(),
             language,
             non_text_state_update_count: 0,
+            capability: Capability::ReadOnly,
         }
     }
 
@@ -1243,6 +1256,7 @@ impl Buffer {
             diagnostics: self.diagnostics.clone(),
             language: self.language.clone(),
             non_text_state_update_count: self.non_text_state_update_count,
+            capability: self.capability,
         }
     }
 
@@ -5171,6 +5185,7 @@ impl Clone for BufferSnapshot {
             language: self.language.clone(),
             tree_sitter_data: self.tree_sitter_data.clone(),
             non_text_state_update_count: self.non_text_state_update_count,
+            capability: self.capability,
         }
     }
 }

crates/multi_buffer/src/multi_buffer.rs 🔗

@@ -1195,7 +1195,7 @@ impl MultiBuffer {
     }
 
     pub fn read_only(&self) -> bool {
-        self.capability == Capability::ReadOnly
+        !self.capability.editable()
     }
 
     /// Returns an up-to-date snapshot of the MultiBuffer.
@@ -1428,7 +1428,9 @@ impl MultiBuffer {
                 (end_region.buffer_range.start + end_overshoot).min(end_region.buffer_range.end);
 
             if start_region.excerpt.id == end_region.excerpt.id {
-                if start_region.is_main_buffer {
+                if start_region.buffer.capability == Capability::ReadWrite
+                    && start_region.is_main_buffer
+                {
                     edited_excerpt_ids.push(start_region.excerpt.id);
                     buffer_edits
                         .entry(start_region.buffer.remote_id())
@@ -1444,7 +1446,9 @@ impl MultiBuffer {
             } else {
                 let start_excerpt_range = buffer_start..start_region.buffer_range.end;
                 let end_excerpt_range = end_region.buffer_range.start..buffer_end;
-                if start_region.is_main_buffer {
+                if start_region.buffer.capability == Capability::ReadWrite
+                    && start_region.is_main_buffer
+                {
                     edited_excerpt_ids.push(start_region.excerpt.id);
                     buffer_edits
                         .entry(start_region.buffer.remote_id())
@@ -1457,7 +1461,9 @@ impl MultiBuffer {
                             excerpt_id: start_region.excerpt.id,
                         });
                 }
-                if end_region.is_main_buffer {
+                if end_region.buffer.capability == Capability::ReadWrite
+                    && end_region.is_main_buffer
+                {
                     edited_excerpt_ids.push(end_region.excerpt.id);
                     buffer_edits
                         .entry(end_region.buffer.remote_id())
@@ -1477,7 +1483,7 @@ impl MultiBuffer {
                     if region.excerpt.id == end_region.excerpt.id {
                         break;
                     }
-                    if region.is_main_buffer {
+                    if region.buffer.capability == Capability::ReadWrite && region.is_main_buffer {
                         edited_excerpt_ids.push(region.excerpt.id);
                         buffer_edits
                             .entry(region.buffer.remote_id())
@@ -1557,26 +1563,6 @@ impl MultiBuffer {
         }
     }
 
-    /// Inserts newlines at the given position to create an empty line, returning the start of the new line.
-    /// You can also request the insertion of empty lines above and below the line starting at the returned point.
-    /// Panics if the given position is invalid.
-    pub fn insert_empty_line(
-        &mut self,
-        position: impl ToPoint,
-        space_above: bool,
-        space_below: bool,
-        cx: &mut Context<Self>,
-    ) -> Point {
-        let multibuffer_point = position.to_point(&self.read(cx));
-        let (buffer, buffer_point, _) = self.point_to_buffer_point(multibuffer_point, cx).unwrap();
-        self.start_transaction(cx);
-        let empty_line_start = buffer.update(cx, |buffer, cx| {
-            buffer.insert_empty_line(buffer_point, space_above, space_below, cx)
-        });
-        self.end_transaction(cx);
-        multibuffer_point + (empty_line_start - buffer_point)
-    }
-
     pub fn set_active_selections(
         &self,
         selections: &[Selection<Anchor>],

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::Read, 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/lsp_store.rs 🔗

@@ -61,11 +61,11 @@ use gpui::{
 use http_client::HttpClient;
 use itertools::Itertools as _;
 use language::{
-    Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, CodeLabel, Diagnostic,
-    DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language, LanguageName,
-    LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller, ManifestDelegate,
-    ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16, Toolchain,
-    Transaction, Unclipped,
+    Bias, BinaryStatus, Buffer, BufferRow, BufferSnapshot, CachedLspAdapter, Capability, CodeLabel,
+    Diagnostic, DiagnosticEntry, DiagnosticSet, DiagnosticSourceKind, Diff, File as _, Language,
+    LanguageName, LanguageRegistry, LocalFile, LspAdapter, LspAdapterDelegate, LspInstaller,
+    ManifestDelegate, ManifestName, Patch, PointUtf16, TextBufferSnapshot, ToOffset, ToPointUtf16,
+    Toolchain, Transaction, Unclipped,
     language_settings::{FormatOnSave, Formatter, LanguageSettings, language_settings},
     point_to_lsp,
     proto::{
@@ -8690,14 +8690,14 @@ impl LspStore {
                 } else {
                     (Arc::<Path>::from(abs_path.as_path()), None)
                 };
-            let (worktree, relative_path) = if let Some(result) =
-                lsp_store.update(cx, |lsp_store, cx| {
-                    lsp_store.worktree_store.update(cx, |worktree_store, cx| {
-                        worktree_store.find_worktree(&worktree_root_target, cx)
-                    })
-                })? {
+            let worktree = lsp_store.update(cx, |lsp_store, cx| {
+                lsp_store.worktree_store.update(cx, |worktree_store, cx| {
+                    worktree_store.find_worktree(&worktree_root_target, cx)
+                })
+            })?;
+            let (worktree, relative_path, source_ws) = if let Some(result) = worktree {
                 let relative_path = known_relative_path.unwrap_or_else(|| result.1.clone());
-                (result.0, relative_path)
+                (result.0, relative_path, None)
             } else {
                 let worktree = lsp_store
                     .update(cx, |lsp_store, cx| {
@@ -8706,7 +8706,8 @@ impl LspStore {
                         })
                     })?
                     .await?;
-                if worktree.read_with(cx, |worktree, _| worktree.is_local())? {
+                let worktree_root = worktree.read_with(cx, |worktree, _| worktree.abs_path())?;
+                let source_ws = if worktree.read_with(cx, |worktree, _| worktree.is_local())? {
                     lsp_store
                         .update(cx, |lsp_store, cx| {
                             if let Some(local) = lsp_store.as_local_mut() {
@@ -8716,29 +8717,56 @@ impl LspStore {
                                     cx,
                                 )
                             }
+                            match lsp_store.language_server_statuses.get(&language_server_id) {
+                                Some(status) => status.worktree,
+                                None => None,
+                            }
                         })
-                        .ok();
-                }
-                let worktree_root = worktree.read_with(cx, |worktree, _| worktree.abs_path())?;
+                        .ok()
+                        .flatten()
+                        .zip(Some(worktree_root.clone()))
+                } else {
+                    None
+                };
                 let relative_path = if let Some(known_path) = known_relative_path {
                     known_path
                 } else {
                     RelPath::new(abs_path.strip_prefix(worktree_root)?, PathStyle::local())?
                         .into_arc()
                 };
-                (worktree, relative_path)
+                (worktree, relative_path, source_ws)
             };
             let project_path = ProjectPath {
                 worktree_id: worktree.read_with(cx, |worktree, _| worktree.id())?,
                 path: relative_path,
             };
-            lsp_store
+            let buffer = lsp_store
                 .update(cx, |lsp_store, cx| {
                     lsp_store.buffer_store().update(cx, |buffer_store, cx| {
                         buffer_store.open_buffer(project_path, cx)
                     })
                 })?
-                .await
+                .await?;
+            // we want to adhere to the read-only settings of the worktree we came from in case we opened an invisible one
+            if let Some((source_ws, worktree_root)) = source_ws {
+                buffer.update(cx, |buffer, cx| {
+                    let settings = WorktreeSettings::get(
+                        Some(
+                            (&ProjectPath {
+                                worktree_id: source_ws,
+                                path: Arc::from(RelPath::empty()),
+                            })
+                                .into(),
+                        ),
+                        cx,
+                    );
+                    let is_read_only = settings.is_std_path_read_only(&worktree_root);
+                    if is_read_only {
+                        buffer.set_capability(Capability::ReadOnly, cx);
+                    }
+                })?;
+            }
+            Ok(buffer)
         })
     }
 

crates/project/src/project.rs 🔗

@@ -2667,7 +2667,7 @@ impl Project {
 
     #[inline]
     pub fn is_read_only(&self, cx: &App) -> bool {
-        self.is_disconnected(cx) || self.capability() == Capability::ReadOnly
+        self.is_disconnected(cx) || !self.capability().editable()
     }
 
     #[inline]

crates/project/src/project_tests.rs 🔗

@@ -11081,3 +11081,217 @@ async fn test_optimistic_hunks_in_staged_files(cx: &mut gpui::TestAppContext) {
         );
     });
 }
+
+#[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 🔗

@@ -110,6 +110,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 🔗

@@ -906,6 +906,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,12 @@ 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 toggle_read_only(&mut self, _window: &mut Window, _cx: &mut Context<Self>) {}
+
     fn has_deleted_file(&self, _: &App) -> bool {
         false
     }
@@ -476,6 +482,8 @@ 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 toggle_read_only(&self, window: &mut Window, cx: &mut App);
     fn has_deleted_file(&self, cx: &App) -> bool;
     fn has_conflict(&self, cx: &App) -> bool;
     fn can_save(&self, cx: &App) -> bool;
@@ -949,6 +957,16 @@ 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 toggle_read_only(&self, window: &mut Window, cx: &mut App) {
+        self.update(cx, |this, cx| {
+            this.toggle_read_only(window, cx);
+        })
+    }
+
     fn has_deleted_file(&self, cx: &App) -> bool {
         self.read(cx).has_deleted_file(cx)
     }

crates/workspace/src/pane.rs 🔗

@@ -2678,6 +2678,24 @@ impl Pane {
         let is_pinned = self.is_tab_pinned(ix);
         let position_relative_to_active_item = ix.cmp(&self.active_item_index);
 
+        let read_only_toggle = || {
+            IconButton::new("toggle_read_only", IconName::FileLock)
+                .size(ButtonSize::None)
+                .shape(IconButtonShape::Square)
+                .icon_color(Color::Muted)
+                .icon_size(IconSize::Small)
+                .tooltip(move |_, cx| {
+                    Tooltip::with_meta("Unlock File", None, "This will make this file editable", cx)
+                })
+                .on_click(cx.listener(move |pane, _, window, cx| {
+                    if let Some(item) = pane.item_for_index(ix) {
+                        item.toggle_read_only(window, cx);
+                    }
+                }))
+        };
+
+        let has_file_icon = icon.is_some() | decorated_icon.is_some();
+
         let tab = Tab::new(ix)
             .position(if is_first_item {
                 TabPosition::First
@@ -2812,24 +2830,36 @@ impl Pane {
             })
             .child(
                 h_flex()
+                    .id(("pane-tab-content", ix))
                     .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(decorated_icon.into_any_element())
+                    } else if let Some(icon) = icon {
+                        Some(icon.into_any_element())
+                    } else if item.is_read_only(cx) {
+                        Some(read_only_toggle().into_any_element())
+                    } else {
+                        None
+                    })
                     .child(label)
-                    .id(("pane-tab-content", ix))
                     .map(|this| match tab_tooltip_content {
-                        Some(TabTooltipContent::Text(text)) => this.tooltip(Tooltip::text(text)),
+                        Some(TabTooltipContent::Text(text)) => {
+                            if item.is_read_only(cx) {
+                                this.tooltip(move |_, cx| {
+                                    let text = text.clone();
+                                    Tooltip::with_meta(text, None, "Read-Only File", cx)
+                                })
+                            } else {
+                                this.tooltip(Tooltip::text(text))
+                            }
+                        }
                         Some(TabTooltipContent::Custom(element_fn)) => {
                             this.tooltip(move |window, cx| element_fn(window, cx))
                         }
                         None => this,
+                    })
+                    .when(item.is_read_only(cx) && has_file_icon, |this| {
+                        this.child(read_only_toggle())
                     }),
             );
 
@@ -2846,8 +2876,11 @@ impl Pane {
         let has_items_to_right = ix < total_items - 1;
         let has_clean_items = self.items.iter().any(|item| !item.is_dirty(cx));
         let is_pinned = self.is_tab_pinned(ix);
+        let is_read_only = item.is_read_only(cx);
+
         let pane = cx.entity().downgrade();
         let menu_context = item.item_focus_handle(cx);
+
         right_click_menu(ix)
             .trigger(|_, _, _| tab)
             .menu(move |window, cx| {
@@ -2994,6 +3027,22 @@ impl Pane {
                                 }
                             })
                         };
+
+                        let read_only_label = if is_read_only {
+                            "Make File Editable"
+                        } else {
+                            "Make File Read-Only"
+                        };
+                        menu = menu.separator().entry(
+                            read_only_label,
+                            None,
+                            window.handler_for(&pane, move |pane, window, cx| {
+                                if let Some(item) = pane.item_for_index(ix) {
+                                    item.toggle_read_only(window, cx);
+                                }
+                            }),
+                        );
+
                         if let Some(entry) = single_entry_to_resolve {
                             let project_path = pane
                                 .read(cx)
@@ -3025,6 +3074,7 @@ impl Pane {
                                 && worktree.is_some_and(|worktree| worktree.read(cx).is_visible());
 
                             let entry_id = entry.to_proto();
+
                             menu = menu
                                 .separator()
                                 .when_some(entry_abs_path, |menu, abs_path| {
@@ -3089,7 +3139,7 @@ impl Pane {
                         } else {
                             menu = menu.map(pin_tab_entries);
                         }
-                    }
+                    };
 
                     menu.context(menu_context)
                 })

crates/workspace/src/workspace.rs 🔗

@@ -281,6 +281,8 @@ actions!(
         ToggleRightDock,
         /// Toggles zoom on the active pane.
         ToggleZoom,
+        /// Toggles read-only mode for the active item (if supported by that item).
+        ToggleReadOnlyFile,
         /// Zooms in on the active pane.
         ZoomIn,
         /// Zooms out of the active pane.
@@ -6263,6 +6265,14 @@ impl Workspace {
                     cx.propagate();
                 },
             ))
+            .on_action(
+                cx.listener(|workspace, _: &ToggleReadOnlyFile, window, cx| {
+                    let pane = workspace.active_pane().clone();
+                    if let Some(item) = pane.read(cx).active_item() {
+                        item.toggle_read_only(window, cx);
+                    }
+                }),
+            )
             .on_action(cx.listener(Workspace::cancel))
     }
 

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