editor: Prevent auto save formatting on read only files (#62921)

# Objective

Read-only marked files in zed could still be affected by format on save
feature. This PR fixes that.
FIXES #62899 

## Solution

These changes add an early return on Editor's `Item::save`
implementation so neither formatting nor the write to disk occurs.

## Testing

I've tested by reproducing the issue in the original ticket. 

## Self-Review Checklist:

- [x] I've reviewed my own diff for quality, security, and reliability
- [x] Unsafe blocks (if any) have justifying comments
- [x] The content adheres to Zed's UI standards
([UX/UI](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)
and
[icon](https://github.com/zed-industries/zed/blob/main/crates/icons/README.md)
guidelines)
- [] Tests cover the new/changed behavior
- [x] Performance impact has been considered and is acceptable

---

Release Notes:

- Fixed read-only files being formatted and saved when `format_on_save`
is enabled

---------

Co-authored-by: Kirill Bulatov <kirill@zed.dev>
This commit is contained in:
Matei OpreaandKirill Bulatov authored and GitHub committed 2026-08-21 13:19:58 +00:00
1 parent fbe49339af
commit 5d473cc6af
7 files changed
+449 -20

No files matched your search

+11 -1
View File
@@ -8223,8 +8223,11 @@ impl Editor {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.read_only(cx) {
return Task::ready(Ok(()));
}
let buffer = self.buffer.clone();
let (buffers, target) = match target {
let (mut buffers, target) = match target {
FormatTarget::Buffers(buffers) => (buffers, LspFormatTarget::Buffers),
FormatTarget::Ranges(selection_ranges) => {
let multi_buffer = buffer.read(cx);
@@ -8250,6 +8253,8 @@ impl Editor {
}
};
buffers.retain(|buffer| !buffer.read(cx).read_only());
let transaction_id_prev = buffer.read(cx).last_transaction_id(cx);
let selections_prev = transaction_id_prev
.and_then(|transaction_id_prev| {
@@ -9817,6 +9822,10 @@ impl Editor {
cx.emit(EditorEvent::TitleChanged);
cx.emit(EditorEvent::FileHandleChanged);
}
multi_buffer::Event::CapabilityChanged => {
cx.emit(EditorEvent::CapabilityChanged);
cx.notify();
}
multi_buffer::Event::Reloaded | multi_buffer::Event::BufferDiffChanged => {
cx.emit(EditorEvent::TitleChanged)
}
@@ -11960,6 +11969,7 @@ pub enum EditorEvent {
Blurred,
DirtyChanged,
Saved,
CapabilityChanged,
TitleChanged,
FileHandleChanged,
SelectionsChanged {
+339 -1
View File
@@ -26,7 +26,7 @@ use gpui::{
use indoc::indoc;
use language::{
BracketPair, BracketPairConfig,
Capability::ReadWrite,
Capability::{Read, ReadOnly, ReadWrite},
ContextLocation, ContextProvider, DiagnosticSourceKind, FakeLspAdapter, IndentGuideSettings,
LanguageConfig, LanguageConfigOverride, LanguageMatcher, LanguageName, LanguageQueries,
LanguageToolchainStore, Override, PLAIN_TEXT, Point,
@@ -15441,6 +15441,344 @@ async fn test_snippet_with_multi_word_prefix(cx: &mut TestAppContext) {
}
}
#[gpui::test]
async fn test_read_only_buffer_is_not_formatted_or_saved(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec())
.await;
let project = Project::test(fs.clone(), [path!("/file.rs").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let format_request_count = Arc::new(AtomicUsize::new(0));
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
initializer: Some(Box::new({
let format_request_count = format_request_count.clone();
move |fake_server| {
let format_request_count = format_request_count.clone();
fake_server.set_request_handler::<lsp::request::Formatting, _, _>(
move |_, _| {
let format_request_count = format_request_count.clone();
async move {
format_request_count.fetch_add(1, atomic::Ordering::Release);
Ok(Some(vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 3),
lsp::Position::new(1, 0),
),
", ".to_string(),
)]))
}
},
);
}
})),
..FakeLspAdapter::default()
},
);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/file.rs"), cx)
})
.await
.unwrap();
buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), buffer, window, cx)
});
fake_servers.next().await.unwrap();
let save = editor
.update_in(cx, |editor, window, cx| {
editor.save(
SaveOptions {
format: true,
force_format: false,
autosave: false,
},
project.clone(),
window,
cx,
)
})
.unwrap();
save.await;
assert_eq!(format_request_count.load(atomic::Ordering::Acquire), 0);
assert_eq!(
editor.update(cx, |editor, cx| editor.text(cx)),
"one\ntwo\nthree\n"
);
assert_eq!(
fs.load(path!("/file.rs").as_ref()).await.unwrap(),
"one\ntwo\nthree\n"
);
}
#[gpui::test]
async fn test_read_only_buffer_can_be_saved_as(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/dir"),
json!({
"file.rs": "one\ntwo\nthree\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
let worktree_id = project.update(cx, |project, cx| {
project.worktrees(cx).next().unwrap().read(cx).id()
});
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/dir/file.rs"), cx)
})
.await
.unwrap();
buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx));
let buffer = cx.new(|cx| MultiBuffer::singleton(buffer, cx));
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), buffer, window, cx)
});
let save_as = editor.update_in(cx, |editor, window, cx| {
editor.save_as(
project.clone(),
ProjectPath {
worktree_id,
path: rel_path("copy.rs").into(),
},
window,
cx,
)
});
save_as.await.unwrap();
assert_eq!(
fs.load(path!("/dir/copy.rs").as_ref()).await.unwrap(),
"one\ntwo\nthree\n"
);
assert_eq!(
fs.load(path!("/dir/file.rs").as_ref()).await.unwrap(),
"one\ntwo\nthree\n"
);
}
#[gpui::test]
async fn test_format_and_save_skip_read_only_buffers_in_multi_buffer(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_tree(
path!("/a"),
json!({
"main.rs": "one\ntwo\nthree\n",
"other.rs": "one\ntwo\nthree\n",
}),
)
.await;
let project = Project::test(fs.clone(), [path!("/a").as_ref()], cx).await;
let language_registry = project.read_with(cx, |project, _| project.languages().clone());
language_registry.add(rust_lang());
let formatted_uris = Arc::new(Mutex::new(Vec::new()));
let mut fake_servers = language_registry.register_fake_lsp(
"Rust",
FakeLspAdapter {
capabilities: lsp::ServerCapabilities {
document_formatting_provider: Some(lsp::OneOf::Left(true)),
..lsp::ServerCapabilities::default()
},
initializer: Some(Box::new({
let formatted_uris = formatted_uris.clone();
move |fake_server| {
let formatted_uris = formatted_uris.clone();
fake_server.set_request_handler::<lsp::request::Formatting, _, _>(
move |params, _| {
let formatted_uris = formatted_uris.clone();
async move {
formatted_uris.lock().push(params.text_document.uri.clone());
Ok(Some(vec![lsp::TextEdit::new(
lsp::Range::new(
lsp::Position::new(0, 3),
lsp::Position::new(1, 0),
),
", ".to_string(),
)]))
}
},
);
}
})),
..FakeLspAdapter::default()
},
);
let writable_buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/a/main.rs"), cx)
})
.await
.unwrap();
let read_only_buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/a/other.rs"), cx)
})
.await
.unwrap();
read_only_buffer.update(cx, |buffer, cx| buffer.set_capability(Read, cx));
let multi_buffer = cx.new(|cx| {
let mut multi_buffer = MultiBuffer::new(ReadWrite);
multi_buffer.set_excerpts_for_path(
PathKey::sorted(0),
writable_buffer.clone(),
[Point::new(0, 0)..Point::new(3, 0)],
0,
cx,
);
multi_buffer.set_excerpts_for_path(
PathKey::sorted(1),
read_only_buffer.clone(),
[Point::new(0, 0)..Point::new(3, 0)],
0,
cx,
);
multi_buffer
});
let (editor, cx) = cx.add_window_view(|window, cx| {
build_editor_with_project(project.clone(), multi_buffer, window, cx)
});
fake_servers.next().await.unwrap();
let format = editor
.update_in(cx, |editor, window, cx| editor.format(&Format, window, cx))
.unwrap();
format.await.unwrap();
assert_eq!(
*formatted_uris.lock(),
vec![lsp::Uri::from_file_path(path!("/a/main.rs")).unwrap()]
);
writable_buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "one, two\nthree\n");
});
read_only_buffer.read_with(cx, |buffer, _| {
assert_eq!(buffer.text(), "one\ntwo\nthree\n");
});
read_only_buffer.update(cx, |buffer, cx| {
buffer.edit([(0..0, "dirty ")], None, cx);
});
assert_eq!(
read_only_buffer.read_with(cx, |buffer, _| buffer.is_dirty()),
true
);
let save = editor
.update_in(cx, |editor, window, cx| {
editor.save(
SaveOptions {
format: false,
force_format: false,
autosave: false,
},
project.clone(),
window,
cx,
)
})
.unwrap();
save.await;
assert_eq!(
fs.load(path!("/a/main.rs").as_ref()).await.unwrap(),
"one, two\nthree\n"
);
assert_eq!(
fs.load(path!("/a/other.rs").as_ref()).await.unwrap(),
"one\ntwo\nthree\n"
);
}
#[gpui::test]
async fn test_save_actions_are_hidden_for_read_only_files(cx: &mut TestAppContext) {
init_test(cx, |_| {});
let fs = FakeFs::new(cx.executor());
fs.insert_file(path!("/file.rs"), b"one\ntwo\nthree\n".to_vec())
.await;
let project = Project::test(fs, [path!("/file.rs").as_ref()], cx).await;
let window = cx.add_window(|window, cx| MultiWorkspace::test_new(project.clone(), window, cx));
let cx = &mut VisualTestContext::from_window(*window, cx);
let buffer = project
.update(cx, |project, cx| {
project.open_local_buffer(path!("/file.rs"), cx)
})
.await
.unwrap();
let multi_buffer = cx.new(|cx| MultiBuffer::singleton(buffer.clone(), cx));
let editor = cx.new_window_entity(|window, cx| {
Editor::new(
EditorMode::full(),
multi_buffer,
Some(project.clone()),
window,
cx,
)
});
window
.update(cx, |multi_workspace, window, cx| {
multi_workspace.workspace().update(cx, |workspace, cx| {
workspace.add_item_to_active_pane(Box::new(editor.clone()), None, true, window, cx);
})
})
.unwrap();
cx.run_until_parked();
fn save_actions_visible(cx: &mut VisualTestContext) -> (bool, bool, bool, bool) {
let actions = cx.update(|window, cx| window.available_actions(cx));
let visible = |name: &str| actions.iter().any(|action| action.name() == name);
(
visible("workspace::Save"),
visible("workspace::FormatAndSave"),
visible("workspace::SaveWithoutFormat"),
visible("workspace::SaveAs"),
)
}
assert_eq!(save_actions_visible(cx), (true, true, true, true));
buffer.update(cx, |buffer, cx| buffer.set_capability(ReadOnly, cx));
cx.run_until_parked();
assert_eq!(save_actions_visible(cx), (false, false, false, true));
buffer.update(cx, |buffer, cx| buffer.set_capability(ReadWrite, cx));
cx.run_until_parked();
assert_eq!(save_actions_visible(cx), (true, true, true, true));
}
#[gpui::test]
async fn test_document_format_during_save(cx: &mut TestAppContext) {
init_test(cx, |_| {});
+8 -2
View File
@@ -932,6 +932,9 @@ impl Item for Editor {
}
fn can_save(&self, cx: &App) -> bool {
if self.read_only(cx) {
return false;
}
let buffer = &self.buffer().read(cx);
if let Some(buffer) = buffer.as_singleton() {
buffer.read(cx).project_path(cx).is_some()
@@ -947,6 +950,9 @@ impl Item for Editor {
window: &mut Window,
cx: &mut Context<Self>,
) -> Task<Result<()>> {
if self.read_only(cx) {
return Task::ready(Ok(()));
}
// Add meta data tracking # of auto saves
if options.autosave {
self.report_editor_event(ReportEditorEvent::Saved { auto_saved: true }, None, cx);
@@ -970,7 +976,7 @@ impl Item for Editor {
// `save_as`. Trying to save it here errors and aborts the whole save.
.filter(|buffer| {
let buffer = buffer.read(cx);
buffer.is_dirty() && buffer.file().is_some()
buffer.is_dirty() && !buffer.read_only() && buffer.file().is_some()
})
.collect()
};
@@ -1180,7 +1186,7 @@ impl Item for Editor {
f(ItemEvent::UpdateBreadcrumbs);
}
EditorEvent::DirtyChanged => {
EditorEvent::DirtyChanged | EditorEvent::CapabilityChanged => {
f(ItemEvent::UpdateTab);
}
+2 -1
View File
@@ -117,6 +117,7 @@ pub enum Event {
transaction_id: TransactionId,
},
Reloaded,
CapabilityChanged,
LanguageChanged(BufferId, bool),
Reparsed(BufferId),
Saved,
@@ -2012,7 +2013,7 @@ impl MultiBuffer {
BufferEvent::DiagnosticsUpdated => Event::DiagnosticsUpdated,
BufferEvent::CapabilityChanged => {
self.capability = buffer.read(cx).capability();
return;
Event::CapabilityChanged
}
BufferEvent::Operation { .. } | BufferEvent::ReloadNeeded => return,
});
+14
View File
@@ -297,6 +297,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
fn can_save_as(&self, _: &App) -> bool {
false
}
fn save(
&mut self,
_options: SaveOptions,
@@ -1437,6 +1438,7 @@ pub mod test {
InteractiveElement, IntoElement, ParentElement, Render, SharedString, Task, WeakEntity,
Window,
};
use language::Capability;
use project::{Project, ProjectEntryId, ProjectPath, WorktreeId};
use std::{any::Any, cell::Cell, sync::Arc};
use util::rel_path::rel_path;
@@ -1458,6 +1460,7 @@ pub mod test {
pub buffer_kind: ItemBufferKind,
pub has_conflict: bool,
pub has_deleted_file: bool,
pub capability: Capability,
pub project_items: Vec<Entity<TestProjectItem>>,
pub nav_history: Option<ItemNavHistory>,
pub tab_descriptions: Option<Vec<&'static str>>,
@@ -1548,6 +1551,7 @@ pub mod test {
is_dirty: false,
has_conflict: false,
has_deleted_file: false,
capability: Capability::ReadWrite,
project_items: Vec::new(),
buffer_kind: ItemBufferKind::Singleton,
nav_history: None,
@@ -1590,6 +1594,11 @@ pub mod test {
self
}
pub fn with_capability(mut self, capability: Capability) -> Self {
self.capability = capability;
self
}
pub fn with_project_items(mut self, items: &[Entity<TestProjectItem>]) -> Self {
self.project_items.clear();
self.project_items.extend(items.iter().cloned());
@@ -1737,6 +1746,7 @@ pub mod test {
buffer_kind: self.buffer_kind,
has_conflict: self.has_conflict,
has_deleted_file: self.has_deleted_file,
capability: self.capability,
project_items: self.project_items.clone(),
nav_history: None,
tab_descriptions: None,
@@ -1777,6 +1787,10 @@ pub mod test {
self.buffer_kind == ItemBufferKind::Singleton
}
fn capability(&self, _: &App) -> Capability {
self.capability
}
fn save(
&mut self,
_: SaveOptions,
+47
View File
@@ -2269,6 +2269,14 @@ impl Pane {
return Ok(true);
};
if (save_intent == SaveIntent::Save
|| save_intent == SaveIntent::FormatAndSave
|| save_intent == SaveIntent::SaveWithoutFormat)
&& cx.update(|_window, cx| !item.capability(cx).editable())?
{
return Ok(true);
}
let (
mut has_conflict,
mut is_dirty,
@@ -8941,6 +8949,45 @@ mod tests {
assert_item_labels(&pane, ["A", "B", "C*"], cx);
}
#[gpui::test]
async fn test_save_intents_are_noops_for_read_only_items(cx: &mut TestAppContext) {
init_test(cx);
let fs = FakeFs::new(cx.executor());
let project = Project::test(fs, None, cx).await;
let (workspace, cx) =
cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
let pane = workspace.read_with(cx, |workspace, _| workspace.active_pane().clone());
let item = pane.update_in(cx, |pane, window, cx| {
let item = cx.new(|cx| {
TestItem::new(cx)
.with_dirty(true)
.with_capability(Capability::ReadOnly)
.with_project_items(&[TestProjectItem::new(1, "read_only.txt", cx)])
});
pane.add_item(Box::new(item.clone()), true, true, None, window, cx);
item
});
for save_intent in [
SaveIntent::Save,
SaveIntent::FormatAndSave,
SaveIntent::SaveWithoutFormat,
] {
workspace
.update_in(cx, |workspace, window, cx| {
workspace.save_active_item(save_intent, window, cx)
})
.await
.unwrap();
}
item.read_with(cx, |item, _| {
assert_eq!(item.save_count, 0);
assert_eq!(item.save_as_count, 0);
});
}
fn init_test(cx: &mut TestAppContext) {
cx.update(|cx| {
let settings_store = SettingsStore::test(cx);
+28 -15
View File
@@ -5789,6 +5789,7 @@ impl Workspace {
pane::Event::ChangeItemTitle => {
if *pane == self.active_pane {
self.active_item_path_changed(false, window, cx);
cx.notify();
}
serialize_workspace = false;
}
@@ -7593,6 +7594,10 @@ impl Workspace {
/// Multiworkspace uses this to add workspace action handling to itself
pub fn actions(&self, div: Div, window: &mut Window, cx: &mut Context<Self>) -> Div {
let active_item_is_read_only = self
.active_item(cx)
.is_some_and(|item| !item.capability(cx).editable());
self.add_workspace_actions_listeners(div, window, cx)
.on_action(cx.listener(
|_workspace, action_sequence: &settings::ActionSequence, window, cx| {
@@ -7618,21 +7623,29 @@ impl Workspace {
let pane = workspace.active_pane().clone();
workspace.unfollow_in_pane(&pane, window, cx);
}))
.on_action(cx.listener(|workspace, action: &Save, window, cx| {
workspace
.save_active_item(action.save_intent.unwrap_or(SaveIntent::Save), window, cx)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
}))
.on_action(cx.listener(|workspace, _: &FormatAndSave, window, cx| {
workspace
.save_active_item(SaveIntent::FormatAndSave, window, cx)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
}))
.on_action(cx.listener(|workspace, _: &SaveWithoutFormat, window, cx| {
workspace
.save_active_item(SaveIntent::SaveWithoutFormat, window, cx)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
}))
.when(!active_item_is_read_only, |this| {
this.on_action(cx.listener(|workspace, action: &Save, window, cx| {
workspace
.save_active_item(
action.save_intent.unwrap_or(SaveIntent::Save),
window,
cx,
)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
}))
.on_action(cx.listener(|workspace, _: &FormatAndSave, window, cx| {
workspace
.save_active_item(SaveIntent::FormatAndSave, window, cx)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
}))
.on_action(cx.listener(
|workspace, _: &SaveWithoutFormat, window, cx| {
workspace
.save_active_item(SaveIntent::SaveWithoutFormat, window, cx)
.detach_and_prompt_err("Failed to save", window, cx, |_, _, _| None);
},
))
})
.on_action(cx.listener(|workspace, _: &SaveAs, window, cx| {
workspace
.save_active_item(SaveIntent::SaveAs, window, cx)