Don't autosave unmodified buffers (#32626)

Kirill Bulatov , Max Brunsfeld , and Cole Miller created

Closes https://github.com/zed-industries/zed/issues/12091

Proper redo of https://github.com/zed-industries/zed/pull/32603

Release Notes:

- Fixed formatting effects not triggered when saving unmodified
singleton buffers

---------

Co-authored-by: Max Brunsfeld <maxbrunsfeld@gmail.com>
Co-authored-by: Cole Miller <m@cole-miller.net>

Change summary

assets/settings/default.json                   |   3 
crates/agent/src/agent_diff.rs                 |   6 
crates/debugger_ui/src/stack_trace_view.rs     |   6 
crates/debugger_ui/src/tests/debugger_panel.rs |  11 
crates/diagnostics/src/diagnostics.rs          |   6 
crates/editor/src/editor.rs                    |  40 +-
crates/editor/src/editor_settings.rs           |   7 
crates/editor/src/editor_tests.rs              | 316 +++++++++++++++----
crates/editor/src/items.rs                     |  84 ++--
crates/editor/src/proposed_changes_editor.rs   |   6 
crates/fs/src/fs.rs                            |  15 
crates/git_ui/src/project_diff.rs              |  16 
crates/repl/src/notebook/notebook_ui.rs        |   4 
crates/search/src/project_search.rs            |  22 +
crates/workspace/src/item.rs                   |  27 +
crates/workspace/src/pane.rs                   |  44 ++
crates/zed/src/zed.rs                          |  11 
17 files changed, 453 insertions(+), 171 deletions(-)

Detailed changes

assets/settings/default.json 🔗

@@ -219,9 +219,6 @@
   "inline_code_actions": true,
   // Whether to allow drag and drop text selection in buffer.
   "drag_and_drop_selection": true,
-  // Whether to save singleton buffers that are not dirty.
-  // This will "touch" the file and related tools enabled, e.g. formatters.
-  "save_non_dirty_buffers": true,
   // What to do when go to definition yields no results.
   //
   // 1. Do nothing: `none`

crates/agent/src/agent_diff.rs 🔗

