Implement 'format without save' (#8806)

Mikayla Maki created

This solves a major usability problem in Zed, that there's no way to
temporarily disable auto formatting without toggling the whole feature
off.

fixes https://github.com/zed-industries/zed/issues/5230

Release Notes:

- Added a new `workspace::SaveWithoutFormatting`, bound to `cmd-k s`, to
save a file without invoking the auto formatter.

Change summary

assets/keymaps/default-linux.json     |  1 +
assets/keymaps/default-macos.json     |  3 ++-
crates/diagnostics/src/diagnostics.rs |  9 +++++++--
crates/editor/src/editor_tests.rs     | 12 ++++++------
crates/editor/src/items.rs            | 17 ++++++++++++-----
crates/search/src/project_search.rs   |  3 ++-
crates/workspace/src/item.rs          | 24 ++++++++++++++++++++----
crates/workspace/src/pane.rs          | 16 ++++++++++++----
crates/workspace/src/workspace.rs     |  6 ++++++
crates/zed/src/zed.rs                 |  2 +-
10 files changed, 69 insertions(+), 24 deletions(-)

Detailed changes

assets/keymaps/default-linux.json 🔗

@@ -365,6 +365,7 @@
       "ctrl-alt-b": "branches::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
       "ctrl-s": "workspace::Save",
+      "ctrl-k s": "workspace::SaveWithoutFormat",
       "ctrl-shift-s": "workspace::SaveAs",
       "ctrl-n": "workspace::NewFile",
       "ctrl-shift-n": "workspace::NewWindow",

assets/keymaps/default-macos.json 🔗

@@ -408,6 +408,7 @@
       "alt-cmd-b": "branches::OpenRecent",
       "ctrl-~": "workspace::NewTerminal",
       "cmd-s": "workspace::Save",
+      "cmd-k s": "workspace::SaveWithoutFormat",
       "cmd-shift-s": "workspace::SaveAs",
       "cmd-n": "workspace::NewFile",
       "cmd-shift-n": "workspace::NewWindow",
@@ -426,8 +427,8 @@
       "cmd-j": "workspace::ToggleBottomDock",
       "alt-cmd-y": "workspace::CloseAllDocks",
       "cmd-shift-f": "pane::DeploySearch",
-      "cmd-k cmd-t": "theme_selector::Toggle",
       "cmd-k cmd-s": "zed::OpenKeymap",
+      "cmd-k cmd-t": "theme_selector::Toggle",
       "cmd-t": "project_symbols::Toggle",
       "cmd-p": "file_finder::Toggle",
       "cmd-shift-p": "command_palette::Toggle",

crates/diagnostics/src/diagnostics.rs 🔗

@@ -735,8 +735,13 @@ impl Item for ProjectDiagnosticsEditor {
         true
     }
 
-    fn save(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
-        self.editor.save(project, cx)
+    fn save(
+        &mut self,
+        format: bool,
+        project: Model<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
+        self.editor.save(format, project, cx)
     }
 
     fn save_as(

crates/editor/src/editor_tests.rs 🔗

@@ -5265,7 +5265,7 @@ async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
     assert!(cx.read(|cx| editor.is_dirty(cx)));
 
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     fake_server
         .handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
@@ -5303,7 +5303,7 @@ async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
         unreachable!()
     });
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     cx.executor().advance_clock(super::FORMAT_TIMEOUT);
     cx.executor().start_waiting();
@@ -5326,7 +5326,7 @@ async fn test_document_format_during_save(cx: &mut gpui::TestAppContext) {
     });
 
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     fake_server
         .handle_request::<lsp::request::Formatting, _, _>(move |params, _| async move {
@@ -5379,7 +5379,7 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
     assert!(cx.read(|cx| editor.is_dirty(cx)));
 
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     fake_server
         .handle_request::<lsp::request::RangeFormatting, _, _>(move |params, _| async move {
@@ -5418,7 +5418,7 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
         },
     );
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     cx.executor().advance_clock(super::FORMAT_TIMEOUT);
     cx.executor().start_waiting();
@@ -5441,7 +5441,7 @@ async fn test_range_format_during_save(cx: &mut gpui::TestAppContext) {
     });
 
     let save = editor
-        .update(cx, |editor, cx| editor.save(project.clone(), cx))
+        .update(cx, |editor, cx| editor.save(true, project.clone(), cx))
         .unwrap();
     fake_server
         .handle_request::<lsp::request::RangeFormatting, _, _>(move |params, _| async move {

crates/editor/src/items.rs 🔗

@@ -702,14 +702,21 @@ impl Item for Editor {
         }
     }
 
-    fn save(&mut self, project: Model<Project>, cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+    fn save(
+        &mut self,
+        format: bool,
+        project: Model<Project>,
+        cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
         self.report_editor_event("save", None, cx);
         let buffers = self.buffer().clone().read(cx).all_buffers();
         cx.spawn(|this, mut cx| async move {
-            this.update(&mut cx, |this, cx| {
-                this.perform_format(project.clone(), FormatTrigger::Save, cx)
-            })?
-            .await?;
+            if format {
+                this.update(&mut cx, |this, cx| {
+                    this.perform_format(project.clone(), FormatTrigger::Save, cx)
+                })?
+                .await?;
+            }
 
             if buffers.len() == 1 {
                 project

crates/search/src/project_search.rs 🔗

@@ -539,11 +539,12 @@ impl Item for ProjectSearchView {
 
     fn save(
         &mut self,
+        format: bool,
         project: Model<Project>,
         cx: &mut ViewContext<Self>,
     ) -> Task<anyhow::Result<()>> {
         self.results_editor
-            .update(cx, |editor, cx| editor.save(project, cx))
+            .update(cx, |editor, cx| editor.save(format, project, cx))
     }
 
     fn save_as(

crates/workspace/src/item.rs 🔗

@@ -146,7 +146,12 @@ pub trait Item: FocusableView + EventEmitter<Self::Event> {
     fn can_save(&self, _cx: &AppContext) -> bool {
         false
     }
-    fn save(&mut self, _project: Model<Project>, _cx: &mut ViewContext<Self>) -> Task<Result<()>> {
+    fn save(
+        &mut self,
+        _format: bool,
+        _project: Model<Project>,
+        _cx: &mut ViewContext<Self>,
+    ) -> Task<Result<()>> {
         unimplemented!("save() must be implemented if can_save() returns true")
     }
     fn save_as(
@@ -258,7 +263,12 @@ pub trait ItemHandle: 'static + Send {
     fn is_dirty(&self, cx: &AppContext) -> bool;
     fn has_conflict(&self, cx: &AppContext) -> bool;
     fn can_save(&self, cx: &AppContext) -> bool;
-    fn save(&self, project: Model<Project>, cx: &mut WindowContext) -> Task<Result<()>>;
+    fn save(
+        &self,
+        format: bool,
+        project: Model<Project>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<()>>;
     fn save_as(
         &self,
         project: Model<Project>,
@@ -566,8 +576,13 @@ impl<T: Item> ItemHandle for View<T> {
         self.read(cx).can_save(cx)
     }
 
-    fn save(&self, project: Model<Project>, cx: &mut WindowContext) -> Task<Result<()>> {
-        self.update(cx, |item, cx| item.save(project, cx))
+    fn save(
+        &self,
+        format: bool,
+        project: Model<Project>,
+        cx: &mut WindowContext,
+    ) -> Task<Result<()>> {
+        self.update(cx, |item, cx| item.save(format, project, cx))
     }
 
     fn save_as(
@@ -1018,6 +1033,7 @@ pub mod test {
 
         fn save(
             &mut self,
+            _: bool,
             _: Model<Project>,
             _: &mut ViewContext<Self>,
         ) -> Task<anyhow::Result<()>> {

crates/workspace/src/pane.rs 🔗

@@ -44,6 +44,8 @@ pub enum SaveIntent {
     /// write all files (even if unchanged)
     /// prompt before overwriting on-disk changes
     Save,
+    /// same as Save, but without auto formatting
+    SaveWithoutFormat,
     /// write any files that have local changes
     /// prompt before overwriting on-disk changes
     SaveAll,
@@ -1122,7 +1124,7 @@ impl Pane {
         })?;
 
         // when saving a single buffer, we ignore whether or not it's dirty.
-        if save_intent == SaveIntent::Save {
+        if save_intent == SaveIntent::Save || save_intent == SaveIntent::SaveWithoutFormat {
             is_dirty = true;
         }
 
@@ -1136,6 +1138,8 @@ impl Pane {
             has_conflict = false;
         }
 
+        let should_format = save_intent != SaveIntent::SaveWithoutFormat;
+
         if has_conflict && can_save {
             let answer = pane.update(cx, |pane, cx| {
                 pane.activate_item(item_ix, true, true, cx);
@@ -1147,7 +1151,10 @@ impl Pane {
                 )
             })?;
             match answer.await {
-                Ok(0) => pane.update(cx, |_, cx| item.save(project, cx))?.await?,
+                Ok(0) => {
+                    pane.update(cx, |_, cx| item.save(should_format, project, cx))?
+                        .await?
+                }
                 Ok(1) => pane.update(cx, |_, cx| item.reload(project, cx))?.await?,
                 _ => return Ok(false),
             }
@@ -1179,7 +1186,8 @@ impl Pane {
             }
 
             if can_save {
-                pane.update(cx, |_, cx| item.save(project, cx))?.await?;
+                pane.update(cx, |_, cx| item.save(should_format, project, cx))?
+                    .await?;
             } else if can_save_as {
                 let start_abs_path = project
                     .update(cx, |project, cx| {
@@ -1211,7 +1219,7 @@ impl Pane {
         cx: &mut WindowContext,
     ) -> Task<Result<()>> {
         if Self::can_autosave_item(item, cx) {
-            item.save(project, cx)
+            item.save(true, project, cx)
         } else {
             Task::ready(Ok(()))
         }

crates/workspace/src/workspace.rs 🔗

@@ -106,6 +106,7 @@ actions!(
         AddFolderToProject,
         Unfollow,
         SaveAs,
+        SaveWithoutFormat,
         ReloadActiveItem,
         ActivatePreviousPane,
         ActivateNextPane,
@@ -3532,6 +3533,11 @@ impl Workspace {
                     .save_active_item(action.save_intent.unwrap_or(SaveIntent::Save), cx)
                     .detach_and_log_err(cx);
             }))
+            .on_action(cx.listener(|workspace, _: &SaveWithoutFormat, cx| {
+                workspace
+                    .save_active_item(SaveIntent::SaveWithoutFormat, cx)
+                    .detach_and_log_err(cx);
+            }))
             .on_action(cx.listener(|workspace, _: &SaveAs, cx| {
                 workspace
                     .save_active_item(SaveIntent::SaveAs, cx)

crates/zed/src/zed.rs 🔗

@@ -1979,7 +1979,7 @@ mod tests {
                     editor.newline(&Default::default(), cx);
                     editor.move_down(&Default::default(), cx);
                     editor.move_down(&Default::default(), cx);
-                    editor.save(project.clone(), cx)
+                    editor.save(true, project.clone(), cx)
                 })
             })
             .unwrap()