diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index d92b05775e..20e1c665cc 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -8223,8 +8223,11 @@ impl Editor { window: &mut Window, cx: &mut Context, ) -> Task> { + 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 { diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index 87ac298332..8683cf3ba5 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -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::( + 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::( + 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, |_| {}); diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index f451b3968c..3461733c81 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -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, ) -> Task> { + 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); } diff --git a/crates/multi_buffer/src/multi_buffer.rs b/crates/multi_buffer/src/multi_buffer.rs index 0f17aa0782..10b0e25dd7 100644 --- a/crates/multi_buffer/src/multi_buffer.rs +++ b/crates/multi_buffer/src/multi_buffer.rs @@ -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, }); diff --git a/crates/workspace/src/item.rs b/crates/workspace/src/item.rs index 4a52b9099f..ddadbf6094 100644 --- a/crates/workspace/src/item.rs +++ b/crates/workspace/src/item.rs @@ -297,6 +297,7 @@ pub trait Item: Focusable + EventEmitter + 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>, pub nav_history: Option, pub tab_descriptions: Option>, @@ -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]) -> 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, diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index f57f526751..f9d51fd2f0 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -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); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index d28dbfae09..2f8d54d60f 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -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) -> 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)