@@ -31,7 +31,7 @@ use util::ResultExt;
 use workspace::{
     Item, ItemHandle, ItemNavHistory, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView,
     Workspace,
-    item::{BreadcrumbText, ItemEvent, TabContentParams},
+    item::{BreadcrumbText, ItemEvent, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 use zed_actions::assistant::ToggleFocus;
@@ -532,12 +532,12 @@ impl Item for AgentDiffPane {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(

crates/debugger_ui/src/stack_trace_view.rs 🔗

@@ -16,7 +16,7 @@ use ui::{ActiveTheme as _, Context, ParentElement as _, Styled as _, div};
 use util::ResultExt as _;
 use workspace::{
     Item, ItemHandle as _, ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, ItemEvent},
+    item::{BreadcrumbText, ItemEvent, SaveOptions},
     searchable::SearchableItemHandle,
 };
 
@@ -386,12 +386,12 @@ impl Item for StackTraceView {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(

crates/debugger_ui/src/tests/debugger_panel.rs 🔗

@@ -33,6 +33,7 @@ use std::{
 use terminal_view::terminal_panel::TerminalPanel;
 use tests::{active_debug_session_panel, init_test, init_test_workspace};
 use util::path;
+use workspace::item::SaveOptions;
 use workspace::{Item, dock::Panel};
 
 #[gpui::test]
@@ -1213,7 +1214,15 @@ async fn test_send_breakpoints_when_editor_has_been_saved(
 
     editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .await
         .unwrap();

crates/diagnostics/src/diagnostics.rs 🔗

@@ -43,7 +43,7 @@ use ui::{Icon, IconName, Label, h_flex, prelude::*};
 use util::ResultExt;
 use workspace::{
     ItemNavHistory, ToolbarItemLocation, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -849,12 +849,12 @@ impl Item for ProjectDiagnosticsEditor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(

crates/editor/src/editor.rs 🔗

@@ -208,7 +208,7 @@ use workspace::{
     CollaboratorId, Item as WorkspaceItem, ItemId, ItemNavHistory, OpenInTerminal, OpenTerminal,
     RestoreOnStartupBehavior, SERIALIZATION_THROTTLE_TIME, SplitDirection, TabBarSettings, Toast,
     ViewId, Workspace, WorkspaceId, WorkspaceSettings,
-    item::{ItemHandle, PreviewTabsSettings},
+    item::{ItemHandle, PreviewTabsSettings, SaveOptions},
     notifications::{DetachAndPromptErr, NotificationId, NotifyTaskExt},
     searchable::SearchEvent,
 };
@@ -1498,7 +1498,7 @@ impl InlayHintRefreshReason {
 }
 
 pub enum FormatTarget {
-    Buffers,
+    Buffers(HashSet<Entity<Buffer>>),
     Ranges(Vec<Range<MultiBufferPoint>>),
 }
 
@@ -15601,7 +15601,7 @@ impl Editor {
         Some(self.perform_format(
             project,
             FormatTrigger::Manual,
-            FormatTarget::Buffers,
+            FormatTarget::Buffers(self.buffer.read(cx).all_buffers()),
             window,
             cx,
         ))
@@ -15636,10 +15636,6 @@ impl Editor {
         ))
     }
 
-    fn save_non_dirty_buffers(&self, cx: &App) -> bool {
-        self.is_singleton(cx) && EditorSettings::get_global(cx).save_non_dirty_buffers
-    }
-
     fn perform_format(
         &mut self,
         project: Entity<Project>,
@@ -15650,13 +15646,7 @@ impl Editor {
     ) -> Task<Result<()>> {
         let buffer = self.buffer.clone();
         let (buffers, target) = match target {
-            FormatTarget::Buffers => {
-                let mut buffers = buffer.read(cx).all_buffers();
-                if trigger == FormatTrigger::Save && !self.save_non_dirty_buffers(cx) {
-                    buffers.retain(|buffer| buffer.read(cx).is_dirty());
-                }
-                (buffers, LspFormatTarget::Buffers)
-            }
+            FormatTarget::Buffers(buffers) => (buffers, LspFormatTarget::Buffers),
             FormatTarget::Ranges(selection_ranges) => {
                 let multi_buffer = buffer.read(cx);
                 let snapshot = multi_buffer.read(cx);
@@ -17101,7 +17091,16 @@ impl Editor {
         }
 
         if let Some(project) = self.project.clone() {
-            self.save(true, project, window, cx).detach_and_log_err(cx);
+            self.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx);
         }
     }
 
@@ -17133,7 +17132,16 @@ impl Editor {
         });
 
         if let Some(project) = self.project.clone() {
-            self.save(true, project, window, cx).detach_and_log_err(cx);
+            self.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project,
+                window,
+                cx,
+            )
+            .detach_and_log_err(cx);
         }
     }
 

crates/editor/src/editor_settings.rs 🔗

@@ -50,7 +50,6 @@ pub struct EditorSettings {
     pub diagnostics_max_severity: Option<DiagnosticSeverity>,
     pub inline_code_actions: bool,
     pub drag_and_drop_selection: bool,
-    pub save_non_dirty_buffers: bool,
 }
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -503,12 +502,6 @@ pub struct EditorSettingsContent {
     ///
     /// Default: true
     pub drag_and_drop_selection: Option<bool>,
-
-    /// Whether to save singleton buffers that are not dirty.
-    /// This will "touch" the file and related tools enabled, e.g. formatters.
-    ///
-    /// Default: true
-    pub save_non_dirty_buffers: Option<bool>,
 }
 
 // Toolbar related settings

crates/editor/src/editor_tests.rs 🔗

@@ -56,7 +56,7 @@ use util::{
 };
 use workspace::{
     CloseActiveItem, CloseAllItems, CloseInactiveItems, NavigationEntry, OpenOptions, ViewId,
-    item::{FollowEvent, FollowableItem, Item, ItemHandle},
+    item::{FollowEvent, FollowableItem, Item, ItemHandle, SaveOptions},
 };
 
 #[gpui::test]
@@ -9041,7 +9041,15 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
         );
         let save = editor
             .update_in(cx, |editor, window, cx| {
-                editor.save(true, project.clone(), window, cx)
+                editor.save(
+                    SaveOptions {
+                        format: true,
+                        autosave: false,
+                    },
+                    project.clone(),
+                    window,
+                    cx,
+                )
             })
             .unwrap();
         cx.executor().start_waiting();
@@ -9073,7 +9081,15 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
         );
         let save = editor
             .update_in(cx, |editor, window, cx| {
-                editor.save(true, project.clone(), window, cx)
+                editor.save(
+                    SaveOptions {
+                        format: true,
+                        autosave: false,
+                    },
+                    project.clone(),
+                    window,
+                    cx,
+                )
             })
             .unwrap();
         cx.executor().advance_clock(super::FORMAT_TIMEOUT);
@@ -9085,38 +9101,6 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
         );
     }
 
-    // For non-dirty buffer and the corresponding settings, no formatting request should be sent
-    {
-        assert!(!cx.read(|cx| editor.is_dirty(cx)));
-        cx.update_global::<SettingsStore, _>(|settings, cx| {
-            settings.update_user_settings::<EditorSettings>(cx, |settings| {
-                settings.save_non_dirty_buffers = Some(false);
-            });
-        });
-
-        fake_server.set_request_handler::<lsp::request::Formatting, _, _>(move |_, _| async move {
-            panic!("Should not be invoked on non-dirty buffer when configured so");
-        });
-        let save = editor
-            .update_in(cx, |editor, window, cx| {
-                editor.save(true, project.clone(), window, cx)
-            })
-            .unwrap();
-        cx.executor().start_waiting();
-        save.await;
-
-        assert_eq!(
-            editor.update(cx, |editor, cx| editor.text(cx)),
-            "one\ntwo\nthree\n"
-        );
-        assert!(!cx.read(|cx| editor.is_dirty(cx)));
-    }
-
-    cx.update_global::<SettingsStore, _>(|settings, cx| {
-        settings.update_user_settings::<EditorSettings>(cx, |settings| {
-            settings.save_non_dirty_buffers = Some(false);
-        });
-    });
     // Set rust language override and assert overridden tabsize is sent to language server
     update_test_language_settings(cx, |settings| {
         settings.languages.insert(
@@ -9144,7 +9128,15 @@ async fn test_document_format_during_save(cx: &mut TestAppContext) {
             });
         let save = editor
             .update_in(cx, |editor, window, cx| {
-                editor.save(true, project.clone(), window, cx)
+                editor.save(
+                    SaveOptions {
+                        format: true,
+                        autosave: false,
+                    },
+                    project.clone(),
+                    window,
+                    cx,
+                )
             })
             .unwrap();
         cx.executor().start_waiting();
@@ -9312,7 +9304,15 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) {
     cx.executor().start_waiting();
     let save = multi_buffer_editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .unwrap();
 
@@ -9356,6 +9356,170 @@ async fn test_multibuffer_format_during_save(cx: &mut TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_autosave_with_dirty_buffers(cx: &mut TestAppContext) {
+    init_test(cx, |_| {});
+
+    let fs = FakeFs::new(cx.executor());
+    fs.insert_tree(
+        path!("/dir"),
+        json!({
+            "file1.rs": "fn main() { println!(\"hello\"); }",
+            "file2.rs": "fn test() { println!(\"test\"); }",
+            "file3.rs": "fn other() { println!(\"other\"); }\n",
+        }),
+    )
+    .await;
+
+    let project = Project::test(fs.clone(), [path!("/dir").as_ref()], cx).await;
+    let workspace = cx.add_window(|window, cx| Workspace::test_new(project.clone(), window, cx));
+    let cx = &mut VisualTestContext::from_window(*workspace.deref(), cx);
+
+    let language_registry = project.read_with(cx, |project, _| project.languages().clone());
+    language_registry.add(rust_lang());
+
+    let worktree = project.update(cx, |project, cx| project.worktrees(cx).next().unwrap());
+    let worktree_id = worktree.update(cx, |worktree, _| worktree.id());
+
+    // Open three buffers
+    let buffer_1 = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, "file1.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let buffer_2 = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, "file2.rs"), cx)
+        })
+        .await
+        .unwrap();
+    let buffer_3 = project
+        .update(cx, |project, cx| {
+            project.open_buffer((worktree_id, "file3.rs"), cx)
+        })
+        .await
+        .unwrap();
+
+    // Create a multi-buffer with all three buffers
+    let multi_buffer = cx.new(|cx| {
+        let mut multi_buffer = MultiBuffer::new(ReadWrite);
+        multi_buffer.push_excerpts(
+            buffer_1.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(1, 0))],
+            cx,
+        );
+        multi_buffer.push_excerpts(
+            buffer_2.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(1, 0))],
+            cx,
+        );
+        multi_buffer.push_excerpts(
+            buffer_3.clone(),
+            [ExcerptRange::new(Point::new(0, 0)..Point::new(1, 0))],
+            cx,
+        );
+        multi_buffer
+    });
+
+    let editor = cx.new_window_entity(|window, cx| {
+        Editor::new(
+            EditorMode::full(),
+            multi_buffer,
+            Some(project.clone()),
+            window,
+            cx,
+        )
+    });
+
+    // Edit only the first buffer
+    editor.update_in(cx, |editor, window, cx| {
+        editor.change_selections(Some(Autoscroll::Next), window, cx, |s| {
+            s.select_ranges(Some(10..10))
+        });
+        editor.insert("// edited", window, cx);
+    });
+
+    // Verify that only buffer 1 is dirty
+    buffer_1.update(cx, |buffer, _| assert!(buffer.is_dirty()));
+    buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty()));
+    buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty()));
+
+    // Get write counts after file creation (files were created with initial content)
+    // We expect each file to have been written once during creation
+    let write_count_after_creation_1 = fs.write_count_for_path(path!("/dir/file1.rs"));
+    let write_count_after_creation_2 = fs.write_count_for_path(path!("/dir/file2.rs"));
+    let write_count_after_creation_3 = fs.write_count_for_path(path!("/dir/file3.rs"));
+
+    // Perform autosave
+    let save_task = editor.update_in(cx, |editor, window, cx| {
+        editor.save(
+            SaveOptions {
+                format: true,
+                autosave: true,
+            },
+            project.clone(),
+            window,
+            cx,
+        )
+    });
+    save_task.await.unwrap();
+
+    // Only the dirty buffer should have been saved
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1,
+        1,
+        "Buffer 1 was dirty, so it should have been written once during autosave"
+    );
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2,
+        0,
+        "Buffer 2 was clean, so it should not have been written during autosave"
+    );
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3,
+        0,
+        "Buffer 3 was clean, so it should not have been written during autosave"
+    );
+
+    // Verify buffer states after autosave
+    buffer_1.update(cx, |buffer, _| assert!(!buffer.is_dirty()));
+    buffer_2.update(cx, |buffer, _| assert!(!buffer.is_dirty()));
+    buffer_3.update(cx, |buffer, _| assert!(!buffer.is_dirty()));
+
+    // Now perform a manual save (format = true)
+    let save_task = editor.update_in(cx, |editor, window, cx| {
+        editor.save(
+            SaveOptions {
+                format: true,
+                autosave: false,
+            },
+            project.clone(),
+            window,
+            cx,
+        )
+    });
+    save_task.await.unwrap();
+
+    // During manual save, clean buffers don't get written to disk
+    // They just get did_save called for language server notifications
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file1.rs")) - write_count_after_creation_1,
+        1,
+        "Buffer 1 should only have been written once total (during autosave, not manual save)"
+    );
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file2.rs")) - write_count_after_creation_2,
+        0,
+        "Buffer 2 should not have been written at all"
+    );
+    assert_eq!(
+        fs.write_count_for_path(path!("/dir/file3.rs")) - write_count_after_creation_3,
+        0,
+        "Buffer 3 should not have been written at all"
+    );
+}
+
 #[gpui::test]
 async fn test_range_format_during_save(cx: &mut TestAppContext) {
     init_test(cx, |_| {});
@@ -9399,7 +9563,15 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
 
     let save = editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .unwrap();
     fake_server
@@ -9442,7 +9614,15 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
     );
     let save = editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .unwrap();
     cx.executor().advance_clock(super::FORMAT_TIMEOUT);
