From dba13522d6343e046294397465009f9b3e7ab165 Mon Sep 17 00:00:00 2001 From: Lukas Wirth Date: Sun, 4 Jan 2026 14:18:34 +0100 Subject: [PATCH] worktree: Implement `read_only_files` worktree setting (#44376) 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 --- .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 ++++++++++++++++++ .../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(-) diff --git a/.zed/settings.json b/.zed/settings.json index 2760be95819e9340acf55f60616a9c22105ff52a..d25548df8d6da21d51046c0097c80c47fa616163 100644 --- a/.zed/settings.json +++ b/.zed/settings.json @@ -2,46 +2,46 @@ "languages": { "Markdown": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "TOML": { "formatter": "prettier", - "format_on_save": "off" + "format_on_save": "off", }, "YAML": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "JSON": { "tab_size": 2, "preferred_line_length": 120, - "formatter": "prettier" + "formatter": "prettier", }, "JSONC": { "tab_size": 2, "preferred_line_length": 120, - "formatter": "prettier" + "formatter": "prettier", }, "JavaScript": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "CSS": { "tab_size": 2, - "formatter": "prettier" + "formatter": "prettier", }, "Rust": { "tasks": { "variables": { - "RUST_DEFAULT_PACKAGE_RUN": "zed" - } - } - } + "RUST_DEFAULT_PACKAGE_RUN": "zed", + }, + }, + }, }, "file_types": { "Dockerfile": ["Dockerfile*[!dockerignore]"], "JSONC": ["**/assets/**/*.json", "renovate.json"], - "Git Ignore": ["dockerignore"] + "Git Ignore": ["dockerignore"], }, "hard_tabs": false, "formatter": "auto", @@ -59,6 +59,7 @@ "**/.DS_Store", "**/Thumbs.db", "**/.classpath", - "**/.settings" - ] + "**/.settings", + ], + "read_only_files": ["**/.rustup/**", "**/.cargo/registry/**", "**/.cargo/git/**", "target/**/*.rs", "**/*.lock"], } diff --git a/assets/settings/default.json b/assets/settings/default.json index 746ccb5986d0fd1d5ef11df525303e344a7393d2..f12586a68039783c44015ae5963187ce986b9d8d 100644 --- a/assets/settings/default.json +++ b/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. diff --git a/crates/diagnostics/src/buffer_diagnostics.rs b/crates/diagnostics/src/buffer_diagnostics.rs index ba10f6fbdabf05a095a7fed7c6ae682d4dc177c7..9a0adbd288e91ef42a01fb056d7656c338d6087d 100644 --- a/crates/diagnostics/src/buffer_diagnostics.rs +++ b/crates/diagnostics/src/buffer_diagnostics.rs @@ -763,6 +763,10 @@ impl Item for BufferDiagnosticsEditor { self.multibuffer.read(cx).is_dirty(cx) } + fn is_read_only(&self, cx: &App) -> bool { + self.multibuffer.read(cx).read_only() + } + fn navigate( &mut self, data: Box, diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ba36f88f6380ade2a0d70f0f7ac3eb221446b781..af370658fe0de57fce20932b71f9d3e1f9eb1831 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -844,7 +844,7 @@ actions!( /// from the current selections. UnwrapSyntaxNode, /// Wraps selections in tag specified by language. - WrapSelectionsInTag + WrapSelectionsInTag, ] ); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 245d7a294ee0d5adf9e862705f6790855cd187cf..af1e1be89a8a50a8ba66633ba9f7188015809b31 100644 --- a/crates/editor/src/editor.rs +++ b/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, + ) { + 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) { let Some(project) = self.project.clone() else { return; diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 4890c1ba01e3904f1201421926d2724699696e4f..b31d20f0da2ed89ee0dcb4f2d3419b3105dd5c98 100644 --- a/crates/editor/src/element.rs +++ b/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) { diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index 34b54795455a9612da04b54f43101f3dcf00efd9..0150559e5536f4df34cd3c8794ab1ed7ad9e39a0 100644 --- a/crates/editor/src/items.rs +++ b/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) { + 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() } diff --git a/crates/language/src/buffer.rs b/crates/language/src/buffer.rs index e990a67ccd59983a526b41a38abcf59d1d2e8108..3bfdf13968ff0f1a2612e27b6134f0523a81724e 100644 --- a/crates/language/src/buffer.rs +++ b/crates/language/src/buffer.rs @@ -85,10 +85,19 @@ pub static BUFFER_DIFF_TASK: LazyLock = 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>, non_text_state_update_count: usize, tree_sitter_data: Arc, + 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, } } } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index d1a6ca83144311419ff2d04bfac1939ab391f463..bcbd734598df8a92f61951586611a45947960649 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/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, - ) -> 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], diff --git a/crates/project/src/buffer_store.rs b/crates/project/src/buffer_store.rs index 22106fa368904d91a5c3da4338e1a79cef7f0fd0..a8fd7e2dd194d4d0eac6be25fa3f290852d6ff5f 100644 --- a/crates/project/src/buffer_store.rs +++ b/crates/project/src/buffer_store.rs @@ -22,10 +22,11 @@ use rpc::{ proto::{self}, }; +use settings::Settings; use std::{io, sync::Arc, time::Instant}; use text::{BufferId, ReplicaId}; use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath}; -use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId}; +use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings}; /// A set of open buffers. pub struct BufferStore { @@ -661,15 +662,28 @@ impl LocalBufferStore { this.add_buffer(buffer.clone(), cx)?; let buffer_id = buffer.read(cx).remote_id(); if let Some(file) = File::from_dyn(buffer.read(cx).file()) { - this.path_to_buffer_id.insert( - ProjectPath { - worktree_id: file.worktree_id(cx), - path: file.path.clone(), - }, - buffer_id, - ); + let project_path = ProjectPath { + worktree_id: file.worktree_id(cx), + path: file.path.clone(), + }; + let entry_id = file.entry_id; + + // Check if the file should be read-only based on settings + let settings = WorktreeSettings::get(Some((&project_path).into()), cx); + let is_read_only = if project_path.path.is_empty() { + settings.is_std_path_read_only(&file.full_path(cx)) + } else { + settings.is_path_read_only(&project_path.path) + }; + if is_read_only { + buffer.update(cx, |buffer, cx| { + buffer.set_capability(Capability::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); } diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 8ac0c8a922ab324662eb2e7c3faf07942a9edb08..3c02f0a487ef99dc7856a6cc81497875a98c0aa2 100644 --- a/crates/project/src/lsp_store.rs +++ b/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::::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) }) } diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 380c0689d4d7fca146ff773e371d0bd754a8408c..4cb64074cc0b765daeacc40cad92841d45a9b4d9 100644 --- a/crates/project/src/project.rs +++ b/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] diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 2087cf72e8b0fc589fea566f6ad441383d2f24f7..af459c29f1dde1e711ea2e18873a62497f065784 100644 --- a/crates/project/src/project_tests.rs +++ b/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::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(vec![ + "**/generated/**".to_string(), + "**/*.gen.rs".to_string(), + ]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": "fn main() {}", + "types.gen.rs": "// Generated file", + }, + "generated": { + "schema.rs": "// Auto-generated schema", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Open a regular file - should be read-write + let regular_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + regular_buffer.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "Regular file should not be read-only"); + }); + + // Open a file matching *.gen.rs pattern - should be read-only + let gen_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/types.gen.rs"), cx) + }) + .await + .unwrap(); + + gen_buffer.read_with(cx, |buffer, _| { + assert!( + buffer.read_only(), + "File matching *.gen.rs pattern should be read-only" + ); + }); + + // Open a file in generated directory - should be read-only + let generated_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/generated/schema.rs"), cx) + }) + .await + .unwrap(); + + generated_buffer.read_with(cx, |buffer, _| { + assert!( + buffer.read_only(), + "File in generated directory should be read-only" + ); + }); +} + +#[gpui::test] +async fn test_read_only_files_empty_setting(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Explicitly set read_only_files to empty (default behavior) + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(vec![]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "src": { + "main.rs": "fn main() {}", + }, + "generated": { + "schema.rs": "// Auto-generated schema", + } + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // All files should be read-write when read_only_files is empty + let main_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/src/main.rs"), cx) + }) + .await + .unwrap(); + + main_buffer.read_with(cx, |buffer, _| { + assert!( + !buffer.read_only(), + "Files should not be read-only when read_only_files is empty" + ); + }); + + let generated_buffer = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/generated/schema.rs"), cx) + }) + .await + .unwrap(); + + generated_buffer.read_with(cx, |buffer, _| { + assert!( + !buffer.read_only(), + "Generated files should not be read-only when read_only_files is empty" + ); + }); +} + +#[gpui::test] +async fn test_read_only_files_with_lock_files(cx: &mut gpui::TestAppContext) { + init_test(cx); + + // Configure to make lock files read-only + cx.update(|cx| { + cx.update_global::(|store, cx| { + store.update_user_settings(cx, |settings| { + settings.project.worktree.read_only_files = Some(vec![ + "**/*.lock".to_string(), + "**/package-lock.json".to_string(), + ]); + }); + }); + }); + + let fs = FakeFs::new(cx.background_executor.clone()); + fs.insert_tree( + path!("/root"), + json!({ + "Cargo.lock": "# Lock file", + "Cargo.toml": "[package]", + "package-lock.json": "{}", + "package.json": "{}", + }), + ) + .await; + + let project = Project::test(fs.clone(), [path!("/root").as_ref()], cx).await; + + // Cargo.lock should be read-only + let cargo_lock = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/Cargo.lock"), cx) + }) + .await + .unwrap(); + + cargo_lock.read_with(cx, |buffer, _| { + assert!(buffer.read_only(), "Cargo.lock should be read-only"); + }); + + // Cargo.toml should be read-write + let cargo_toml = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/Cargo.toml"), cx) + }) + .await + .unwrap(); + + cargo_toml.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "Cargo.toml should not be read-only"); + }); + + // package-lock.json should be read-only + let package_lock = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/package-lock.json"), cx) + }) + .await + .unwrap(); + + package_lock.read_with(cx, |buffer, _| { + assert!(buffer.read_only(), "package-lock.json should be read-only"); + }); + + // package.json should be read-write + let package_json = project + .update(cx, |project, cx| { + project.open_local_buffer(path!("/root/package.json"), cx) + }) + .await + .unwrap(); + + package_json.read_with(cx, |buffer, _| { + assert!(!buffer.read_only(), "package.json should not be read-only"); + }); +} diff --git a/crates/settings/src/settings_content/project.rs b/crates/settings/src/settings_content/project.rs index 4855d9835bbbfaf3c383ac09fb70066afcf1bcd7..8ce7d64940004a1be76e383a71439fc7f725a077 100644 --- a/crates/settings/src/settings_content/project.rs +++ b/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>, + + /// Treat the files matching these globs as read-only. These files can be opened and viewed, + /// but cannot be edited. This is useful for generated files, build outputs, or files from + /// external dependencies that should not be modified directly. + /// Default: [] + pub read_only_files: Option>, } #[with_fallible_options] diff --git a/crates/settings/src/vscode_import.rs b/crates/settings/src/vscode_import.rs index 8f32b7baf119be88717876e3d103b5033b141e86..220f52a7e9abb8e417edeaa8e2563ff7e5062587 100644 --- a/crates/settings/src/vscode_import.rs +++ b/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::>() + }) + .filter(|r| !r.is_empty()), } } } diff --git a/crates/util/src/paths.rs b/crates/util/src/paths.rs index a54f91c7a0392748cb64c984559cf1ce25c2a7d8..051795c8ff9bfac8c1bca98d4de34fca9bd7e215 100644 --- a/crates/util/src/paths.rs +++ b/crates/util/src/paths.rs @@ -786,13 +786,22 @@ impl PathWithPosition { } } -#[derive(Clone, Debug)] +#[derive(Clone)] pub struct PathMatcher { sources: Vec<(String, RelPathBuf, /*trailing separator*/ bool)>, glob: GlobSet, path_style: PathStyle, } +impl std::fmt::Debug for PathMatcher { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PathMatcher") + .field("sources", &self.sources) + .field("path_style", &self.path_style) + .finish() + } +} + impl PartialEq for PathMatcher { fn eq(&self, other: &Self) -> bool { self.sources.eq(&other.sources) @@ -844,12 +853,15 @@ impl PathMatcher { } pub fn is_match>(&self, other: P) -> bool { - if self.sources.iter().any(|(_, source, _)| { - other.as_ref().starts_with(source) || other.as_ref().ends_with(source) - }) { + let other = other.as_ref(); + if self + .sources + .iter() + .any(|(_, source, _)| other.starts_with(source) || other.ends_with(source)) + { return true; } - let other_path = other.as_ref().display(self.path_style); + let other_path = other.display(self.path_style); if self.glob.is_match(&*other_path) { return true; @@ -858,6 +870,16 @@ impl PathMatcher { self.glob .is_match(other_path.into_owned() + self.path_style.primary_separator()) } + + pub fn is_match_std_path>(&self, other: P) -> bool { + let other = other.as_ref(); + if self.sources.iter().any(|(_, source, _)| { + other.starts_with(source.as_std_path()) || other.ends_with(source.as_std_path()) + }) { + return true; + } + self.glob.is_match(other) + } } impl Default for PathMatcher { diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 6e415c23454388bc7931ff9d5e499924d6b8f55d..367cec0da617de612d76f559fe8bb589733b50e0 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -255,6 +255,12 @@ pub trait Item: Focusable + EventEmitter + 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) {} + 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 ItemHandle for Entity { self.read(cx).is_dirty(cx) } + fn is_read_only(&self, cx: &App) -> bool { + self.read(cx).is_read_only(cx) + } + + fn 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) } diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 586a6100e40a0edf3728ecb843b8aece08cb36cc..fdf886e8ca2bd01839654fe2d646282f50abe2ab 100644 --- a/crates/workspace/src/pane.rs +++ b/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) }) diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index 334aea8e36689eb66980330b02dbea4607fc1b32..6dbe58415ff16e55fcaeda163e720605861a52ec 100644 --- a/crates/workspace/src/workspace.rs +++ b/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)) } diff --git a/crates/worktree/src/worktree_settings.rs b/crates/worktree/src/worktree_settings.rs index a86720184ebf6d33755decf415ad97bdcfd7fd8c..05e17e0c8f270fe53d5c7534b3b74f1e8a2149f5 100644 --- a/crates/worktree/src/worktree_settings.rs +++ b/crates/worktree/src/worktree_settings.rs @@ -20,6 +20,7 @@ pub struct WorktreeSettings { pub parent_dir_scan_inclusions: PathMatcher, pub private_files: PathMatcher, pub hidden_files: PathMatcher, + pub read_only_files: PathMatcher, } impl WorktreeSettings { @@ -45,6 +46,14 @@ impl WorktreeSettings { path.ancestors() .any(|ancestor| self.hidden_files.is_match(ancestor)) } + + pub fn is_path_read_only(&self, path: &RelPath) -> bool { + self.read_only_files.is_match(path) + } + + pub fn is_std_path_read_only(&self, path: &Path) -> bool { + self.read_only_files.is_match_std_path(path) + } } impl Settings for WorktreeSettings { @@ -54,6 +63,7 @@ impl Settings for WorktreeSettings { let file_scan_inclusions = worktree.file_scan_inclusions.unwrap(); let private_files = worktree.private_files.unwrap().0; let hidden_files = worktree.hidden_files.unwrap(); + let read_only_files = worktree.read_only_files.unwrap_or_default(); let parsed_file_scan_inclusions: Vec = file_scan_inclusions .iter() .flat_map(|glob| { @@ -84,6 +94,9 @@ impl Settings for WorktreeSettings { hidden_files: path_matchers(hidden_files, "hidden_files") .log_err() .unwrap_or_default(), + read_only_files: path_matchers(read_only_files, "read_only_files") + .log_err() + .unwrap_or_default(), } } } @@ -93,3 +106,126 @@ fn path_matchers(mut values: Vec, context: &'static str) -> anyhow::Resu PathMatcher::new(values, PathStyle::local()) .with_context(|| format!("Failed to parse globs from {}", context)) } + +#[cfg(test)] +mod tests { + use super::*; + use std::path::Path; + + fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings { + WorktreeSettings { + project_name: None, + prevent_sharing_in_public_channels: false, + file_scan_exclusions: PathMatcher::default(), + file_scan_inclusions: PathMatcher::default(), + parent_dir_scan_inclusions: PathMatcher::default(), + private_files: PathMatcher::default(), + hidden_files: PathMatcher::default(), + read_only_files: PathMatcher::new( + patterns.iter().map(|s| s.to_string()), + PathStyle::local(), + ) + .unwrap(), + } + } + + #[test] + fn test_is_path_read_only_with_glob_patterns() { + let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]); + + let generated_file = + RelPath::new(Path::new("src/generated/schema.rs"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&generated_file), + "Files in generated directory should be read-only" + ); + + let gen_rs_file = RelPath::new(Path::new("src/types.gen.rs"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&gen_rs_file), + "Files with .gen.rs extension should be read-only" + ); + + let regular_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(®ular_file), + "Regular files should not be read-only" + ); + + let similar_name = RelPath::new(Path::new("src/generator.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&similar_name), + "Files with 'generator' in name but not in generated dir should not be read-only" + ); + } + + #[test] + fn test_is_path_read_only_with_specific_paths() { + let settings = make_settings_with_read_only(&["vendor/**", "node_modules/**"]); + + let vendor_file = + RelPath::new(Path::new("vendor/lib/package.js"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&vendor_file), + "Files in vendor directory should be read-only" + ); + + let node_modules_file = RelPath::new( + Path::new("node_modules/lodash/index.js"), + PathStyle::local(), + ) + .unwrap(); + assert!( + settings.is_path_read_only(&node_modules_file), + "Files in node_modules should be read-only" + ); + + let src_file = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&src_file), + "Files in src should not be read-only" + ); + } + + #[test] + fn test_is_path_read_only_empty_patterns() { + let settings = make_settings_with_read_only(&[]); + + let any_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(&any_file), + "No files should be read-only when patterns are empty" + ); + } + + #[test] + fn test_is_path_read_only_with_extension_pattern() { + let settings = make_settings_with_read_only(&["**/*.lock", "**/*.min.js"]); + + let lock_file = RelPath::new(Path::new("Cargo.lock"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&lock_file), + "Lock files should be read-only" + ); + + let nested_lock = + RelPath::new(Path::new("packages/app/yarn.lock"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&nested_lock), + "Nested lock files should be read-only" + ); + + let minified_js = + RelPath::new(Path::new("dist/bundle.min.js"), PathStyle::local()).unwrap(); + assert!( + settings.is_path_read_only(&minified_js), + "Minified JS files should be read-only" + ); + + let regular_js = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap(); + assert!( + !settings.is_path_read_only(®ular_js), + "Regular JS files should not be read-only" + ); + } +}