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 <daniloleal09@gmail.com>
This commit is contained in:
Lukas Wirth 2026-01-04 14:18:34 +01:00 committed by GitHub
parent 848d3cc34e
commit dba13522d6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
20 changed files with 685 additions and 91 deletions

View file

@ -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"],
}

View file

@ -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.

View file

@ -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>,

View file

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

View file

@ -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;

View file

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

View file

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

View file

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

View file

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

View file

@ -22,10 +22,11 @@ use rpc::{
proto::{self},
};
use settings::Settings;
use std::{io, sync::Arc, time::Instant};
use text::{BufferId, ReplicaId};
use util::{ResultExt as _, TryFutureExt, debug_panic, maybe, rel_path::RelPath};
use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId};
use worktree::{File, PathChange, ProjectEntryId, Worktree, WorktreeId, WorktreeSettings};
/// A set of open buffers.
pub struct BufferStore {
@ -661,15 +662,28 @@ impl LocalBufferStore {
this.add_buffer(buffer.clone(), cx)?;
let buffer_id = buffer.read(cx).remote_id();
if let Some(file) = File::from_dyn(buffer.read(cx).file()) {
this.path_to_buffer_id.insert(
ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path.clone(),
},
buffer_id,
);
let project_path = ProjectPath {
worktree_id: file.worktree_id(cx),
path: file.path.clone(),
};
let entry_id = file.entry_id;
// Check if the file should be read-only based on settings
let settings = WorktreeSettings::get(Some((&project_path).into()), cx);
let is_read_only = if project_path.path.is_empty() {
settings.is_std_path_read_only(&file.full_path(cx))
} else {
settings.is_path_read_only(&project_path.path)
};
if is_read_only {
buffer.update(cx, |buffer, cx| {
buffer.set_capability(Capability::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);
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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()
.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(),
)
.child(label)
.id(("pane-tab-content", ix))
.gap_1()
.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)
.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)
})

View file

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

View file

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