@@ -9454,35 +9634,27 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
     );
     assert!(!cx.read(|cx| editor.is_dirty(cx)));
 
-    // For non-dirty buffer, a formatting request should be sent anyway with the default settings
-    // where non-dirty singleton buffers are saved and formatted anyway.
+    // For non-dirty buffer, no formatting request should be sent
     let save = editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: false,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .unwrap();
-    fake_server
-        .set_request_handler::<lsp::request::RangeFormatting, _, _>(move |params, _| async move {
-            assert_eq!(
-                params.text_document.uri,
-                lsp::Url::from_file_path(path!("/file.rs")).unwrap()
-            );
-            assert_eq!(params.options.tab_size, 4);
-            Ok(Some(vec![lsp::TextEdit::new(
-                lsp::Range::new(lsp::Position::new(0, 3), lsp::Position::new(1, 0)),
-                ", ".to_string(),
-            )]))
+    let _pending_format_request = fake_server
+        .set_request_handler::<lsp::request::RangeFormatting, _, _>(move |_, _| async move {
+            panic!("Should not be invoked");
         })
-        .next()
-        .await;
-    cx.executor().advance_clock(super::FORMAT_TIMEOUT);
+        .next();
     cx.executor().start_waiting();
     save.await;
-    assert_eq!(
-        editor.update(cx, |editor, cx| editor.text(cx)),
-        "one, two\nthree\n"
-    );
-    assert!(!cx.read(|cx| editor.is_dirty(cx)));
 
     // Set Rust language override and assert overridden tabsize is sent to language server
     update_test_language_settings(cx, |settings| {
@@ -9501,7 +9673,15 @@ async fn test_range_format_during_save(cx: &mut TestAppContext) {
     assert!(cx.read(|cx| editor.is_dirty(cx)));
     let save = editor
         .update_in(cx, |editor, window, cx| {
-            editor.save(true, project.clone(), window, cx)
+            editor.save(
+                SaveOptions {
+                    format: true,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .unwrap();
     fake_server
@@ -9585,7 +9765,7 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) {
             editor.perform_format(
                 project.clone(),
                 FormatTrigger::Manual,
-                FormatTarget::Buffers,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
                 window,
                 cx,
             )
@@ -9631,7 +9811,7 @@ async fn test_document_format_manual_trigger(cx: &mut TestAppContext) {
             editor.perform_format(
                 project,
                 FormatTrigger::Manual,
-                FormatTarget::Buffers,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
                 window,
                 cx,
             )
@@ -9809,7 +9989,7 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) {
             editor.perform_format(
                 project.clone(),
                 FormatTrigger::Manual,
-                FormatTarget::Buffers,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
                 window,
                 cx,
             )
@@ -9845,7 +10025,7 @@ async fn test_multiple_formatters(cx: &mut TestAppContext) {
             editor.perform_format(
                 project.clone(),
                 FormatTrigger::Manual,
-                FormatTarget::Buffers,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
                 window,
                 cx,
             )
@@ -15387,7 +15567,7 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) {
             editor.perform_format(
                 project.clone(),
                 FormatTrigger::Manual,
-                FormatTarget::Buffers,
+                FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
                 window,
                 cx,
             )
@@ -15407,7 +15587,7 @@ async fn test_document_format_with_prettier(cx: &mut TestAppContext) {
         editor.perform_format(
             project.clone(),
             FormatTrigger::Manual,
-            FormatTarget::Buffers,
+            FormatTarget::Buffers(editor.buffer().read(cx).all_buffers()),
             window,
             cx,
         )

crates/editor/src/items.rs 🔗

@@ -40,7 +40,7 @@ use ui::{IconDecorationKind, prelude::*};
 use util::{ResultExt, TryFutureExt, paths::PathExt};
 use workspace::{
     CollaboratorId, ItemId, ItemNavHistory, ToolbarItemLocation, ViewId, Workspace, WorkspaceId,
-    item::{FollowableItem, Item, ItemEvent, ProjectItem},
+    item::{FollowableItem, Item, ItemEvent, ProjectItem, SaveOptions},
     searchable::{Direction, SearchEvent, SearchableItem, SearchableItemHandle},
 };
 use workspace::{
@@ -805,7 +805,7 @@ impl Item for Editor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
@@ -816,48 +816,54 @@ impl Item for Editor {
             .into_iter()
             .map(|handle| handle.read(cx).base_buffer().unwrap_or(handle.clone()))
             .collect::<HashSet<_>>();
-        let save_non_dirty_buffers = self.save_non_dirty_buffers(cx);
-        cx.spawn_in(window, async move |editor, cx| {
-            if format {
-                editor
-                    .update_in(cx, |editor, window, cx| {
-                        editor.perform_format(
-                            project.clone(),
-                            FormatTrigger::Save,
-                            FormatTarget::Buffers,
-                            window,
-                            cx,
-                        )
-                    })?
-                    .await?;
+
+        // let mut buffers_to_save =
+        let buffers_to_save = if self.buffer.read(cx).is_singleton() && !options.autosave {
+            buffers.clone()
+        } else {
+            buffers
+                .iter()
+                .filter(|buffer| buffer.read(cx).is_dirty())
+                .cloned()
+                .collect()
+        };
+
+        cx.spawn_in(window, async move |this, cx| {
+            if options.format {
+                this.update_in(cx, |editor, window, cx| {
+                    editor.perform_format(
+                        project.clone(),
+                        FormatTrigger::Save,
+                        FormatTarget::Buffers(buffers_to_save.clone()),
+                        window,
+                        cx,
+                    )
+                })?
+                .await?;
             }
 
-            if save_non_dirty_buffers {
+            if !buffers_to_save.is_empty() {
                 project
-                    .update(cx, |project, cx| project.save_buffers(buffers, cx))?
+                    .update(cx, |project, cx| {
+                        project.save_buffers(buffers_to_save.clone(), cx)
+                    })?
                     .await?;
-            } else {
-                // For multi-buffers, only format and save the buffers with changes.
-                // For clean buffers, we simulate saving by calling `Buffer::did_save`,
-                // so that language servers or other downstream listeners of save events get notified.
-                let (dirty_buffers, clean_buffers) = buffers.into_iter().partition(|buffer| {
-                    buffer
-                        .read_with(cx, |buffer, _| buffer.is_dirty() || buffer.has_conflict())
-                        .unwrap_or(false)
-                });
+            }
 
-                project
-                    .update(cx, |project, cx| project.save_buffers(dirty_buffers, cx))?
-                    .await?;
-                for buffer in clean_buffers {
-                    buffer
-                        .update(cx, |buffer, cx| {
-                            let version = buffer.saved_version().clone();
-                            let mtime = buffer.saved_mtime();
-                            buffer.did_save(version, mtime, cx);
-                        })
-                        .ok();
-                }
+            // Notify about clean buffers for language server events
+            let buffers_that_were_not_saved: Vec<_> = buffers
+                .into_iter()
+                .filter(|b| !buffers_to_save.contains(b))
+                .collect();
+
+            for buffer in buffers_that_were_not_saved {
+                buffer
+                    .update(cx, |buffer, cx| {
+                        let version = buffer.saved_version().clone();
+                        let mtime = buffer.saved_mtime();
+                        buffer.did_save(version, mtime, cx);
+                    })
+                    .ok();
             }
 
             Ok(())

crates/editor/src/proposed_changes_editor.rs 🔗

@@ -12,7 +12,7 @@ use text::ToOffset;
 use ui::{ButtonLike, KeyBinding, prelude::*};
 use workspace::{
     Item, ItemHandle as _, ToolbarItemEvent, ToolbarItemLocation, ToolbarItemView, Workspace,
-    searchable::SearchableItemHandle,
+    item::SaveOptions, searchable::SearchableItemHandle,
 };
 
 pub struct ProposedChangesEditor {
@@ -351,13 +351,13 @@ impl Item for ProposedChangesEditor {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<()>> {
         self.editor.update(cx, |editor, cx| {
-            Item::save(editor, format, project, window, cx)
+            Item::save(editor, options, project, window, cx)
         })
     }
 }

crates/fs/src/fs.rs 🔗

@@ -897,6 +897,7 @@ struct FakeFsState {
     buffered_events: Vec<PathEvent>,
     metadata_call_count: usize,
     read_dir_call_count: usize,
+    path_write_counts: std::collections::HashMap<PathBuf, usize>,
     moves: std::collections::HashMap<u64, PathBuf>,
     home_dir: Option<PathBuf>,
 }
@@ -1083,6 +1084,7 @@ impl FakeFs {
                 events_paused: false,
                 read_dir_call_count: 0,
                 metadata_call_count: 0,
+                path_write_counts: Default::default(),
                 moves: Default::default(),
                 home_dir: None,
             })),
@@ -1173,6 +1175,8 @@ impl FakeFs {
         recreate_inode: bool,
     ) -> Result<()> {
         let mut state = self.state.lock();
+        let path_buf = path.as_ref().to_path_buf();
+        *state.path_write_counts.entry(path_buf).or_insert(0) += 1;
         let new_inode = state.get_and_increment_inode();
         let new_mtime = state.get_and_increment_mtime();
         let new_len = new_content.len() as u64;
@@ -1727,6 +1731,17 @@ impl FakeFs {
         self.state.lock().metadata_call_count
     }
 
+    /// How many write operations have been issued for a specific path.
+    pub fn write_count_for_path(&self, path: impl AsRef<Path>) -> usize {
+        let path = path.as_ref().to_path_buf();
+        self.state
+            .lock()
+            .path_write_counts
+            .get(&path)
+            .copied()
+            .unwrap_or(0)
+    }
+
     fn simulate_random_delay(&self) -> impl futures::Future<Output = ()> {
         self.executor.simulate_random_delay()
     }

crates/git_ui/src/project_diff.rs 🔗

@@ -37,7 +37,7 @@ use util::ResultExt as _;
 use workspace::{
     CloseActiveItem, ItemNavHistory, SerializableItem, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, TabContentParams},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions, TabContentParams},
     searchable::SearchableItemHandle,
 };
 
@@ -632,12 +632,12 @@ impl Item for ProjectDiff {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<Result<()>> {
-        self.editor.save(format, project, window, cx)
+        self.editor.save(options, project, window, cx)
     }
 
     fn save_as(
@@ -1565,7 +1565,15 @@ mod tests {
 
         cx.update_window_entity(&buffer_editor, |buffer_editor, window, cx| {
             buffer_editor.set_text("different\n", window, cx);
-            buffer_editor.save(false, project.clone(), window, cx)
+            buffer_editor.save(
+                SaveOptions {
+                    format: false,
+                    autosave: false,
+                },
+                project.clone(),
+                window,
+                cx,
+            )
         })
         .await
         .unwrap();

crates/repl/src/notebook/notebook_ui.rs 🔗

@@ -15,7 +15,7 @@ use gpui::{
 use language::{Language, LanguageRegistry};
 use project::{Project, ProjectEntryId, ProjectPath};
 use ui::{Tooltip, prelude::*};
-use workspace::item::{ItemEvent, TabContentParams};
+use workspace::item::{ItemEvent, SaveOptions, TabContentParams};
 use workspace::searchable::SearchableItemHandle;
 use workspace::{Item, ItemHandle, Pane, ProjectItem, ToolbarItemLocation};
 use workspace::{ToolbarItemEvent, ToolbarItemView};
@@ -782,7 +782,7 @@ impl Item for NotebookEditor {
     // TODO
     fn save(
         &mut self,
-        _format: bool,
+        _options: SaveOptions,
         _project: Entity<Project>,
         _window: &mut Window,
         _cx: &mut Context<Self>,

crates/search/src/project_search.rs 🔗

@@ -41,7 +41,7 @@ use util::{ResultExt as _, paths::PathMatcher};
 use workspace::{
     DeploySearch, ItemNavHistory, NewSearch, ToolbarItemEvent, ToolbarItemLocation,
     ToolbarItemView, Workspace, WorkspaceId,
-    item::{BreadcrumbText, Item, ItemEvent, ItemHandle},
+    item::{BreadcrumbText, Item, ItemEvent, ItemHandle, SaveOptions},
     searchable::{Direction, SearchableItem, SearchableItemHandle},
 };
 
@@ -530,13 +530,13 @@ impl Item for ProjectSearchView {
 
     fn save(
         &mut self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut Context<Self>,
     ) -> Task<anyhow::Result<()>> {
         self.results_editor
-            .update(cx, |editor, cx| editor.save(format, project, window, cx))
+            .update(cx, |editor, cx| editor.save(options, project, window, cx))
     }
 
     fn save_as(
@@ -1086,9 +1086,19 @@ impl ProjectSearchView {
                 let result = result_channel.await?;
                 let should_save = result == 0;
                 if should_save {
-                    this.update_in(cx, |this, window, cx| this.save(true, project, window, cx))?
-                        .await
-                        .log_err();
+                    this.update_in(cx, |this, window, cx| {
+                        this.save(
+                            SaveOptions {
+                                format: true,
+                                autosave: false,
+                            },
+                            project,
+                            window,
+                            cx,
+                        )
+                    })?
+                    .await
+                    .log_err();
                 }
                 let should_search = result != 2;
                 should_search

crates/workspace/src/item.rs 🔗

@@ -32,6 +32,21 @@ use util::ResultExt;
 
 pub const LEADER_UPDATE_THROTTLE: Duration = Duration::from_millis(200);
 
+#[derive(Clone, Copy, Debug)]
+pub struct SaveOptions {
+    pub format: bool,
+    pub autosave: bool,
+}
+
+impl Default for SaveOptions {
+    fn default() -> Self {
+        Self {
+            format: true,
+            autosave: false,
+        }
+    }
+}
+
 #[derive(Deserialize)]
 pub struct ItemSettings {
     pub git_status: bool,
@@ -326,7 +341,7 @@ pub trait Item: Focusable + EventEmitter<Self::Event> + Render + Sized {
     }
     fn save(
         &mut self,
-        _format: bool,
+        _options: SaveOptions,
         _project: Entity<Project>,
         _window: &mut Window,
         _cx: &mut Context<Self>,
@@ -528,7 +543,7 @@ pub trait ItemHandle: 'static + Send {
     fn can_save_as(&self, cx: &App) -> bool;
     fn save(
         &self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut App,
@@ -991,12 +1006,12 @@ impl<T: Item> ItemHandle for Entity<T> {
 
     fn save(
         &self,
-        format: bool,
+        options: SaveOptions,
         project: Entity<Project>,
         window: &mut Window,
         cx: &mut App,
     ) -> Task<Result<()>> {
-        self.update(cx, |item, cx| item.save(format, project, window, cx))
+        self.update(cx, |item, cx| item.save(options, project, window, cx))
     }
 
     fn save_as(
@@ -1305,7 +1320,7 @@ impl<T: FollowableItem> WeakFollowableItemHandle for WeakEntity<T> {
 #[cfg(any(test, feature = "test-support"))]
 pub mod test {
     use super::{Item, ItemEvent, SerializableItem, TabContentParams};
-    use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId};
+    use crate::{ItemId, ItemNavHistory, Workspace, WorkspaceId, item::SaveOptions};
     use gpui::{
         AnyElement, App, AppContext as _, Context, Entity, EntityId, EventEmitter, Focusable,
         InteractiveElement, IntoElement, Render, SharedString, Task, WeakEntity, Window,
@@ -1615,7 +1630,7 @@ pub mod test {
 
         fn save(
             &mut self,
-            _: bool,
+            _: SaveOptions,
             _: Entity<Project>,
             _window: &mut Window,
             cx: &mut Context<Self>,

crates/workspace/src/pane.rs 🔗

@@ -4,8 +4,8 @@ use crate::{
     WorkspaceItemBuilder,
     item::{
         ActivateOnClose, ClosePosition, Item, ItemHandle, ItemSettings, PreviewTabsSettings,
-        ProjectItemKind, ShowCloseButton, ShowDiagnostics, TabContentParams, TabTooltipContent,
-        WeakItemHandle,
+        ProjectItemKind, SaveOptions, ShowCloseButton, ShowDiagnostics, TabContentParams,
+        TabTooltipContent, WeakItemHandle,
     },
     move_item,
     notifications::NotifyResultExt,
@@ -1870,7 +1870,15 @@ impl Pane {
                 match answer.await {
                     Ok(0) => {
                         pane.update_in(cx, |_, window, cx| {
-                            item.save(should_format, project, window, cx)
+                            item.save(
+                                SaveOptions {
+                                    format: should_format,
+                                    autosave: false,
+                                },
+                                project,
+                                window,
+                                cx,
+                            )
                         })?
                         .await?
                     }
@@ -1896,7 +1904,15 @@ impl Pane {
                 match answer.await {
                     Ok(0) => {
                         pane.update_in(cx, |_, window, cx| {
-                            item.save(should_format, project, window, cx)
+                            item.save(
+                                SaveOptions {
+                                    format: should_format,
+                                    autosave: false,
+                                },
+                                project,
+                                window,
+                                cx,
+                            )
                         })?
                         .await?
                     }
@@ -1967,7 +1983,15 @@ impl Pane {
                     if pane.is_active_preview_item(item.item_id()) {
                         pane.set_preview_item_id(None, cx);
                     }
-                    item.save(should_format, project, window, cx)
+                    item.save(
+                        SaveOptions {
+                            format: should_format,
+                            autosave: false,
+                        },
+                        project,
+                        window,
+                        cx,
+                    )
                 })?
                 .await?;
             } else if can_save_as && is_singleton {
@@ -2044,7 +2068,15 @@ impl Pane {
             AutosaveSetting::AfterDelay { .. }
         );
         if item.can_autosave(cx) {
-            item.save(format, project, window, cx)
+            item.save(
+                SaveOptions {
+                    format,
+                    autosave: true,
+                },
+                project,
+                window,
+                cx,
+            )
         } else {
             Task::ready(Ok(()))
         }

crates/zed/src/zed.rs 🔗

@@ -1764,6 +1764,7 @@ mod tests {
     use workspace::{
         NewFile, OpenOptions, OpenVisible, SERIALIZATION_THROTTLE_TIME, SaveIntent, SplitDirection,
         WorkspaceHandle,
+        item::SaveOptions,
         item::{Item, ItemHandle},
         open_new, open_paths, pane,
     };
@@ -3356,7 +3357,15 @@ mod tests {
                     editor.newline(&Default::default(), window, cx);
                     editor.move_down(&Default::default(), window, cx);
                     editor.move_down(&Default::default(), window, cx);
-                    editor.save(true, project.clone(), window, cx)
+                    editor.save(
+                        SaveOptions {
+                            format: true,
+                            autosave: false,
+                        },
+                        project.clone(),
+                        window,
+                        cx,
+                    )
                 })
             })
             .unwrap()