diff --git a/assets/keymaps/default.json b/assets/keymaps/default.json index 7a25dc19d3..1d994f86af 100644 --- a/assets/keymaps/default.json +++ b/assets/keymaps/default.json @@ -93,6 +93,7 @@ "cmd-shift-down": "editor::SelectToEnd", "cmd-a": "editor::SelectAll", "cmd-l": "editor::SelectLine", + "cmd-shift-i": "editor::Format", "cmd-shift-left": [ "editor::SelectToBeginningOfLine", { diff --git a/assets/settings/default.json b/assets/settings/default.json index d8efdc41ff..34b665b41d 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -42,21 +42,20 @@ // 3. Position the dock full screen over the entire workspace" // "default_dock_anchor": "expanded" "default_dock_anchor": "right", - // How to auto-format modified buffers when saving them. This - // setting can take three values: + // Whether or not to perform a buffer format before saving + "format_on_save": true, + // How to perform a buffer format. This setting can take two values: // - // 1. Don't format code - // "format_on_save": "off" - // 2. Format code using the current language server: + // 1. Format code using the current language server: // "format_on_save": "language_server" - // 3. Format code using an external command: + // 2. Format code using an external command: // "format_on_save": { // "external": { // "command": "prettier", // "arguments": ["--stdin-filepath", "{buffer_path}"] // } // } - "format_on_save": "language_server", + "formatter": "language_server", // How to soft-wrap long lines of text. This setting can take // three values: // diff --git a/crates/collab/src/integration_tests.rs b/crates/collab/src/integration_tests.rs index 6b512d950f..0735474728 100644 --- a/crates/collab/src/integration_tests.rs +++ b/crates/collab/src/integration_tests.rs @@ -36,7 +36,7 @@ use project::{ use rand::prelude::*; use rpc::PeerId; use serde_json::json; -use settings::{FormatOnSave, Settings}; +use settings::{Formatter, Settings}; use sqlx::types::time::OffsetDateTime; use std::{ cell::RefCell, @@ -1990,6 +1990,8 @@ async fn test_reloading_buffer_manually(cx_a: &mut TestAppContext, cx_b: &mut Te #[gpui::test(iterations = 10)] async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppContext) { + use project::FormatTrigger; + let mut server = TestServer::start(cx_a.foreground(), cx_a.background()).await; let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; @@ -2042,7 +2044,12 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon project_b .update(cx_b, |project, cx| { - project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) + project.format( + HashSet::from_iter([buffer_b.clone()]), + true, + FormatTrigger::Save, + cx, + ) }) .await .unwrap(); @@ -2055,7 +2062,7 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon // host's configuration is honored as opposed to using the guest's settings. cx_a.update(|cx| { cx.update_global(|settings: &mut Settings, _| { - settings.editor_defaults.format_on_save = Some(FormatOnSave::External { + settings.editor_defaults.formatter = Some(Formatter::External { command: "awk".to_string(), arguments: vec!["{sub(/two/,\"{buffer_path}\")}1".to_string()], }); @@ -2063,7 +2070,12 @@ async fn test_formatting_buffer(cx_a: &mut TestAppContext, cx_b: &mut TestAppCon }); project_b .update(cx_b, |project, cx| { - project.format(HashSet::from_iter([buffer_b.clone()]), true, cx) + project.format( + HashSet::from_iter([buffer_b.clone()]), + true, + FormatTrigger::Save, + cx, + ) }) .await .unwrap(); diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index a7d46f7710..b23e1660b6 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -19,6 +19,7 @@ use collections::{BTreeMap, Bound, HashMap, HashSet, VecDeque}; pub use display_map::DisplayPoint; use display_map::*; pub use element::*; +use futures::FutureExt; use fuzzy::{StringMatch, StringMatchCandidate}; use gpui::{ actions, @@ -49,7 +50,7 @@ pub use multi_buffer::{ }; use multi_buffer::{MultiBufferChunks, ToOffsetUtf16}; use ordered_float::OrderedFloat; -use project::{LocationLink, Project, ProjectPath, ProjectTransaction}; +use project::{FormatTrigger, LocationLink, Project, ProjectPath, ProjectTransaction}; use selections_collection::{resolve_multiple, MutableSelectionsCollection, SelectionsCollection}; use serde::{Deserialize, Serialize}; use settings::Settings; @@ -76,6 +77,8 @@ const MAX_LINE_LEN: usize = 1024; const MIN_NAVIGATION_HISTORY_ROW_DELTA: i64 = 10; const MAX_SELECTION_HISTORY_LEN: usize = 1024; +pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); + #[derive(Clone, Deserialize, PartialEq, Default)] pub struct SelectNext { #[serde(default)] @@ -204,6 +207,7 @@ actions!( OpenExcerpts, RestartLanguageServer, Hover, + Format, ] ); @@ -310,6 +314,7 @@ pub fn init(cx: &mut MutableAppContext) { cx.add_action(Editor::toggle_code_actions); cx.add_action(Editor::open_excerpts); cx.add_action(Editor::jump); + cx.add_async_action(Editor::format); cx.add_action(Editor::restart_language_server); cx.add_action(Editor::show_character_palette); cx.add_async_action(Editor::confirm_completion); @@ -5173,6 +5178,43 @@ impl Editor { self.pending_rename.as_ref() } + fn format(&mut self, _: &Format, cx: &mut ViewContext<'_, Self>) -> Option>> { + let project = match &self.project { + Some(project) => project, + None => return None, + }; + + let buffer = self.buffer().clone(); + let buffers = buffer.read(cx).all_buffers(); + + let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse(); + let format = project.update(cx, |project, cx| { + project.format(buffers, true, FormatTrigger::Manual, cx) + }); + + Some(cx.spawn(|_, mut cx| async move { + let transaction = futures::select_biased! { + _ = timeout => { + log::warn!("timed out waiting for formatting"); + None + } + transaction = format.log_err().fuse() => transaction, + }; + + buffer.update(&mut cx, |buffer, cx| { + if let Some(transaction) = transaction { + if !buffer.is_singleton() { + buffer.push_transaction(&transaction.0); + } + } + + cx.notify(); + }); + + Ok(()) + })) + } + fn restart_language_server(&mut self, _: &RestartLanguageServer, cx: &mut ViewContext) { if let Some(project) = self.project.clone() { self.buffer.update(cx, |multi_buffer, cx| { @@ -10087,7 +10129,7 @@ mod tests { unreachable!() }); let save = cx.update(|cx| editor.save(project.clone(), cx)); - cx.foreground().advance_clock(items::FORMAT_TIMEOUT); + cx.foreground().advance_clock(super::FORMAT_TIMEOUT); cx.foreground().start_waiting(); save.await.unwrap(); assert_eq!( @@ -10203,7 +10245,7 @@ mod tests { }, ); let save = cx.update(|cx| editor.save(project.clone(), cx)); - cx.foreground().advance_clock(items::FORMAT_TIMEOUT); + cx.foreground().advance_clock(super::FORMAT_TIMEOUT); cx.foreground().start_waiting(); save.await.unwrap(); assert_eq!( diff --git a/crates/editor/src/items.rs b/crates/editor/src/items.rs index fb6f12a16f..ba600b2a5c 100644 --- a/crates/editor/src/items.rs +++ b/crates/editor/src/items.rs @@ -1,7 +1,7 @@ use crate::{ display_map::ToDisplayPoint, link_go_to_definition::hide_link_definition, movement::surrounding_word, Anchor, Autoscroll, Editor, Event, ExcerptId, MultiBuffer, - MultiBufferSnapshot, NavigationData, ToPoint as _, + MultiBufferSnapshot, NavigationData, ToPoint as _, FORMAT_TIMEOUT, }; use anyhow::{anyhow, Result}; use futures::FutureExt; @@ -10,7 +10,7 @@ use gpui::{ RenderContext, Subscription, Task, View, ViewContext, ViewHandle, }; use language::{Bias, Buffer, File as _, OffsetRangeExt, SelectionGoal}; -use project::{File, Project, ProjectEntryId, ProjectPath}; +use project::{File, FormatTrigger, Project, ProjectEntryId, ProjectPath}; use rpc::proto::{self, update_view}; use settings::Settings; use smallvec::SmallVec; @@ -20,7 +20,6 @@ use std::{ fmt::Write, ops::Range, path::{Path, PathBuf}, - time::Duration, }; use text::{Point, Selection}; use util::TryFutureExt; @@ -30,7 +29,6 @@ use workspace::{ ToolbarItemLocation, }; -pub const FORMAT_TIMEOUT: Duration = Duration::from_secs(2); pub const MAX_TAB_TITLE_LEN: usize = 24; impl FollowableItem for Editor { @@ -409,7 +407,9 @@ impl Item for Editor { let buffer = self.buffer().clone(); let buffers = buffer.read(cx).all_buffers(); let mut timeout = cx.background().timer(FORMAT_TIMEOUT).fuse(); - let format = project.update(cx, |project, cx| project.format(buffers, true, cx)); + let format = project.update(cx, |project, cx| { + project.format(buffers, true, FormatTrigger::Save, cx) + }); cx.spawn(|_, mut cx| async move { let transaction = futures::select_biased! { _ = timeout => { diff --git a/crates/project/src/project.rs b/crates/project/src/project.rs index 09c5a72315..8a3ffbc1c4 100644 --- a/crates/project/src/project.rs +++ b/crates/project/src/project.rs @@ -364,6 +364,22 @@ impl ProjectEntryId { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FormatTrigger { + Save, + Manual, +} + +impl FormatTrigger { + fn from_proto(value: i32) -> FormatTrigger { + match value { + 0 => FormatTrigger::Save, + 1 => FormatTrigger::Manual, + _ => FormatTrigger::Save, + } + } +} + impl Project { pub fn init(client: &Arc) { client.add_model_message_handler(Self::handle_request_join_project); @@ -3047,6 +3063,7 @@ impl Project { &self, buffers: HashSet>, push_to_history: bool, + trigger: FormatTrigger, cx: &mut ModelContext, ) -> Task> { let mut local_buffers = Vec::new(); @@ -3076,6 +3093,7 @@ impl Project { let response = client .request(proto::FormatBuffers { project_id, + trigger: trigger as i32, buffer_ids: remote_buffers .iter() .map(|buffer| buffer.read_with(&cx, |buffer, _| buffer.remote_id())) @@ -3092,18 +3110,22 @@ impl Project { } for (buffer, buffer_abs_path, language_server) in local_buffers { - let (format_on_save, tab_size) = buffer.read_with(&cx, |buffer, cx| { + let (format_on_save, formatter, tab_size) = buffer.read_with(&cx, |buffer, cx| { let settings = cx.global::(); let language_name = buffer.language().map(|language| language.name()); ( settings.format_on_save(language_name.as_deref()), + settings.formatter(language_name.as_deref()), settings.tab_size(language_name.as_deref()), ) }); - let transaction = match format_on_save { - settings::FormatOnSave::Off => continue, - settings::FormatOnSave::LanguageServer => Self::format_via_lsp( + if trigger == FormatTrigger::Save && !format_on_save { + continue; + } + + let transaction = match formatter { + settings::Formatter::LanguageServer => Self::format_via_lsp( &this, &buffer, &buffer_abs_path, @@ -3113,7 +3135,8 @@ impl Project { ) .await .context("failed to format via language server")?, - settings::FormatOnSave::External { command, arguments } => { + + settings::Formatter::External { command, arguments } => { Self::format_via_external_command( &buffer, &buffer_abs_path, @@ -5296,7 +5319,8 @@ impl Project { .ok_or_else(|| anyhow!("unknown buffer id {}", buffer_id))?, ); } - Ok::<_, anyhow::Error>(this.format(buffers, false, cx)) + let trigger = FormatTrigger::from_proto(envelope.payload.trigger); + Ok::<_, anyhow::Error>(this.format(buffers, false, trigger, cx)) })?; let project_transaction = format.await?; diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 4cf7e38a13..7840829b44 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -420,9 +420,15 @@ message ReloadBuffersResponse { ProjectTransaction transaction = 1; } +enum FormatTrigger { + Save = 0; + Manual = 1; +} + message FormatBuffers { uint64 project_id = 1; - repeated uint64 buffer_ids = 2; + FormatTrigger trigger = 2; + repeated uint64 buffer_ids = 3; } message FormatBuffersResponse { diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 308e5b0f43..b9f6e6a739 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -6,4 +6,4 @@ pub use conn::Connection; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 31; +pub const PROTOCOL_VERSION: u32 = 32; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index d2b076b012..8e4c49572a 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -58,7 +58,8 @@ pub struct EditorSettings { pub hard_tabs: Option, pub soft_wrap: Option, pub preferred_line_length: Option, - pub format_on_save: Option, + pub format_on_save: Option, + pub formatter: Option, pub enable_language_server: Option, } @@ -72,8 +73,7 @@ pub enum SoftWrap { #[derive(Clone, Debug, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(rename_all = "snake_case")] -pub enum FormatOnSave { - Off, +pub enum Formatter { LanguageServer, External { command: String, @@ -207,6 +207,7 @@ impl Settings { font_cache: &FontCache, themes: &ThemeRegistry, ) -> Self { + #[track_caller] fn required(value: Option) -> Option { assert!(value.is_some(), "missing default setting value"); value @@ -236,6 +237,7 @@ impl Settings { soft_wrap: required(defaults.editor.soft_wrap), preferred_line_length: required(defaults.editor.preferred_line_length), format_on_save: required(defaults.editor.format_on_save), + formatter: required(defaults.editor.formatter), enable_language_server: required(defaults.editor.enable_language_server), }, editor_overrides: Default::default(), @@ -322,8 +324,12 @@ impl Settings { self.language_setting(language, |settings| settings.preferred_line_length) } - pub fn format_on_save(&self, language: Option<&str>) -> FormatOnSave { - self.language_setting(language, |settings| settings.format_on_save.clone()) + pub fn format_on_save(&self, language: Option<&str>) -> bool { + self.language_setting(language, |settings| settings.format_on_save) + } + + pub fn formatter(&self, language: Option<&str>) -> Formatter { + self.language_setting(language, |settings| settings.formatter.clone()) } pub fn enable_language_server(&self, language: Option<&str>) -> bool { @@ -358,7 +364,8 @@ impl Settings { hard_tabs: Some(false), soft_wrap: Some(SoftWrap::None), preferred_line_length: Some(80), - format_on_save: Some(FormatOnSave::LanguageServer), + format_on_save: Some(true), + formatter: Some(Formatter::LanguageServer), enable_language_server: Some(true), }, editor_overrides: Default::default(),