Detailed changes
@@ -2,46 +2,46 @@
"languages": {
"Markdown": {
"tab_size": 2,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"TOML": {
"formatter": "prettier",
- "format_on_save": "off"
+ "format_on_save": "off",
},
"YAML": {
"tab_size": 2,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"JSON": {
"tab_size": 2,
"preferred_line_length": 120,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"JSONC": {
"tab_size": 2,
"preferred_line_length": 120,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"JavaScript": {
"tab_size": 2,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"CSS": {
"tab_size": 2,
- "formatter": "prettier"
+ "formatter": "prettier",
},
"Rust": {
"tasks": {
"variables": {
- "RUST_DEFAULT_PACKAGE_RUN": "zed"
- }
- }
- }
+ "RUST_DEFAULT_PACKAGE_RUN": "zed",
+ },
+ },
+ },
},
"file_types": {
"Dockerfile": ["Dockerfile*[!dockerignore]"],
"JSONC": ["**/assets/**/*.json", "renovate.json"],
- "Git Ignore": ["dockerignore"]
+ "Git Ignore": ["dockerignore"],
},
"hard_tabs": false,
"formatter": "auto",
@@ -59,6 +59,7 @@
"**/.DS_Store",
"**/Thumbs.db",
"**/.classpath",
- "**/.settings"
- ]
+ "**/.settings",
+ ],
+ "read_only_files": ["**/.rustup/**", "**/.cargo/registry/**", "**/.cargo/git/**", "target/**/*.rs", "**/*.lock"],
}
@@ -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.
@@ -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>,
@@ -844,7 +844,7 @@ actions!(
/// from the current selections.
UnwrapSyntaxNode,
/// Wraps selections in tag specified by language.
- WrapSelectionsInTag
+ WrapSelectionsInTag,
]
);
@@ -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;
@@ -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) {
@@ -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()
}
@@ -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,
}
}
}
@@ -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>],
@@ -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);
}
@@ -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)
})
}
@@ -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]
@@ -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");
+ });
+}
@@ -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]
@@ -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()),
}
}
}
@@ -786,13 +786,22 @@ impl PathWithPosition {
}
}
-#[derive(Clone, Debug)]
+#[derive(Clone)]
pub struct PathMatcher {
sources: Vec<(String, RelPathBuf, /*trailing separator*/ bool)>,
glob: GlobSet,
path_style: PathStyle,
}
+impl std::fmt::Debug for PathMatcher {
+ fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
+ f.debug_struct("PathMatcher")
+ .field("sources", &self.sources)
+ .field("path_style", &self.path_style)
+ .finish()
+ }
+}
+
impl PartialEq for PathMatcher {
fn eq(&self, other: &Self) -> bool {
self.sources.eq(&other.sources)
@@ -844,12 +853,15 @@ impl PathMatcher {
}
pub fn is_match<P: AsRef<RelPath>>(&self, other: P) -> bool {
- if self.sources.iter().any(|(_, source, _)| {
- other.as_ref().starts_with(source) || other.as_ref().ends_with(source)
- }) {
+ let other = other.as_ref();
+ if self
+ .sources
+ .iter()
+ .any(|(_, source, _)| other.starts_with(source) || other.ends_with(source))
+ {
return true;
}
- let other_path = other.as_ref().display(self.path_style);
+ let other_path = other.display(self.path_style);
if self.glob.is_match(&*other_path) {
return true;
@@ -858,6 +870,16 @@ impl PathMatcher {
self.glob
.is_match(other_path.into_owned() + self.path_style.primary_separator())
}
+
+ pub fn is_match_std_path<P: AsRef<Path>>(&self, other: P) -> bool {
+ let other = other.as_ref();
+ if self.sources.iter().any(|(_, source, _)| {
+ other.starts_with(source.as_std_path()) || other.ends_with(source.as_std_path())
+ }) {
+ return true;
+ }
+ self.glob.is_match(other)
+ }
}
impl Default for PathMatcher {
@@ -255,6 +255,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)
}
@@ -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)
})
@@ -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))
}
@@ -20,6 +20,7 @@ pub struct WorktreeSettings {
pub parent_dir_scan_inclusions: PathMatcher,
pub private_files: PathMatcher,
pub hidden_files: PathMatcher,
+ pub read_only_files: PathMatcher,
}
impl WorktreeSettings {
@@ -45,6 +46,14 @@ impl WorktreeSettings {
path.ancestors()
.any(|ancestor| self.hidden_files.is_match(ancestor))
}
+
+ pub fn is_path_read_only(&self, path: &RelPath) -> bool {
+ self.read_only_files.is_match(path)
+ }
+
+ pub fn is_std_path_read_only(&self, path: &Path) -> bool {
+ self.read_only_files.is_match_std_path(path)
+ }
}
impl Settings for WorktreeSettings {
@@ -54,6 +63,7 @@ impl Settings for WorktreeSettings {
let file_scan_inclusions = worktree.file_scan_inclusions.unwrap();
let private_files = worktree.private_files.unwrap().0;
let hidden_files = worktree.hidden_files.unwrap();
+ let read_only_files = worktree.read_only_files.unwrap_or_default();
let parsed_file_scan_inclusions: Vec<String> = file_scan_inclusions
.iter()
.flat_map(|glob| {
@@ -84,6 +94,9 @@ impl Settings for WorktreeSettings {
hidden_files: path_matchers(hidden_files, "hidden_files")
.log_err()
.unwrap_or_default(),
+ read_only_files: path_matchers(read_only_files, "read_only_files")
+ .log_err()
+ .unwrap_or_default(),
}
}
}
@@ -93,3 +106,126 @@ fn path_matchers(mut values: Vec<String>, context: &'static str) -> anyhow::Resu
PathMatcher::new(values, PathStyle::local())
.with_context(|| format!("Failed to parse globs from {}", context))
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use std::path::Path;
+
+ fn make_settings_with_read_only(patterns: &[&str]) -> WorktreeSettings {
+ WorktreeSettings {
+ project_name: None,
+ prevent_sharing_in_public_channels: false,
+ file_scan_exclusions: PathMatcher::default(),
+ file_scan_inclusions: PathMatcher::default(),
+ parent_dir_scan_inclusions: PathMatcher::default(),
+ private_files: PathMatcher::default(),
+ hidden_files: PathMatcher::default(),
+ read_only_files: PathMatcher::new(
+ patterns.iter().map(|s| s.to_string()),
+ PathStyle::local(),
+ )
+ .unwrap(),
+ }
+ }
+
+ #[test]
+ fn test_is_path_read_only_with_glob_patterns() {
+ let settings = make_settings_with_read_only(&["**/generated/**", "**/*.gen.rs"]);
+
+ let generated_file =
+ RelPath::new(Path::new("src/generated/schema.rs"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&generated_file),
+ "Files in generated directory should be read-only"
+ );
+
+ let gen_rs_file = RelPath::new(Path::new("src/types.gen.rs"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&gen_rs_file),
+ "Files with .gen.rs extension should be read-only"
+ );
+
+ let regular_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap();
+ assert!(
+ !settings.is_path_read_only(®ular_file),
+ "Regular files should not be read-only"
+ );
+
+ let similar_name = RelPath::new(Path::new("src/generator.rs"), PathStyle::local()).unwrap();
+ assert!(
+ !settings.is_path_read_only(&similar_name),
+ "Files with 'generator' in name but not in generated dir should not be read-only"
+ );
+ }
+
+ #[test]
+ fn test_is_path_read_only_with_specific_paths() {
+ let settings = make_settings_with_read_only(&["vendor/**", "node_modules/**"]);
+
+ let vendor_file =
+ RelPath::new(Path::new("vendor/lib/package.js"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&vendor_file),
+ "Files in vendor directory should be read-only"
+ );
+
+ let node_modules_file = RelPath::new(
+ Path::new("node_modules/lodash/index.js"),
+ PathStyle::local(),
+ )
+ .unwrap();
+ assert!(
+ settings.is_path_read_only(&node_modules_file),
+ "Files in node_modules should be read-only"
+ );
+
+ let src_file = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap();
+ assert!(
+ !settings.is_path_read_only(&src_file),
+ "Files in src should not be read-only"
+ );
+ }
+
+ #[test]
+ fn test_is_path_read_only_empty_patterns() {
+ let settings = make_settings_with_read_only(&[]);
+
+ let any_file = RelPath::new(Path::new("src/main.rs"), PathStyle::local()).unwrap();
+ assert!(
+ !settings.is_path_read_only(&any_file),
+ "No files should be read-only when patterns are empty"
+ );
+ }
+
+ #[test]
+ fn test_is_path_read_only_with_extension_pattern() {
+ let settings = make_settings_with_read_only(&["**/*.lock", "**/*.min.js"]);
+
+ let lock_file = RelPath::new(Path::new("Cargo.lock"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&lock_file),
+ "Lock files should be read-only"
+ );
+
+ let nested_lock =
+ RelPath::new(Path::new("packages/app/yarn.lock"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&nested_lock),
+ "Nested lock files should be read-only"
+ );
+
+ let minified_js =
+ RelPath::new(Path::new("dist/bundle.min.js"), PathStyle::local()).unwrap();
+ assert!(
+ settings.is_path_read_only(&minified_js),
+ "Minified JS files should be read-only"
+ );
+
+ let regular_js = RelPath::new(Path::new("src/app.js"), PathStyle::local()).unwrap();
+ assert!(
+ !settings.is_path_read_only(®ular_js),
+ "Regular JS files should not be read-only"
+ );
+ }
+}