mirror of
https://github.com/zed-industries/zed.git
synced 2026-07-09 16:00:35 +00:00
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:
parent
848d3cc34e
commit
dba13522d6
20 changed files with 685 additions and 91 deletions
|
|
@ -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()
|
||||
.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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue