From 6aba3950d2a5ade7751d6bb226057b1f16a75f82 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Sun, 12 Jan 2025 19:34:35 -0700 Subject: [PATCH 01/25] Improve keymap json schema (#23044) Also: * Adds `impl_internal_actions!` for deriving the `Action` trait without registering. * Removes some deserializers that immediately fail in favor of `#[serde(skip)]` on fields where they were used. This also omits them from the schema. Release Notes: - Keymap settings file now has more JSON schema information to inform `json-language-server` completions and info, particularly for actions that take input. --- Cargo.lock | 6 + crates/assistant/src/assistant.rs | 6 +- crates/editor/src/actions.rs | 61 ++++---- crates/gpui/src/action.rs | 163 +++++++++++++++++++--- crates/gpui/src/app.rs | 18 ++- crates/gpui/src/gpui.rs | 2 + crates/gpui/tests/action_macros.rs | 3 +- crates/gpui_macros/src/register_action.rs | 5 +- crates/language/src/language.rs | 5 +- crates/languages/Cargo.toml | 1 + crates/languages/src/json.rs | 16 ++- crates/picker/Cargo.toml | 1 + crates/picker/src/picker.rs | 3 +- crates/project_panel/src/project_panel.rs | 17 ++- crates/search/Cargo.toml | 1 + crates/search/src/buffer_search.rs | 3 +- crates/settings/src/keymap_file.rs | 154 ++++++++++++++------ crates/tab_switcher/Cargo.toml | 1 + crates/tab_switcher/src/tab_switcher.rs | 3 +- crates/terminal_view/Cargo.toml | 1 + crates/terminal_view/src/terminal_view.rs | 7 +- crates/title_bar/Cargo.toml | 1 + crates/title_bar/src/application_menu.rs | 7 +- crates/vim/src/command.rs | 45 +++--- crates/vim/src/digraph.rs | 3 +- crates/vim/src/motion.rs | 31 ++-- crates/vim/src/normal/increment.rs | 8 +- crates/vim/src/normal/paste.rs | 6 +- crates/vim/src/normal/search.rs | 24 ++-- crates/vim/src/object.rs | 12 +- crates/vim/src/state.rs | 51 +++++-- crates/vim/src/surrounds.rs | 12 +- crates/vim/src/vim.rs | 12 +- crates/workspace/src/pane.rs | 22 +-- crates/workspace/src/pane_group.rs | 5 +- crates/workspace/src/workspace.rs | 34 ++--- crates/zed_actions/src/lib.rs | 25 ++-- 37 files changed, 499 insertions(+), 276 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7f63e6aceb73c..fd93fbfeb350f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6817,6 +6817,7 @@ dependencies = [ "regex", "rope", "rust-embed", + "schemars", "serde", "serde_json", "settings", @@ -9332,6 +9333,7 @@ dependencies = [ "env_logger 0.11.6", "gpui", "menu", + "schemars", "serde", "serde_json", "ui", @@ -11278,6 +11280,7 @@ dependencies = [ "language", "menu", "project", + "schemars", "serde", "serde_json", "settings", @@ -12659,6 +12662,7 @@ dependencies = [ "menu", "picker", "project", + "schemars", "serde", "serde_json", "settings", @@ -12852,6 +12856,7 @@ dependencies = [ "language", "project", "rand 0.8.5", + "schemars", "search", "serde", "serde_json", @@ -13186,6 +13191,7 @@ dependencies = [ "project", "remote", "rpc", + "schemars", "serde", "settings", "smallvec", diff --git a/crates/assistant/src/assistant.rs b/crates/assistant/src/assistant.rs index 33c12088a7d1f..6ac1d851f2cb2 100644 --- a/crates/assistant/src/assistant.rs +++ b/crates/assistant/src/assistant.rs @@ -26,7 +26,7 @@ pub use context::*; pub use context_store::*; use feature_flags::FeatureFlagAppExt; use fs::Fs; -use gpui::impl_actions; +use gpui::impl_internal_actions; use gpui::{actions, AppContext, Global, SharedString, UpdateGlobal}; pub(crate) use inline_assistant::*; use language_model::{ @@ -74,13 +74,13 @@ actions!( ] ); -#[derive(PartialEq, Clone, Deserialize)] +#[derive(PartialEq, Clone)] pub enum InsertDraggedFiles { ProjectPaths(Vec), ExternalFiles(Vec), } -impl_actions!(assistant, [InsertDraggedFiles]); +impl_internal_actions!(assistant, [InsertDraggedFiles]); const DEFAULT_CONTEXT_LINES: usize = 50; diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index ede5916b06fa6..43226f0f62a87 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -1,82 +1,84 @@ //! This module contains all actions supported by [`Editor`]. use super::*; use gpui::{action_aliases, action_as}; +use schemars::JsonSchema; use util::serde::default_true; -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectNext { #[serde(default)] pub replace_newest: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectPrevious { #[serde(default)] pub replace_newest: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveToBeginningOfLine { #[serde(default = "default_true")] pub stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectToBeginningOfLine { #[serde(default)] pub(super) stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MovePageUp { #[serde(default)] pub(super) center_cursor: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MovePageDown { #[serde(default)] pub(super) center_cursor: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveToEndOfLine { #[serde(default = "default_true")] pub stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectToEndOfLine { #[serde(default)] pub(super) stop_at_soft_wraps: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ToggleCodeActions { // Display row from which the action was deployed. #[serde(default)] + #[serde(skip)] pub deployed_from_indicator: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ConfirmCompletion { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ComposeCompletion { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ConfirmCodeAction { #[serde(default)] pub item_ix: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ToggleComments { #[serde(default)] pub advance_downwards: bool, @@ -84,84 +86,87 @@ pub struct ToggleComments { pub ignore_indent: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct FoldAt { + #[serde(skip)] pub buffer_row: MultiBufferRow, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct UnfoldAt { + #[serde(skip)] pub buffer_row: MultiBufferRow, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveUpByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct MoveDownByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectUpByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SelectDownByLines { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerpts { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerptsUp { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ExpandExcerptsDown { #[serde(default)] pub(super) lines: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] + +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct ShowCompletions { #[serde(default)] pub(super) trigger: Option, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct HandleInput(pub String); -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct DeleteToNextWordEnd { #[serde(default)] pub ignore_newlines: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct DeleteToPreviousWordStart { #[serde(default)] pub ignore_newlines: bool, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct FoldAtLevel { pub level: u32, } -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct SpawnNearestTask { #[serde(default)] pub reveal: task::RevealStrategy, diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 338397a5513f1..bc7388e513ee1 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -63,6 +63,16 @@ pub trait Action: 'static + Send { where Self: Sized; + /// Optional JSON schema for the action's input data. + fn action_json_schema( + _: &mut schemars::gen::SchemaGenerator, + ) -> Option + where + Self: Sized, + { + None + } + /// A list of alternate, deprecated names for this action. fn deprecated_aliases() -> &'static [&'static str] where @@ -90,16 +100,16 @@ impl dyn Action { type ActionBuilder = fn(json: serde_json::Value) -> anyhow::Result>; pub(crate) struct ActionRegistry { - builders_by_name: HashMap, + by_name: HashMap, names_by_type_id: HashMap, all_names: Vec, // So we can return a static slice. - deprecations: Vec<(SharedString, SharedString)>, + deprecations: HashMap, } impl Default for ActionRegistry { fn default() -> Self { let mut this = ActionRegistry { - builders_by_name: Default::default(), + by_name: Default::default(), names_by_type_id: Default::default(), all_names: Default::default(), deprecations: Default::default(), @@ -111,19 +121,25 @@ impl Default for ActionRegistry { } } +struct ActionData { + pub build: ActionBuilder, + pub json_schema: fn(&mut schemars::gen::SchemaGenerator) -> Option, +} + /// This type must be public so that our macros can build it in other crates. /// But this is an implementation detail and should not be used directly. #[doc(hidden)] -pub type MacroActionBuilder = fn() -> ActionData; +pub type MacroActionBuilder = fn() -> MacroActionData; /// This type must be public so that our macros can build it in other crates. /// But this is an implementation detail and should not be used directly. #[doc(hidden)] -pub struct ActionData { +pub struct MacroActionData { pub name: &'static str, pub aliases: &'static [&'static str], pub type_id: TypeId, pub build: ActionBuilder, + pub json_schema: fn(&mut schemars::gen::SchemaGenerator) -> Option, } /// This constant must be public to be accessible from other crates. @@ -143,20 +159,35 @@ impl ActionRegistry { #[cfg(test)] pub(crate) fn load_action(&mut self) { - self.insert_action(ActionData { + self.insert_action(MacroActionData { name: A::debug_name(), aliases: A::deprecated_aliases(), type_id: TypeId::of::(), build: A::build, + json_schema: A::action_json_schema, }); } - fn insert_action(&mut self, action: ActionData) { + fn insert_action(&mut self, action: MacroActionData) { let name: SharedString = action.name.into(); - self.builders_by_name.insert(name.clone(), action.build); + self.by_name.insert( + name.clone(), + ActionData { + build: action.build, + json_schema: action.json_schema, + }, + ); for &alias in action.aliases { - self.builders_by_name.insert(alias.into(), action.build); - self.deprecations.push((alias.into(), name.clone())); + let alias: SharedString = alias.into(); + self.by_name.insert( + alias.clone(), + ActionData { + build: action.build, + json_schema: action.json_schema, + }, + ); + self.deprecations.insert(alias.clone(), name.clone()); + self.all_names.push(alias); } self.names_by_type_id.insert(action.type_id, name.clone()); self.all_names.push(name); @@ -180,9 +211,10 @@ impl ActionRegistry { params: Option, ) -> Result> { let build_action = self - .builders_by_name + .by_name .get(name) - .ok_or_else(|| anyhow!("no action type registered for {}", name))?; + .ok_or_else(|| anyhow!("No action type registered for {}", name))? + .build; (build_action)(params.unwrap_or_else(|| json!({}))) .with_context(|| format!("Attempting to build action {}", name)) } @@ -191,12 +223,30 @@ impl ActionRegistry { self.all_names.as_slice() } - pub fn action_deprecations(&self) -> &[(SharedString, SharedString)] { - self.deprecations.as_slice() + pub fn action_schemas( + &self, + generator: &mut schemars::gen::SchemaGenerator, + ) -> Vec<(SharedString, Option)> { + // Use the order from all_names so that the resulting schema has sensible order. + self.all_names + .iter() + .map(|name| { + let action_data = self + .by_name + .get(name) + .expect("All actions in all_names should be registered"); + (name.clone(), (action_data.json_schema)(generator)) + }) + .collect::>() + } + + pub fn action_deprecations(&self) -> &HashMap { + &self.deprecations } } -/// Defines unit structs that can be used as actions. +/// Defines and registers unit structs that can be used as actions. +/// /// To use more complex data types as actions, use `impl_actions!` #[macro_export] macro_rules! actions { @@ -211,6 +261,11 @@ macro_rules! actions { gpui::__impl_action!($namespace, $name, $name, fn build(_: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) + }, + fn action_json_schema( + _: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None } ); @@ -219,11 +274,10 @@ macro_rules! actions { }; } -/// Defines a unit struct that can be used as an actions, with a name -/// that differs from it's type name. +/// Defines and registers a unit struct that can be used as an actions, with a name that differs +/// from it's type name. /// -/// To use more complex data types as actions, and rename them use -/// `impl_action_as!` +/// To use more complex data types as actions, and rename them use `impl_action_as!` #[macro_export] macro_rules! action_as { ($namespace:path, $name:ident as $visual_name:ident) => { @@ -241,6 +295,11 @@ macro_rules! action_as { _: gpui::private::serde_json::Value, ) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None } ); @@ -248,7 +307,7 @@ macro_rules! action_as { }; } -/// Defines a unit struct that can be used as an action, with some deprecated aliases. +/// Defines and registers a unit struct that can be used as an action, with some deprecated aliases. #[macro_export] macro_rules! action_aliases { ($namespace:path, $name:ident, [$($alias:ident),* $(,)?]) => { @@ -261,6 +320,7 @@ macro_rules! action_aliases { ::std::default::Default, ::std::fmt::Debug, gpui::private::serde_derive::Deserialize, + gpui::private::schemars::JsonSchema, )] #[serde(crate = "gpui::private::serde")] pub struct $name; @@ -274,6 +334,12 @@ macro_rules! action_aliases { ) -> gpui::Result<::std::boxed::Box> { Ok(Box::new(Self)) }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None + + }, fn deprecated_aliases() -> &'static [&'static str] { &[ $(concat!(stringify!($namespace), "::", stringify!($alias))),* @@ -285,7 +351,11 @@ macro_rules! action_aliases { }; } -/// Implements the Action trait for any struct that implements Clone, Default, PartialEq, and serde_deserialize::Deserialize +/// Registers the action and implements the Action trait for any struct that implements Clone, +/// Default, PartialEq, serde_deserialize::Deserialize, and schemars::JsonSchema. +/// +/// Fields and variants that don't make sense for user configuration should be annotated with +/// #[serde(skip)]. #[macro_export] macro_rules! impl_actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { @@ -293,6 +363,13 @@ macro_rules! impl_actions { gpui::__impl_action!($namespace, $name, $name, fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { Ok(std::boxed::Box::new(gpui::private::serde_json::from_value::(value)?)) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + Some(::json_schema( + generator, + )) } ); @@ -301,8 +378,41 @@ macro_rules! impl_actions { }; } -/// Implements the Action trait for a struct that implements Clone, Default, PartialEq, and serde_deserialize::Deserialize -/// Allows you to rename the action visually, without changing the struct's name +/// Implements the Action trait for internal action structs that implement Clone, Default, +/// PartialEq. The purpose of this is to conveniently define values that can be passed in `dyn +/// Action`. +/// +/// These actions are internal and so are not registered and do not support deserialization. +#[macro_export] +macro_rules! impl_internal_actions { + ($namespace:path, [ $($name:ident),* $(,)? ]) => { + $( + gpui::__impl_action!($namespace, $name, $name, + fn build(value: gpui::private::serde_json::Value) -> gpui::Result<::std::boxed::Box> { + gpui::Result::Err(gpui::private::anyhow::anyhow!( + concat!( + stringify!($namespace), + "::", + stringify!($visual_name), + " is an internal action, so cannot be built from JSON." + ))) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + None + } + ); + )* + }; +} + +/// Implements the Action trait for a struct that implements Clone, Default, PartialEq, and +/// serde_deserialize::Deserialize. Allows you to rename the action visually, without changing the +/// struct's name. +/// +/// Fields and variants that don't make sense for user configuration should be annotated with +/// #[serde(skip)]. #[macro_export] macro_rules! impl_action_as { ($namespace:path, $name:ident as $visual_name:tt ) => { @@ -316,6 +426,13 @@ macro_rules! impl_action_as { Ok(std::boxed::Box::new( gpui::private::serde_json::from_value::(value)?, )) + }, + fn action_json_schema( + generator: &mut gpui::private::schemars::gen::SchemaGenerator, + ) -> Option { + Some(::json_schema( + generator, + )) } ); diff --git a/crates/gpui/src/app.rs b/crates/gpui/src/app.rs index a0ec1f9933f11..0c23eaeb3bb6b 100644 --- a/crates/gpui/src/app.rs +++ b/crates/gpui/src/app.rs @@ -23,7 +23,7 @@ use parking_lot::RwLock; use slotmap::SlotMap; pub use async_context::*; -use collections::{FxHashMap, FxHashSet, VecDeque}; +use collections::{FxHashMap, FxHashSet, HashMap, VecDeque}; pub use entity_map::*; use http_client::HttpClient; pub use model_context::*; @@ -1218,16 +1218,22 @@ impl AppContext { self.actions.build_action(name, data) } - /// Get a list of all action names that have been registered. - /// in the application. Note that registration only allows for - /// actions to be built dynamically, and is unrelated to binding - /// actions in the element tree. + /// Get all action names that have been registered. Note that registration only allows for + /// actions to be built dynamically, and is unrelated to binding actions in the element tree. pub fn all_action_names(&self) -> &[SharedString] { self.actions.all_action_names() } + /// Get all non-internal actions that have been registered, along with their schemas. + pub fn action_schemas( + &self, + generator: &mut schemars::gen::SchemaGenerator, + ) -> Vec<(SharedString, Option)> { + self.actions.action_schemas(generator) + } + /// Get a list of all deprecated action aliases and their canonical names. - pub fn action_deprecations(&self) -> &[(SharedString, SharedString)] { + pub fn action_deprecations(&self) -> &HashMap { self.actions.action_deprecations() } diff --git a/crates/gpui/src/gpui.rs b/crates/gpui/src/gpui.rs index fd2617f393f9f..b173382dcab46 100644 --- a/crates/gpui/src/gpui.rs +++ b/crates/gpui/src/gpui.rs @@ -102,7 +102,9 @@ mod window; /// Do not touch, here be dragons for use by gpui_macros and such. #[doc(hidden)] pub mod private { + pub use anyhow; pub use linkme; + pub use schemars; pub use serde; pub use serde_derive; pub use serde_json; diff --git a/crates/gpui/tests/action_macros.rs b/crates/gpui/tests/action_macros.rs index 99572a4b3c5b1..98a70daa9532e 100644 --- a/crates/gpui/tests/action_macros.rs +++ b/crates/gpui/tests/action_macros.rs @@ -1,12 +1,13 @@ use gpui::{actions, impl_actions}; use gpui_macros::register_action; +use schemars::JsonSchema; use serde_derive::Deserialize; #[test] fn test_action_macros() { actions!(test, [TestAction]); - #[derive(PartialEq, Clone, Deserialize)] + #[derive(PartialEq, Clone, Deserialize, JsonSchema)] struct AnotherTestAction; impl_actions!(test, [AnotherTestAction]); diff --git a/crates/gpui_macros/src/register_action.rs b/crates/gpui_macros/src/register_action.rs index 7fc8158e9bf2c..6c22ccf02fbd7 100644 --- a/crates/gpui_macros/src/register_action.rs +++ b/crates/gpui_macros/src/register_action.rs @@ -29,12 +29,13 @@ pub(crate) fn register_action(type_name: &Ident) -> proc_macro2::TokenStream { fn __autogenerated() { /// This is an auto generated function, do not use. #[doc(hidden)] - fn #action_builder_fn_name() -> gpui::ActionData { - gpui::ActionData { + fn #action_builder_fn_name() -> gpui::MacroActionData { + gpui::MacroActionData { name: <#type_name as gpui::Action>::debug_name(), aliases: <#type_name as gpui::Action>::deprecated_aliases(), type_id: ::std::any::TypeId::of::<#type_name>(), build: <#type_name as gpui::Action>::build, + json_schema: <#type_name as gpui::Action>::action_json_schema, } } #[doc(hidden)] diff --git a/crates/language/src/language.rs b/crates/language/src/language.rs index 793513e025fee..9732939d50686 100644 --- a/crates/language/src/language.rs +++ b/crates/language/src/language.rs @@ -662,8 +662,7 @@ pub struct LanguageConfigOverride { pub line_comments: Override>>, #[serde(default)] pub block_comment: Override<(Arc, Arc)>, - #[serde(skip_deserializing)] - #[schemars(skip)] + #[serde(skip)] pub disabled_bracket_ixs: Vec, #[serde(default)] pub word_characters: Override>, @@ -776,7 +775,7 @@ pub struct BracketPairConfig { pub pairs: Vec, /// A list of tree-sitter scopes for which a given bracket should not be active. /// N-th entry in `[Self::disabled_scopes_by_bracket_ix]` contains a list of disabled scopes for an n-th entry in `[Self::pairs]` - #[schemars(skip)] + #[serde(skip)] pub disabled_scopes_by_bracket_ix: Vec>, } diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index 951423056e641..fffe0698554a8 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -56,6 +56,7 @@ project.workspace = true regex.workspace = true rope.workspace = true rust-embed.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 7172f96f74430..07a28d6abd04a 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -10,6 +10,7 @@ use language::{LanguageRegistry, LanguageToolchainStore, LspAdapter, LspAdapterD use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::NodeRuntime; use project::{lsp_store::language_server_settings, ContextProviderWithTasks}; +use schemars::gen::SchemaSettings; use serde_json::{json, Value}; use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; use smol::{ @@ -75,9 +76,6 @@ impl JsonLspAdapter { } fn get_workspace_config(language_names: Vec, cx: &mut AppContext) -> Value { - let action_names = cx.all_action_names(); - let deprecations = cx.action_deprecations(); - let font_names = &cx.text_system().all_font_names(); let settings_schema = cx.global::().json_schema( &SettingsJsonSchemaParams { @@ -117,7 +115,7 @@ impl JsonLspAdapter { }, { "fileMatch": [schema_file_match(paths::keymap_file())], - "schema": KeymapFile::generate_json_schema(action_names, deprecations), + "schema": Self::generate_keymap_schema(cx), }, { "fileMatch": [ @@ -131,6 +129,16 @@ impl JsonLspAdapter { } }) } + + fn generate_keymap_schema(cx: &mut AppContext) -> Value { + let mut generator = SchemaSettings::draft07() + .with(|settings| settings.option_add_null_type = false) + .into_generator(); + + let action_schemas = cx.action_schemas(&mut generator); + let deprecations = cx.action_deprecations(); + KeymapFile::generate_json_schema(generator, action_schemas, deprecations) + } } #[async_trait(?Send)] diff --git a/crates/picker/Cargo.toml b/crates/picker/Cargo.toml index db42c1cd80cdf..e906b0965e9d9 100644 --- a/crates/picker/Cargo.toml +++ b/crates/picker/Cargo.toml @@ -20,6 +20,7 @@ anyhow.workspace = true editor.workspace = true gpui.workspace = true menu.workspace = true +schemars.workspace = true serde.workspace = true ui.workspace = true workspace.workspace = true diff --git a/crates/picker/src/picker.rs b/crates/picker/src/picker.rs index c97fceeef3403..56dbf69835b38 100644 --- a/crates/picker/src/picker.rs +++ b/crates/picker/src/picker.rs @@ -7,6 +7,7 @@ use gpui::{ ViewContext, WindowContext, }; use head::Head; +use schemars::JsonSchema; use serde::Deserialize; use std::{sync::Arc, time::Duration}; use ui::{prelude::*, v_flex, Color, Divider, Label, ListItem, ListItemSpacing}; @@ -24,7 +25,7 @@ actions!(picker, [ConfirmCompletion]); /// ConfirmInput is an alternative editor action which - instead of selecting active picker entry - treats pickers editor input literally, /// performing some kind of action on it. -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema, Default)] pub struct ConfirmInput { pub secondary: bool, } diff --git a/crates/project_panel/src/project_panel.rs b/crates/project_panel/src/project_panel.rs index 1eccfc9e1b409..5cdbd78ede508 100644 --- a/crates/project_panel/src/project_panel.rs +++ b/crates/project_panel/src/project_panel.rs @@ -1,10 +1,10 @@ mod project_panel_settings; mod utils; +use anyhow::{anyhow, Context as _, Result}; use client::{ErrorCode, ErrorExt}; -use language::DiagnosticSeverity; -use settings::{Settings, SettingsStore}; - +use collections::{hash_map, BTreeSet, HashMap}; +use command_palette_hooks::CommandPaletteFilter; use db::kvp::KEY_VALUE_STORE; use editor::{ items::{ @@ -15,10 +15,6 @@ use editor::{ Editor, EditorEvent, EditorSettings, ShowScrollbar, }; use file_icons::FileIcons; - -use anyhow::{anyhow, Context as _, Result}; -use collections::{hash_map, BTreeSet, HashMap}; -use command_palette_hooks::CommandPaletteFilter; use git::repository::GitFileStatus; use gpui::{ actions, anchored, deferred, div, impl_actions, point, px, size, uniform_list, Action, @@ -30,6 +26,7 @@ use gpui::{ VisualContext as _, WeakView, WindowContext, }; use indexmap::IndexMap; +use language::DiagnosticSeverity; use menu::{Confirm, SelectFirst, SelectLast, SelectNext, SelectPrev}; use project::{ relativize_path, Entry, EntryKind, Fs, Project, ProjectEntryId, ProjectPath, Worktree, @@ -38,7 +35,9 @@ use project::{ use project_panel_settings::{ ProjectPanelDockPosition, ProjectPanelSettings, ShowDiagnostics, ShowIndentGuides, }; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; +use settings::{Settings, SettingsStore}; use smallvec::SmallVec; use std::any::TypeId; use std::{ @@ -152,13 +151,13 @@ struct EntryDetails { canonical_path: Option>, } -#[derive(PartialEq, Clone, Default, Debug, Deserialize)] +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] struct Delete { #[serde(default)] pub skip_prompt: bool, } -#[derive(PartialEq, Clone, Default, Debug, Deserialize)] +#[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] struct Trash { #[serde(default)] pub skip_prompt: bool, diff --git a/crates/search/Cargo.toml b/crates/search/Cargo.toml index 18cdb36f169b6..ad36242d7cb32 100644 --- a/crates/search/Cargo.toml +++ b/crates/search/Cargo.toml @@ -31,6 +31,7 @@ gpui.workspace = true language.workspace = true menu.workspace = true project.workspace = true +schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/search/src/buffer_search.rs b/crates/search/src/buffer_search.rs index a81ddc1a6a9c2..df6e7e7d6a8f8 100644 --- a/crates/search/src/buffer_search.rs +++ b/crates/search/src/buffer_search.rs @@ -22,6 +22,7 @@ use project::{ search::SearchQuery, search_history::{SearchHistory, SearchHistoryCursor}, }; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -43,7 +44,7 @@ use registrar::{ForDeployed, ForDismissed, SearchActionsRegistrar, WithResults}; const MAX_BUFFER_SEARCH_HISTORY_SIZE: usize = 50; -#[derive(PartialEq, Clone, Deserialize)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema)] pub struct Deploy { #[serde(default = "util::serde::default_true")] pub focus: bool, diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index c2b4625ffc74c..9b8a17c23e18c 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -1,11 +1,11 @@ use crate::{settings_store::parse_json_with_comments, SettingsAssets}; use anyhow::{anyhow, Context, Result}; -use collections::BTreeMap; +use collections::{BTreeMap, HashMap}; use gpui::{Action, AppContext, KeyBinding, SharedString}; use schemars::{ - gen::{SchemaGenerator, SchemaSettings}, - schema::{InstanceType, Schema, SchemaObject, SingleOrVec, SubschemaValidation}, - JsonSchema, Map, + gen::SchemaGenerator, + schema::{ArrayValidation, InstanceType, Metadata, Schema, SchemaObject, SubschemaValidation}, + JsonSchema, }; use serde::Deserialize; use serde_json::Value; @@ -140,55 +140,117 @@ impl KeymapFile { } pub fn generate_json_schema( - action_names: &[SharedString], - deprecations: &[(SharedString, SharedString)], + generator: SchemaGenerator, + action_schemas: Vec<(SharedString, Option)>, + deprecations: &HashMap, ) -> serde_json::Value { - let mut root_schema = SchemaSettings::draft07() - .with(|settings| settings.option_add_null_type = false) - .into_generator() - .into_root_schema_for::(); - - let mut alternatives = vec![ - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - enum_values: Some( - action_names - .iter() - .map(|name| Value::String(name.to_string())) - .collect(), - ), - ..Default::default() - }), - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Array))), - ..Default::default() - }), - Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::Null))), + fn set(input: I) -> Option + where + I: Into, + { + Some(input.into()) + } + + fn add_deprecation_notice(schema_object: &mut SchemaObject, new_name: &SharedString) { + schema_object.extensions.insert( + // deprecationMessage is not part of the JSON Schema spec, + // but json-language-server recognizes it. + "deprecationMessage".to_owned(), + format!("Deprecated, use {new_name}").into(), + ); + } + + let empty_object: SchemaObject = SchemaObject { + instance_type: set(InstanceType::Object), + ..Default::default() + }; + + let mut keymap_action_alternatives = Vec::new(); + for (name, action_schema) in action_schemas.iter() { + let schema = if let Some(Schema::Object(schema)) = action_schema { + Some(schema.clone()) + } else { + None + }; + + // If the type has a description, also apply it to the value. Ideally it would be + // removed and applied to the overall array, but `json-language-server` does not show + // these descriptions. + let description = schema.as_ref().and_then(|schema| { + schema + .metadata + .as_ref() + .and_then(|metadata| metadata.description.as_ref()) + }); + let mut matches_action_name = SchemaObject { + const_value: Some(Value::String(name.to_string())), ..Default::default() - }), - ]; - for (old, new) in deprecations { - alternatives.push(Schema::Object(SchemaObject { - instance_type: Some(SingleOrVec::Single(Box::new(InstanceType::String))), - const_value: Some(Value::String(old.to_string())), - extensions: Map::from_iter([( - // deprecationMessage is not part of the JSON Schema spec, - // but json-language-server recognizes it. - "deprecationMessage".to_owned(), - format!("Deprecated, use {new}").into(), - )]), + }; + if let Some(description) = description { + matches_action_name.metadata = set(Metadata { + description: Some(description.clone()), + ..Default::default() + }); + } + + // Add an alternative for plain action names. + let deprecation = deprecations.get(name); + let mut plain_action = SchemaObject { + instance_type: set(InstanceType::String), + const_value: Some(Value::String(name.to_string())), ..Default::default() - })); + }; + if let Some(new_name) = deprecation { + add_deprecation_notice(&mut plain_action, new_name); + } + keymap_action_alternatives.push(plain_action.into()); + + // When all fields are skipped or an empty struct is added with impl_actions! / + // impl_actions_as! an empty struct is produced. The action should be invoked without + // data in this case. + if let Some(schema) = schema { + if schema != empty_object { + let mut action_with_data = SchemaObject { + instance_type: set(InstanceType::Array), + array: Some( + ArrayValidation { + items: set(vec![matches_action_name.into(), schema.into()]), + min_items: Some(2), + max_items: Some(2), + ..Default::default() + } + .into(), + ), + ..Default::default() + }; + if let Some(new_name) = deprecation { + add_deprecation_notice(&mut action_with_data, new_name); + } + keymap_action_alternatives.push(action_with_data.into()); + } + } } - let action_schema = Schema::Object(SchemaObject { - subschemas: Some(Box::new(SubschemaValidation { - one_of: Some(alternatives), + + // Placing null first causes json-language-server to default assuming actions should be + // null, so place it last. + keymap_action_alternatives.push( + SchemaObject { + instance_type: set(InstanceType::Null), + ..Default::default() + } + .into(), + ); + + let action_schema = SchemaObject { + subschemas: set(SubschemaValidation { + one_of: Some(keymap_action_alternatives), ..Default::default() - })), + }), ..Default::default() - }); + } + .into(); + let mut root_schema = generator.into_root_schema_for::(); root_schema .definitions .insert("KeymapAction".to_owned(), action_schema); diff --git a/crates/tab_switcher/Cargo.toml b/crates/tab_switcher/Cargo.toml index c30469fb176c7..cee167ed7b5ff 100644 --- a/crates/tab_switcher/Cargo.toml +++ b/crates/tab_switcher/Cargo.toml @@ -19,6 +19,7 @@ gpui.workspace = true menu.workspace = true picker.workspace = true project.workspace = true +schemars.workspace = true serde.workspace = true settings.workspace = true ui.workspace = true diff --git a/crates/tab_switcher/src/tab_switcher.rs b/crates/tab_switcher/src/tab_switcher.rs index f076a4f1bc5a6..1b427d9f31a59 100644 --- a/crates/tab_switcher/src/tab_switcher.rs +++ b/crates/tab_switcher/src/tab_switcher.rs @@ -10,6 +10,7 @@ use gpui::{ }; use picker::{Picker, PickerDelegate}; use project::Project; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -23,7 +24,7 @@ use workspace::{ const PANEL_WIDTH_REMS: f32 = 28.; -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, JsonSchema, Default)] pub struct Toggle { #[serde(default)] pub select_last: bool, diff --git a/crates/terminal_view/Cargo.toml b/crates/terminal_view/Cargo.toml index 83803ff21c976..b71c095abc3db 100644 --- a/crates/terminal_view/Cargo.toml +++ b/crates/terminal_view/Cargo.toml @@ -29,6 +29,7 @@ itertools.workspace = true language.workspace = true project.workspace = true task.workspace = true +schemars.workspace = true search.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/terminal_view/src/terminal_view.rs b/crates/terminal_view/src/terminal_view.rs index 63958bd839040..6d87b03d0562f 100644 --- a/crates/terminal_view/src/terminal_view.rs +++ b/crates/terminal_view/src/terminal_view.rs @@ -15,6 +15,7 @@ use gpui::{ use language::Bias; use persistence::TERMINAL_DB; use project::{search::SearchQuery, terminals::TerminalKind, Fs, Metadata, Project}; +use schemars::JsonSchema; use terminal::{ alacritty_terminal::{ index::Point, @@ -66,14 +67,14 @@ const CURSOR_BLINK_INTERVAL: Duration = Duration::from_millis(500); const GIT_DIFF_PATH_PREFIXES: &[char] = &['a', 'b']; -///Event to transmit the scroll from the element to the view +/// Event to transmit the scroll from the element to the view #[derive(Clone, Debug, PartialEq)] pub struct ScrollTerminal(pub i32); -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)] pub struct SendText(String); -#[derive(Clone, Debug, Default, Deserialize, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, JsonSchema, PartialEq)] pub struct SendKeystroke(String); impl_actions!(terminal, [SendText, SendKeystroke]); diff --git a/crates/title_bar/Cargo.toml b/crates/title_bar/Cargo.toml index df6e0afd17424..18980300b2832 100644 --- a/crates/title_bar/Cargo.toml +++ b/crates/title_bar/Cargo.toml @@ -36,6 +36,7 @@ notifications.workspace = true project.workspace = true remote.workspace = true rpc.workspace = true +schemars.workspace = true serde.workspace = true settings.workspace = true smallvec.workspace = true diff --git a/crates/title_bar/src/application_menu.rs b/crates/title_bar/src/application_menu.rs index 1c3e67c0958fd..23e470a345444 100644 --- a/crates/title_bar/src/application_menu.rs +++ b/crates/title_bar/src/application_menu.rs @@ -1,17 +1,18 @@ use gpui::{impl_actions, OwnedMenu, OwnedMenuItem, View}; +use schemars::JsonSchema; use serde::Deserialize; use smallvec::SmallVec; use ui::{prelude::*, ContextMenu, PopoverMenu, PopoverMenuHandle, Tooltip}; impl_actions!( app_menu, - [OpenApplicationMenu, NavigateApplicationMenuInDirection,] + [OpenApplicationMenu, NavigateApplicationMenuInDirection] ); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default)] pub struct OpenApplicationMenu(String); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq, Default)] pub struct NavigateApplicationMenuInDirection(String); #[derive(Clone)] diff --git a/crates/vim/src/command.rs b/crates/vim/src/command.rs index f7af6d0567cef..e2bd19af5fb3c 100644 --- a/crates/vim/src/command.rs +++ b/crates/vim/src/command.rs @@ -1,11 +1,3 @@ -use std::{ - iter::Peekable, - ops::{Deref, Range}, - str::Chars, - sync::OnceLock, - time::Instant, -}; - use anyhow::{anyhow, Result}; use command_palette_hooks::CommandInterceptResult; use editor::{ @@ -13,12 +5,22 @@ use editor::{ display_map::ToDisplayPoint, Bias, Editor, ToPoint, }; -use gpui::{actions, impl_actions, Action, AppContext, Global, ViewContext, WindowContext}; +use gpui::{ + actions, impl_internal_actions, Action, AppContext, Global, ViewContext, WindowContext, +}; use language::Point; use multi_buffer::MultiBufferRow; use regex::Regex; +use schemars::JsonSchema; use search::{BufferSearchBar, SearchOptions}; use serde::Deserialize; +use std::{ + iter::Peekable, + ops::{Deref, Range}, + str::Chars, + sync::OnceLock, + time::Instant, +}; use util::ResultExt; use workspace::{notifications::NotifyResultExt, SaveIntent}; @@ -33,24 +35,24 @@ use crate::{ Vim, }; -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct GoToLine { range: CommandRange, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct YankCommand { range: CommandRange, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct WithRange { restore_selection: bool, range: CommandRange, action: WrappedAction, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct WithCount { count: u32, action: WrappedAction, @@ -60,20 +62,11 @@ pub struct WithCount { struct WrappedAction(Box); actions!(vim, [VisualCommand, CountCommand]); -impl_actions!( +impl_internal_actions!( vim, [GoToLine, YankCommand, WithRange, WithCount, OnMatchingLines] ); -impl<'de> Deserialize<'de> for WrappedAction { - fn deserialize(_: D) -> Result - where - D: serde::Deserializer<'de>, - { - Err(serde::de::Error::custom("Cannot deserialize WrappedAction")) - } -} - impl PartialEq for WrappedAction { fn eq(&self, other: &Self) -> bool { self.0.partial_eq(&*other.0) @@ -423,7 +416,7 @@ impl VimCommand { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] enum Position { Line { row: u32, offset: i32 }, Mark { name: char, offset: i32 }, @@ -467,7 +460,7 @@ impl Position { } } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct CommandRange { start: Position, end: Option, @@ -877,7 +870,7 @@ fn generate_positions(string: &str, query: &str) -> Vec { positions } -#[derive(Debug, PartialEq, Deserialize, Clone)] +#[derive(Debug, PartialEq, Clone)] pub(crate) struct OnMatchingLines { range: CommandRange, search: String, diff --git a/crates/vim/src/digraph.rs b/crates/vim/src/digraph.rs index dcccc8b5cd316..c17a774be04ec 100644 --- a/crates/vim/src/digraph.rs +++ b/crates/vim/src/digraph.rs @@ -3,6 +3,7 @@ use std::sync::Arc; use collections::HashMap; use editor::Editor; use gpui::{impl_actions, AppContext, Keystroke, KeystrokeEvent}; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::LazyLock; @@ -12,7 +13,7 @@ use crate::{state::Operator, Vim, VimSettings}; mod default; -#[derive(PartialEq, Clone, Deserialize)] +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq)] struct Literal(String, char); impl_actions!(vim, [Literal]); diff --git a/crates/vim/src/motion.rs b/crates/vim/src/motion.rs index 43c4397e8b272..cf90d03a7ded7 100644 --- a/crates/vim/src/motion.rs +++ b/crates/vim/src/motion.rs @@ -9,6 +9,7 @@ use editor::{ use gpui::{actions, impl_actions, px, ViewContext}; use language::{CharKind, Point, Selection, SelectionGoal}; use multi_buffer::MultiBufferRow; +use schemars::JsonSchema; use serde::Deserialize; use std::ops::Range; use workspace::searchable::Direction; @@ -139,105 +140,105 @@ pub enum Motion { }, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct NextWordStart { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct NextWordEnd { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct PreviousWordStart { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct PreviousWordEnd { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct NextSubwordStart { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct NextSubwordEnd { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct PreviousSubwordStart { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct PreviousSubwordEnd { #[serde(default)] pub(crate) ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Up { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct Down { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct FirstNonWhitespace { #[serde(default)] display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct EndOfLine { #[serde(default)] display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub struct StartOfLine { #[serde(default)] pub(crate) display_lines: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct UnmatchedForward { #[serde(default)] char: char, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct UnmatchedBackward { #[serde(default)] diff --git a/crates/vim/src/normal/increment.rs b/crates/vim/src/normal/increment.rs index ca300fc1be27d..803a8d01e28bf 100644 --- a/crates/vim/src/normal/increment.rs +++ b/crates/vim/src/normal/increment.rs @@ -1,20 +1,20 @@ -use std::ops::Range; - use editor::{scroll::Autoscroll, Editor, MultiBufferSnapshot, ToOffset, ToPoint}; use gpui::{impl_actions, ViewContext}; use language::{Bias, Point}; +use schemars::JsonSchema; use serde::Deserialize; +use std::ops::Range; use crate::{state::Mode, Vim}; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Increment { #[serde(default)] step: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Decrement { #[serde(default)] diff --git a/crates/vim/src/normal/paste.rs b/crates/vim/src/normal/paste.rs index 8d49a6802c195..4fe18a61b1266 100644 --- a/crates/vim/src/normal/paste.rs +++ b/crates/vim/src/normal/paste.rs @@ -1,16 +1,16 @@ -use std::cmp; - use editor::{display_map::ToDisplayPoint, movement, scroll::Autoscroll, DisplayPoint, RowExt}; use gpui::{impl_actions, ViewContext}; use language::{Bias, SelectionGoal}; +use schemars::JsonSchema; use serde::Deserialize; +use std::cmp; use crate::{ state::{Mode, Register}, Vim, }; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub struct Paste { #[serde(default)] diff --git a/crates/vim/src/normal/search.rs b/crates/vim/src/normal/search.rs index 103d33f8af12c..fb6bd6a93b4ae 100644 --- a/crates/vim/src/normal/search.rs +++ b/crates/vim/src/normal/search.rs @@ -1,10 +1,10 @@ -use std::{iter::Peekable, str::Chars, time::Duration}; - use editor::Editor; -use gpui::{actions, impl_actions, ViewContext}; +use gpui::{actions, impl_actions, impl_internal_actions, ViewContext}; use language::Point; +use schemars::JsonSchema; use search::{buffer_search, BufferSearchBar, SearchOptions}; use serde_derive::Deserialize; +use std::{iter::Peekable, str::Chars, time::Duration}; use util::serde::default_true; use workspace::{notifications::NotifyResultExt, searchable::Direction}; @@ -15,7 +15,7 @@ use crate::{ Vim, }; -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToNext { #[serde(default = "default_true")] @@ -26,7 +26,7 @@ pub(crate) struct MoveToNext { regex: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] pub(crate) struct MoveToPrev { #[serde(default = "default_true")] @@ -37,7 +37,7 @@ pub(crate) struct MoveToPrev { regex: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] pub(crate) struct Search { #[serde(default)] backwards: bool, @@ -45,19 +45,19 @@ pub(crate) struct Search { regex: bool, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq)] pub struct FindCommand { pub query: String, pub backwards: bool, } -#[derive(Debug, Clone, PartialEq, Deserialize)] +#[derive(Clone, Debug, PartialEq)] pub struct ReplaceCommand { pub(crate) range: CommandRange, pub(crate) replacement: Replacement, } -#[derive(Debug, Default, PartialEq, Deserialize, Clone)] +#[derive(Clone, Debug, PartialEq)] pub(crate) struct Replacement { search: String, replacement: String, @@ -66,10 +66,8 @@ pub(crate) struct Replacement { } actions!(vim, [SearchSubmit, MoveToNextMatch, MoveToPrevMatch]); -impl_actions!( - vim, - [FindCommand, ReplaceCommand, Search, MoveToPrev, MoveToNext] -); +impl_actions!(vim, [FindCommand, Search, MoveToPrev, MoveToNext]); +impl_internal_actions!(vim, [ReplaceCommand]); pub(crate) fn register(editor: &mut Editor, cx: &mut ViewContext) { Vim::action(editor, cx, Vim::move_to_next); diff --git a/crates/vim/src/object.rs b/crates/vim/src/object.rs index 745d1adb78418..a00e6fea17e8d 100644 --- a/crates/vim/src/object.rs +++ b/crates/vim/src/object.rs @@ -10,15 +10,14 @@ use editor::{ movement::{self, FindRange}, Bias, DisplayPoint, Editor, }; - -use itertools::Itertools; - use gpui::{actions, impl_actions, ViewContext}; +use itertools::Itertools; use language::{BufferSnapshot, CharKind, Point, Selection, TextObject, TreeSitterOptions}; use multi_buffer::MultiBufferRow; +use schemars::JsonSchema; use serde::Deserialize; -#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize)] +#[derive(Copy, Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] pub enum Object { Word { ignore_punctuation: bool }, Sentence, @@ -40,13 +39,14 @@ pub enum Object { Comment, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct Word { #[serde(default)] ignore_punctuation: bool, } -#[derive(Clone, Deserialize, PartialEq)] + +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] #[serde(rename_all = "camelCase")] struct IndentObj { #[serde(default)] diff --git a/crates/vim/src/state.rs b/crates/vim/src/state.rs index f7a617a6ab55f..e401903c9a9b9 100644 --- a/crates/vim/src/state.rs +++ b/crates/vim/src/state.rs @@ -1,6 +1,3 @@ -use std::borrow::BorrowMut; -use std::{fmt::Display, ops::Range, sync::Arc}; - use crate::command::command_interceptor; use crate::normal::repeat::Replayer; use crate::surrounds::SurroundsType; @@ -13,12 +10,15 @@ use gpui::{ Action, AppContext, BorrowAppContext, ClipboardEntry, ClipboardItem, Global, View, WeakView, }; use language::Point; +use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; +use std::borrow::BorrowMut; +use std::{fmt::Display, ops::Range, sync::Arc}; use ui::{SharedString, ViewContext}; use workspace::searchable::Direction; -#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize, JsonSchema, Serialize)] pub enum Mode { Normal, Insert, @@ -59,22 +59,39 @@ impl Default for Mode { } } -#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Deserialize, JsonSchema)] pub enum Operator { Change, Delete, Yank, Replace, - Object { around: bool }, - FindForward { before: bool }, - FindBackward { after: bool }, - Sneak { first_char: Option }, - SneakBackward { first_char: Option }, - AddSurrounds { target: Option }, - ChangeSurrounds { target: Option }, + Object { + around: bool, + }, + FindForward { + before: bool, + }, + FindBackward { + after: bool, + }, + Sneak { + first_char: Option, + }, + SneakBackward { + first_char: Option, + }, + AddSurrounds { + #[serde(skip)] + target: Option, + }, + ChangeSurrounds { + target: Option, + }, DeleteSurrounds, Mark, - Jump { line: bool }, + Jump { + line: bool, + }, Indent, Outdent, AutoIndent, @@ -82,8 +99,12 @@ pub enum Operator { Lowercase, Uppercase, OppositeCase, - Digraph { first_char: Option }, - Literal { prefix: Option }, + Digraph { + first_char: Option, + }, + Literal { + prefix: Option, + }, Register, RecordRegister, ReplayRegister, diff --git a/crates/vim/src/surrounds.rs b/crates/vim/src/surrounds.rs index 719a147062386..a480ff3617bab 100644 --- a/crates/vim/src/surrounds.rs +++ b/crates/vim/src/surrounds.rs @@ -6,7 +6,7 @@ use crate::{ }; use editor::{movement, scroll::Autoscroll, Bias}; use language::BracketPair; -use serde::Deserialize; + use std::sync::Arc; use ui::ViewContext; @@ -17,16 +17,6 @@ pub enum SurroundsType { Selection, } -// This exists so that we can have Deserialize on Operators, but not on Motions. -impl<'de> Deserialize<'de> for SurroundsType { - fn deserialize(_: D) -> Result - where - D: serde::Deserializer<'de>, - { - Err(serde::de::Error::custom("Cannot deserialize SurroundsType")) - } -} - impl Vim { pub fn add_surrounds( &mut self, diff --git a/crates/vim/src/vim.rs b/crates/vim/src/vim.rs index dbc3a25ce320c..d8a58e4789374 100644 --- a/crates/vim/src/vim.rs +++ b/crates/vim/src/vim.rs @@ -49,25 +49,25 @@ use workspace::{self, Pane, ResizeIntent, Workspace}; use crate::state::ReplayableAction; /// Used to resize the current pane -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct ResizePane(pub ResizeIntent); /// An Action to Switch between modes -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct SwitchMode(pub Mode); /// PushOperator is used to put vim into a "minor" mode, /// where it's waiting for a specific next set of keystrokes. /// For example 'd' needs a motion to complete. -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] pub struct PushOperator(pub Operator); /// Number is used to manage vim's count. Pushing a digit -/// multiplis the current value by 10 and adds the digit. -#[derive(Clone, Deserialize, PartialEq)] +/// multiplies the current value by 10 and adds the digit. +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] struct Number(usize); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, JsonSchema, PartialEq)] struct SelectRegister(String); actions!( diff --git a/crates/workspace/src/pane.rs b/crates/workspace/src/pane.rs index 05ab9a8f90e2b..a633c27610b04 100644 --- a/crates/workspace/src/pane.rs +++ b/crates/workspace/src/pane.rs @@ -25,6 +25,7 @@ use itertools::Itertools; use language::DiagnosticSeverity; use parking_lot::Mutex; use project::{Project, ProjectEntryId, ProjectPath, WorktreeId}; +use schemars::JsonSchema; use serde::Deserialize; use settings::{Settings, SettingsStore}; use std::{ @@ -71,7 +72,7 @@ impl DraggedSelection { } } -#[derive(PartialEq, Clone, Copy, Deserialize, Debug)] +#[derive(Clone, Copy, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub enum SaveIntent { /// write all files (even if unchanged) @@ -92,16 +93,16 @@ pub enum SaveIntent { Skip, } -#[derive(Clone, Deserialize, PartialEq, Debug)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] pub struct ActivateItem(pub usize); -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseActiveItem { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseInactiveItems { pub save_intent: Option, @@ -109,7 +110,7 @@ pub struct CloseInactiveItems { pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseAllItems { pub save_intent: Option, @@ -117,34 +118,35 @@ pub struct CloseAllItems { pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseCleanItems { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseItemsToTheRight { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct CloseItemsToTheLeft { #[serde(default)] pub close_pinned: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] #[serde(rename_all = "camelCase")] pub struct RevealInProjectPanel { + #[serde(skip)] pub entry_id: Option, } -#[derive(Default, PartialEq, Clone, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema, Default)] pub struct DeploySearch { #[serde(default)] pub replace_enabled: bool, diff --git a/crates/workspace/src/pane_group.rs b/crates/workspace/src/pane_group.rs index 168f6539e0100..1b523c3d2ca79 100644 --- a/crates/workspace/src/pane_group.rs +++ b/crates/workspace/src/pane_group.rs @@ -13,6 +13,7 @@ use gpui::{ }; use parking_lot::Mutex; use project::Project; +use schemars::JsonSchema; use serde::Deserialize; use settings::Settings; use std::sync::Arc; @@ -717,7 +718,7 @@ impl PaneAxis { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, PartialEq, JsonSchema)] pub enum SplitDirection { Up, Down, @@ -800,7 +801,7 @@ impl SplitDirection { } } -#[derive(Clone, Copy, Debug, Deserialize, PartialEq)] +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, PartialEq)] pub enum ResizeIntent { Lengthen, Shorten, diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index ad91bb386b009..bc5d801e06a81 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -61,10 +61,9 @@ use persistence::{ SerializedWindowBounds, DB, }; use postage::stream::Stream; -use project::{ - DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree, WorktreeId, -}; +use project::{DirectoryLister, Project, ProjectEntryId, ProjectPath, ResolvedPath, Worktree}; use remote::{ssh_session::ConnectionIdentifier, SshClientDelegate, SshConnectionOptions}; +use schemars::JsonSchema; use serde::Deserialize; use session::AppSession; use settings::Settings; @@ -119,9 +118,6 @@ static ZED_WINDOW_POSITION: LazyLock>> = LazyLock::new(|| { .and_then(parse_pixel_position_env_var) }); -#[derive(Clone, PartialEq)] -pub struct RemoveWorktreeFromProject(pub WorktreeId); - actions!(assistant, [ShowConfiguration]); actions!( @@ -165,64 +161,64 @@ pub struct OpenPaths { pub paths: Vec, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct ActivatePane(pub usize); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct ActivatePaneInDirection(pub SplitDirection); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct SwapPaneInDirection(pub SplitDirection); -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct MoveItemToPane { pub destination: usize, #[serde(default = "default_true")] pub focus: bool, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct MoveItemToPaneInDirection { pub direction: SplitDirection, #[serde(default = "default_true")] pub focus: bool, } -#[derive(Clone, PartialEq, Debug, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct SaveAll { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize)] +#[derive(Clone, PartialEq, Debug, Deserialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct Save { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CloseAllItemsAndPanes { pub save_intent: Option, } -#[derive(Clone, PartialEq, Debug, Deserialize, Default)] +#[derive(Clone, PartialEq, Debug, Deserialize, Default, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct CloseInactiveTabsAndPanes { pub save_intent: Option, } -#[derive(Clone, Deserialize, PartialEq)] +#[derive(Clone, Deserialize, PartialEq, JsonSchema)] pub struct SendKeystrokes(pub String); -#[derive(Clone, Deserialize, PartialEq, Default)] +#[derive(Clone, Deserialize, PartialEq, Default, JsonSchema)] pub struct Reload { pub binary_path: Option, } action_as!(project_symbols, ToggleProjectSymbols as Toggle); -#[derive(Default, PartialEq, Eq, Clone, serde::Deserialize)] +#[derive(Default, PartialEq, Eq, Clone, Deserialize, JsonSchema)] pub struct ToggleFileFinder { #[serde(default)] pub separate_history: bool, @@ -299,7 +295,7 @@ impl PartialEq for Toast { } } -#[derive(Debug, Default, Clone, Deserialize, PartialEq)] +#[derive(Debug, Default, Clone, Deserialize, PartialEq, JsonSchema)] pub struct OpenTerminal { pub working_directory: PathBuf, } diff --git a/crates/zed_actions/src/lib.rs b/crates/zed_actions/src/lib.rs index 6b2691cf76b35..cafc46b743ee6 100644 --- a/crates/zed_actions/src/lib.rs +++ b/crates/zed_actions/src/lib.rs @@ -11,12 +11,12 @@ use serde::{Deserialize, Serialize}; // https://github.com/mmastrac/rust-ctor/issues/280 pub fn init() {} -#[derive(Clone, PartialEq, Deserialize)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema)] pub struct OpenBrowser { pub url: String, } -#[derive(Clone, PartialEq, Deserialize)] +#[derive(Clone, PartialEq, Deserialize, JsonSchema)] pub struct OpenZedUrl { pub url: String, } @@ -65,9 +65,10 @@ pub mod feedback { pub mod theme_selector { use gpui::impl_actions; + use schemars::JsonSchema; use serde::Deserialize; - #[derive(PartialEq, Clone, Default, Debug, Deserialize)] + #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema)] pub struct Toggle { /// A list of theme names to filter the theme selector down to. pub themes_filter: Option>, @@ -76,20 +77,21 @@ pub mod theme_selector { impl_actions!(theme_selector, [Toggle]); } -#[derive(Clone, Default, Deserialize, PartialEq)] +#[derive(Clone, Default, Deserialize, PartialEq, JsonSchema)] pub struct InlineAssist { pub prompt: Option, } impl_actions!(assistant, [InlineAssist]); -#[derive(PartialEq, Clone, Deserialize, Default)] +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct OpenRecent { #[serde(default)] pub create_new_window: bool, } -gpui::impl_actions!(projects, [OpenRecent]); -gpui::actions!(projects, [OpenRemote]); + +impl_actions!(projects, [OpenRecent]); +actions!(projects, [OpenRemote]); /// Where to spawn the task in the UI. #[derive(Default, Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] @@ -102,8 +104,8 @@ pub enum RevealTarget { Dock, } -/// Spawn a task with name or open tasks modal -#[derive(Debug, PartialEq, Clone, Deserialize)] +/// Spawn a task with name or open tasks modal. +#[derive(Debug, PartialEq, Clone, Deserialize, JsonSchema)] #[serde(untagged)] pub enum Spawn { /// Spawns a task by the name given. @@ -128,8 +130,8 @@ impl Spawn { } } -/// Rerun last task -#[derive(PartialEq, Clone, Deserialize, Default)] +/// Rerun the last task. +#[derive(PartialEq, Clone, Deserialize, Default, JsonSchema)] pub struct Rerun { /// Controls whether the task context is reevaluated prior to execution of a task. /// If it is not, environment variables such as ZED_COLUMN, ZED_FILE are gonna be the same as in the last execution of a task @@ -147,6 +149,7 @@ pub struct Rerun { pub use_new_terminal: Option, /// If present, rerun the task with this ID, otherwise rerun the last task. + #[serde(skip)] pub task_id: Option, } From e08484840b19bae7d28dfaeac6025d7f649e1cb5 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Sun, 12 Jan 2025 22:33:24 -0700 Subject: [PATCH 02/25] Clarify logic for `Autoscroll::newest()` and `Autoscroll::fit()` (#23048) Release Notes: - N/A --- crates/editor/src/scroll/autoscroll.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/editor/src/scroll/autoscroll.rs b/crates/editor/src/scroll/autoscroll.rs index 8ae5dea7205c2..3f64831909f4e 100644 --- a/crates/editor/src/scroll/autoscroll.rs +++ b/crates/editor/src/scroll/autoscroll.rs @@ -128,9 +128,9 @@ impl Editor { .next_row() .as_f32(); - // If the selections can't all fit on screen, scroll to the newest. + let selections_fit = target_bottom - target_top <= visible_lines; if autoscroll == Autoscroll::newest() - || autoscroll == Autoscroll::fit() && target_bottom - target_top > visible_lines + || (autoscroll == Autoscroll::fit() && !selections_fit) { let newest_selection_top = selections .iter() From f2ab00cec7545ffb7d8d75e4ccab74d5fccccf9b Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 13 Jan 2025 11:58:49 +0100 Subject: [PATCH 03/25] Improve prompt caching for edit prediction (#23061) This is achieved by halving the number of events instead of popping the front. Release Notes: - N/A Co-authored-by: Thorsten --- crates/zeta/src/zeta.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/zeta/src/zeta.rs b/crates/zeta/src/zeta.rs index 546f6a254905e..9c794dc7fc6e9 100644 --- a/crates/zeta/src/zeta.rs +++ b/crates/zeta/src/zeta.rs @@ -206,7 +206,7 @@ impl Zeta { } fn push_event(&mut self, event: Event) { - const MAX_EVENT_COUNT: usize = 20; + const MAX_EVENT_COUNT: usize = 16; if let Some(Event::BufferChange { new_snapshot: last_new_snapshot, @@ -232,8 +232,8 @@ impl Zeta { } self.events.push_back(event); - if self.events.len() > MAX_EVENT_COUNT { - self.events.pop_front(); + if self.events.len() >= MAX_EVENT_COUNT { + self.events.drain(..MAX_EVENT_COUNT / 2); } } From c26553de820cf7abc7aca1d5671b18047639cccb Mon Sep 17 00:00:00 2001 From: Antonio Scandurra Date: Mon, 13 Jan 2025 13:04:28 +0100 Subject: [PATCH 04/25] Add more metrics for Fireworks Completion Requested (#23062) Release Notes: - N/A Co-authored-by: Thorsten --- crates/collab/src/llm.rs | 20 ++++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/crates/collab/src/llm.rs b/crates/collab/src/llm.rs index e0115cc5d0f01..830ea3673a537 100644 --- a/crates/collab/src/llm.rs +++ b/crates/collab/src/llm.rs @@ -470,6 +470,8 @@ async fn predict_edits( .replace("", &outline_prefix) .replace("", ¶ms.input_events) .replace("", ¶ms.input_excerpt); + + let request_start = std::time::Instant::now(); let mut response = fireworks::complete( &state.http_client, api_url, @@ -486,13 +488,18 @@ async fn predict_edits( }, ) .await?; + let duration = request_start.elapsed(); + + let choice = response + .completion + .choices + .pop() + .context("no output from completion response")?; state.executor.spawn_detached({ let kinesis_client = state.kinesis_client.clone(); let kinesis_stream = state.config.kinesis_stream.clone(); - let headers = response.headers.clone(); let model = model.clone(); - async move { SnowflakeRow::new( "Fireworks Completion Requested", @@ -501,7 +508,9 @@ async fn predict_edits( claims.system_id.clone(), json!({ "model": model.to_string(), - "headers": headers, + "headers": response.headers, + "usage": response.completion.usage, + "duration": duration.as_secs_f64(), }), ) .write(&kinesis_client, &kinesis_stream) @@ -510,11 +519,6 @@ async fn predict_edits( } }); - let choice = response - .completion - .choices - .pop() - .context("no output from completion response")?; Ok(Json(PredictEditsResponse { output_excerpt: choice.text, })) From 13405ed4a389dd9bd0a0b48afd6921c046e9cb13 Mon Sep 17 00:00:00 2001 From: Ozan Date: Mon, 13 Jan 2025 15:53:13 +0100 Subject: [PATCH 05/25] Add emacs keybindings for mark emulation (#22904) These keybindings extend the already selected text. This allows closer emacs emulation where subsequent movement commands extend / shrink the current selection instead of dismissing it. This is a follow up on - #21927 Release Notes: - Added emacs movement keybindings that extend/shrink the current selection --------- Co-authored-by: Peter Tripp --- assets/keymaps/linux/emacs.json | 61 +++++++++++++++++++++++++++++++-- assets/keymaps/macos/emacs.json | 61 +++++++++++++++++++++++++++++++-- 2 files changed, 118 insertions(+), 4 deletions(-) diff --git a/assets/keymaps/linux/emacs.json b/assets/keymaps/linux/emacs.json index 816e20ad787a6..0abdb08b263d5 100755 --- a/assets/keymaps/linux/emacs.json +++ b/assets/keymaps/linux/emacs.json @@ -15,7 +15,6 @@ "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line - //"ctrl-space": "editor::SetMark", "ctrl-f": "editor::MoveRight", // forward-char "ctrl-b": "editor::MoveLeft", // backward-char "ctrl-n": "editor::MoveDown", // next-line @@ -52,7 +51,65 @@ "alt->": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward - "alt-^": "editor::JoinLines" // join-line + "alt-^": "editor::JoinLines", // join-line + "alt-/": "editor::ShowCompletions" // dabbrev-expand + } + }, + // Extend selection with movement bindings + { + "context": "Editor && (mode == full) && selection", + "bindings": { + "right": "editor::SelectRight", + "left": "editor::SelectLeft", + "down": "editor::SelectDown", + "up": "editor::SelectUp", + "home": "editor::SelectToBeginningOfLine", + "end": "editor::SelectToEndOfLine", + "alt-left": "editor::SelectToPreviousWordStart", + "alt-right": "editor::SelectToNextWordEnd", + "pagedown": "editor::SelectPageDown", + "pageup": "editor::SelectPageUp", + "ctrl-f": "editor::SelectRight", + "ctrl-b": "editor::SelectLeft", + "ctrl-n": "editor::SelectDown", + "ctrl-p": "editor::SelectUp", + "ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-e": "editor::SelectToEndOfLine", + "alt-f": "editor::SelectToNextWordEnd", + "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-<": "editor::SelectToBeginning", + "alt->": "editor::SelectToEnd", + "ctrl-space": "editor::Cancel" // clear mark + } + }, + // Emacs set-mark-command emulation (ctrl-space + movement bindings) + { + "context": "Editor && (mode == full) && !selection", + "bindings": { + "ctrl-space right": "editor::SelectRight", + "ctrl-space left": "editor::SelectLeft", + "ctrl-space down": "editor::SelectDown", + "ctrl-space up": "editor::SelectUp", + "ctrl-space home": "editor::SelectToBeginningOfLine", + "ctrl-space end": "editor::SelectToEndOfLine", + "ctrl-space alt-left": "editor::SelectToPreviousWordStart", + "ctrl-space alt-right": "editor::SelectToNextWordEnd", + "ctrl-space pagedown": "editor::SelectPageDown", + "ctrl-space pageup": "editor::SelectPageUp", + "ctrl-space ctrl-f": "editor::SelectRight", + "ctrl-space ctrl-b": "editor::SelectLeft", + "ctrl-space ctrl-n": "editor::SelectDown", + "ctrl-space ctrl-p": "editor::SelectUp", + "ctrl-space ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-space ctrl-e": "editor::SelectToEndOfLine", + "ctrl-space alt-f": "editor::SelectToNextWordEnd", + "ctrl-space alt-b": "editor::SelectToPreviousWordStart", + "ctrl-space alt-<": "editor::SelectToBeginning", + "ctrl-space alt->": "editor::SelectToEnd", + "ctrl-space alt-v": "editor::SelectPageUp", + "ctrl-space ctrl-v": "editor::SelectPageDown", + "ctrl-space alt-{": "editor::SelectToStartOfParagraph", + "ctrl-space alt-}": "editor::SelectToEndOfParagraph" } }, { diff --git a/assets/keymaps/macos/emacs.json b/assets/keymaps/macos/emacs.json index 816e20ad787a6..0abdb08b263d5 100755 --- a/assets/keymaps/macos/emacs.json +++ b/assets/keymaps/macos/emacs.json @@ -15,7 +15,6 @@ "ctrl-x b": "tab_switcher::Toggle", // switch-to-buffer "alt-g g": "go_to_line::Toggle", // goto-line "alt-g alt-g": "go_to_line::Toggle", // goto-line - //"ctrl-space": "editor::SetMark", "ctrl-f": "editor::MoveRight", // forward-char "ctrl-b": "editor::MoveLeft", // backward-char "ctrl-n": "editor::MoveDown", // next-line @@ -52,7 +51,65 @@ "alt->": "editor::MoveToEnd", // end-of-buffer "ctrl-l": "editor::ScrollCursorCenterTopBottom", // recenter-top-bottom "ctrl-s": "buffer_search::Deploy", // isearch-forward - "alt-^": "editor::JoinLines" // join-line + "alt-^": "editor::JoinLines", // join-line + "alt-/": "editor::ShowCompletions" // dabbrev-expand + } + }, + // Extend selection with movement bindings + { + "context": "Editor && (mode == full) && selection", + "bindings": { + "right": "editor::SelectRight", + "left": "editor::SelectLeft", + "down": "editor::SelectDown", + "up": "editor::SelectUp", + "home": "editor::SelectToBeginningOfLine", + "end": "editor::SelectToEndOfLine", + "alt-left": "editor::SelectToPreviousWordStart", + "alt-right": "editor::SelectToNextWordEnd", + "pagedown": "editor::SelectPageDown", + "pageup": "editor::SelectPageUp", + "ctrl-f": "editor::SelectRight", + "ctrl-b": "editor::SelectLeft", + "ctrl-n": "editor::SelectDown", + "ctrl-p": "editor::SelectUp", + "ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-e": "editor::SelectToEndOfLine", + "alt-f": "editor::SelectToNextWordEnd", + "alt-b": "editor::SelectToPreviousSubwordStart", + "alt-<": "editor::SelectToBeginning", + "alt->": "editor::SelectToEnd", + "ctrl-space": "editor::Cancel" // clear mark + } + }, + // Emacs set-mark-command emulation (ctrl-space + movement bindings) + { + "context": "Editor && (mode == full) && !selection", + "bindings": { + "ctrl-space right": "editor::SelectRight", + "ctrl-space left": "editor::SelectLeft", + "ctrl-space down": "editor::SelectDown", + "ctrl-space up": "editor::SelectUp", + "ctrl-space home": "editor::SelectToBeginningOfLine", + "ctrl-space end": "editor::SelectToEndOfLine", + "ctrl-space alt-left": "editor::SelectToPreviousWordStart", + "ctrl-space alt-right": "editor::SelectToNextWordEnd", + "ctrl-space pagedown": "editor::SelectPageDown", + "ctrl-space pageup": "editor::SelectPageUp", + "ctrl-space ctrl-f": "editor::SelectRight", + "ctrl-space ctrl-b": "editor::SelectLeft", + "ctrl-space ctrl-n": "editor::SelectDown", + "ctrl-space ctrl-p": "editor::SelectUp", + "ctrl-space ctrl-a": "editor::SelectToBeginningOfLine", + "ctrl-space ctrl-e": "editor::SelectToEndOfLine", + "ctrl-space alt-f": "editor::SelectToNextWordEnd", + "ctrl-space alt-b": "editor::SelectToPreviousWordStart", + "ctrl-space alt-<": "editor::SelectToBeginning", + "ctrl-space alt->": "editor::SelectToEnd", + "ctrl-space alt-v": "editor::SelectPageUp", + "ctrl-space ctrl-v": "editor::SelectPageDown", + "ctrl-space alt-{": "editor::SelectToStartOfParagraph", + "ctrl-space alt-}": "editor::SelectToEndOfParagraph" } }, { From 7ed834bd79c652ea06ea85c3f41c231060d6f1be Mon Sep 17 00:00:00 2001 From: tims <0xtimsb@gmail.com> Date: Mon, 13 Jan 2025 20:41:45 +0530 Subject: [PATCH 06/25] terminal: Fix unresponsive buttons on load until center pane is clicked + Auto-focus docked terminal on load if no other item is focused (#23039) Closes #23006 This PR should have been split into two, but since the changes are related, I merged them into one. 1. On load, the title bar actions and bottom bar toggles are unresponsive until the center pane is clicked. This happens because the terminal captures focus (even if it's closed) long after the workspace sets focus to itself during loading. The issue was in the `focus_view` call used in the `new` method of `TerminalPanel`. Since new terminal views can be created behind the scenes (i.e., without the terminal being visible to the user), we shouldn't handle focus for the terminal in this case. Removing `focus_view` from the `new` method has no impact on the existing terminal focusing logic. I've tested scenarios such as creating new terminals, splitting terminals, zooming, etc., and everything works as expected. 2. Currently, on load, docked terminals do not automatically focus when they are only visible item to the user. This PR implements it. Before/After: 1. When only the dock terminal is visible on load. Terminal is focused. image image 2. When other items are visible along with the dock terminal on load. Editor is focused. image image 3. Multiple tabs along with split panes. Last terminal is focused. image image Future: When a docked terminal is in a zoomed state and Zed is loaded, we should prioritize focusing on the terminal over the active item (e.g., an editor) behind it. This hasn't been implemented in this PR because the zoomed state during the load function is stale. The correct state is received later via the workspace. I'm still investigating where exactly this should be handled, so this will be a separate PR. cc: @SomeoneToIgnore Release Notes: - Fixed unresponsive buttons on load until the center pane is clicked. - Added auto-focus for the docked terminal on load when no other item is focused. --- crates/terminal_view/src/persistence.rs | 11 +++++++---- crates/terminal_view/src/terminal_panel.rs | 22 ++++++++++++++++++++-- crates/workspace/src/workspace.rs | 13 +++++++++++++ 3 files changed, 40 insertions(+), 6 deletions(-) diff --git a/crates/terminal_view/src/persistence.rs b/crates/terminal_view/src/persistence.rs index a3bb2cc522b04..36b07bdd6dda9 100644 --- a/crates/terminal_view/src/persistence.rs +++ b/crates/terminal_view/src/persistence.rs @@ -146,13 +146,16 @@ fn populate_pane_items( cx: &mut ViewContext, ) { let mut item_index = pane.items_len(); + let mut active_item_index = None; for item in items { - let activate_item = Some(item.item_id().as_u64()) == active_item; + if Some(item.item_id().as_u64()) == active_item { + active_item_index = Some(item_index); + } pane.add_item(Box::new(item), false, false, None, cx); item_index += 1; - if activate_item { - pane.activate_item(item_index, false, false, cx); - } + } + if let Some(index) = active_item_index { + pane.activate_item(index, false, false, cx); } } diff --git a/crates/terminal_view/src/terminal_panel.rs b/crates/terminal_view/src/terminal_panel.rs index a250d3bcadce3..5f3b9d336aa4e 100644 --- a/crates/terminal_view/src/terminal_panel.rs +++ b/crates/terminal_view/src/terminal_panel.rs @@ -31,7 +31,7 @@ use ui::{ }; use util::{ResultExt, TryFutureExt}; use workspace::{ - dock::{DockPosition, Panel, PanelEvent}, + dock::{DockPosition, Panel, PanelEvent, PanelHandle}, item::SerializableItem, move_active_item, move_item, pane, ui::IconName, @@ -83,7 +83,6 @@ impl TerminalPanel { let project = workspace.project(); let pane = new_terminal_pane(workspace.weak_handle(), project.clone(), false, cx); let center = PaneGroup::new(pane.clone()); - cx.focus_view(&pane); let terminal_panel = Self { center, active_pane: pane, @@ -283,6 +282,25 @@ impl TerminalPanel { } } + if let Some(workspace) = workspace.upgrade() { + let should_focus = workspace + .update(&mut cx, |workspace, cx| { + workspace.active_item(cx).is_none() + && workspace.is_dock_at_position_open(terminal_panel.position(cx), cx) + }) + .unwrap_or(false); + + if should_focus { + terminal_panel + .update(&mut cx, |panel, cx| { + panel.active_pane.update(cx, |pane, cx| { + pane.focus_active_item(cx); + }); + }) + .ok(); + } + } + Ok(terminal_panel) } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index bc5d801e06a81..e4488650ec3b6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -2298,6 +2298,19 @@ impl Workspace { } } + pub fn is_dock_at_position_open( + &self, + position: DockPosition, + cx: &mut ViewContext, + ) -> bool { + let dock = match position { + DockPosition::Left => &self.left_dock, + DockPosition::Bottom => &self.bottom_dock, + DockPosition::Right => &self.right_dock, + }; + dock.read(cx).is_open() + } + pub fn toggle_dock(&mut self, dock_side: DockPosition, cx: &mut ViewContext) { let dock = match dock_side { DockPosition::Left => &self.left_dock, From 955248fee0a67411e03e39c27f16312cf7a15023 Mon Sep 17 00:00:00 2001 From: SkywardSyntax <87048477+SkywardSyntax@users.noreply.github.com> Date: Mon, 13 Jan 2025 09:32:26 -0600 Subject: [PATCH 07/25] copilot_chat: Rename `o1-preview` model to `o1` (#23038) https://github.blog/news-insights/openais-o1-model-available-in-copilot-chat-and-github-models/ Release Notes: - Renamed Github Copilot Chat "o1-preview" model to "o1". --------- Co-authored-by: Marshall Bowers --- crates/copilot/src/copilot_chat.rs | 14 +++++++------- .../language_models/src/provider/copilot_chat.rs | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/crates/copilot/src/copilot_chat.rs b/crates/copilot/src/copilot_chat.rs index da92bf25e21f9..4391f0a955684 100644 --- a/crates/copilot/src/copilot_chat.rs +++ b/crates/copilot/src/copilot_chat.rs @@ -34,8 +34,8 @@ pub enum Model { Gpt4, #[serde(alias = "gpt-3.5-turbo", rename = "gpt-3.5-turbo")] Gpt3_5Turbo, - #[serde(alias = "o1-preview", rename = "o1")] - O1Preview, + #[serde(alias = "o1", rename = "o1")] + O1, #[serde(alias = "o1-mini", rename = "o1-mini")] O1Mini, #[serde(alias = "claude-3-5-sonnet", rename = "claude-3.5-sonnet")] @@ -46,7 +46,7 @@ impl Model { pub fn uses_streaming(&self) -> bool { match self { Self::Gpt4o | Self::Gpt4 | Self::Gpt3_5Turbo | Self::Claude3_5Sonnet => true, - Self::O1Mini | Self::O1Preview => false, + Self::O1Mini | Self::O1 => false, } } @@ -55,7 +55,7 @@ impl Model { "gpt-4o" => Ok(Self::Gpt4o), "gpt-4" => Ok(Self::Gpt4), "gpt-3.5-turbo" => Ok(Self::Gpt3_5Turbo), - "o1-preview" => Ok(Self::O1Preview), + "o1" => Ok(Self::O1), "o1-mini" => Ok(Self::O1Mini), "claude-3-5-sonnet" => Ok(Self::Claude3_5Sonnet), _ => Err(anyhow!("Invalid model id: {}", id)), @@ -68,7 +68,7 @@ impl Model { Self::Gpt4 => "gpt-4", Self::Gpt4o => "gpt-4o", Self::O1Mini => "o1-mini", - Self::O1Preview => "o1-preview", + Self::O1 => "o1", Self::Claude3_5Sonnet => "claude-3-5-sonnet", } } @@ -79,7 +79,7 @@ impl Model { Self::Gpt4 => "GPT-4", Self::Gpt4o => "GPT-4o", Self::O1Mini => "o1-mini", - Self::O1Preview => "o1-preview", + Self::O1 => "o1", Self::Claude3_5Sonnet => "Claude 3.5 Sonnet", } } @@ -90,7 +90,7 @@ impl Model { Self::Gpt4 => 32768, Self::Gpt3_5Turbo => 12288, Self::O1Mini => 20000, - Self::O1Preview => 20000, + Self::O1 => 20000, Self::Claude3_5Sonnet => 200_000, } } diff --git a/crates/language_models/src/provider/copilot_chat.rs b/crates/language_models/src/provider/copilot_chat.rs index e35322d7552bc..3f0777a4f6353 100644 --- a/crates/language_models/src/provider/copilot_chat.rs +++ b/crates/language_models/src/provider/copilot_chat.rs @@ -179,7 +179,7 @@ impl LanguageModel for CopilotChatLanguageModel { CopilotChatModel::Gpt4o => open_ai::Model::FourOmni, CopilotChatModel::Gpt4 => open_ai::Model::Four, CopilotChatModel::Gpt3_5Turbo => open_ai::Model::ThreePointFiveTurbo, - CopilotChatModel::O1Preview | CopilotChatModel::O1Mini => open_ai::Model::Four, + CopilotChatModel::O1 | CopilotChatModel::O1Mini => open_ai::Model::Four, CopilotChatModel::Claude3_5Sonnet => unreachable!(), }; count_open_ai_tokens(request, model, cx) From 1c6dd03e50ba7a395303c7b557bd7463639de5d0 Mon Sep 17 00:00:00 2001 From: everdrone Date: Mon, 13 Jan 2025 17:07:04 +0100 Subject: [PATCH 08/25] Add Diagnostics key context (#23043) Closes #17337 Release Notes: - Add `Diagnostics` key context - Enables users to specify key bindings for that pane ```json { "context": "Diagnostics", "bindings": { "alt-q": "diagnostics::ToggleWarnings" } } ``` --- crates/diagnostics/src/diagnostics.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/crates/diagnostics/src/diagnostics.rs b/crates/diagnostics/src/diagnostics.rs index 43464310ed804..71f96e9f9bc31 100644 --- a/crates/diagnostics/src/diagnostics.rs +++ b/crates/diagnostics/src/diagnostics.rs @@ -95,6 +95,7 @@ impl Render for ProjectDiagnosticsEditor { fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { let child = if self.path_states.is_empty() { div() + .key_context("EmptyPane") .bg(cx.theme().colors().editor_background) .flex() .items_center() @@ -106,10 +107,8 @@ impl Render for ProjectDiagnosticsEditor { }; div() + .key_context("Diagnostics") .track_focus(&self.focus_handle(cx)) - .when(self.path_states.is_empty(), |el| { - el.key_context("EmptyPane") - }) .size_full() .on_action(cx.listener(Self::toggle_warnings)) .child(child) From 102e70816cf7c4e50b3d951cb812f33871db7437 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Mon, 13 Jan 2025 11:47:09 -0500 Subject: [PATCH 09/25] git: Git Panel UI, continued (#22960) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TODO: - [ ] Investigate incorrect hit target for `stage all` button - [ ] Add top level context menu - [ ] Add entry context menus - [x] Show paths in list view - [ ] For now, `enter` can just open the file - [ ] 🐞: Hover deadzone in list caused by scrollbar - [x] 🐞: Incorrect status/nothing shown when multiple worktrees are added --- This PR continues work on the feature flagged git panel. Changes: - Defines and wires up git panel actions & keybindings - Re-scopes some actions from `git_ui` -> `git`. - General git actions (StageAll, CommitChanges, ...) are scoped to `git`. - Git panel specific actions (Close, FocusCommitEditor, ...) are scoped to `git_panel. - Staging actions & UI are now connected to git! - Unify more reusable git status into the GitState global over being tied to the panel directly. - Uses the new git status codepaths instead of filtering all workspace entries Release Notes: - N/A --------- Co-authored-by: Cole Miller <53574922+cole-miller@users.noreply.github.com> Co-authored-by: Cole Miller --- Cargo.lock | 2 + assets/keymaps/default-macos.json | 32 + .../random_project_collaboration_tests.rs | 2 +- crates/editor/src/git/project_diff.rs | 3 +- crates/git/src/repository.rs | 73 +- crates/git/src/status.rs | 69 +- crates/git_ui/Cargo.toml | 2 + crates/git_ui/TODO.md | 45 - crates/git_ui/src/git_panel.rs | 1230 +++++++---------- crates/git_ui/src/git_ui.rs | 248 +++- .../ui/src/components/button/button_like.rs | 1 + crates/worktree/src/worktree.rs | 89 +- crates/worktree/src/worktree_tests.rs | 36 +- 13 files changed, 999 insertions(+), 833 deletions(-) delete mode 100644 crates/git_ui/TODO.md diff --git a/Cargo.lock b/Cargo.lock index fd93fbfeb350f..3531ac8877ac3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5113,6 +5113,7 @@ dependencies = [ "collections", "db", "editor", + "futures 0.3.31", "git", "gpui", "language", @@ -5123,6 +5124,7 @@ dependencies = [ "serde_derive", "serde_json", "settings", + "sum_tree", "theme", "ui", "util", diff --git a/assets/keymaps/default-macos.json b/assets/keymaps/default-macos.json index ac4b27f2b02b8..86fb9a8aa1708 100644 --- a/assets/keymaps/default-macos.json +++ b/assets/keymaps/default-macos.json @@ -682,6 +682,38 @@ "space": "project_panel::Open" } }, + { + "context": "GitPanel && !CommitEditor", + "use_key_equivalents": true, + "bindings": { + "escape": "git_panel::Close" + } + }, + { + "context": "GitPanel && ChangesList", + "use_key_equivalents": true, + "bindings": { + "up": "menu::SelectPrev", + "down": "menu::SelectNext", + "cmd-up": "menu::SelectFirst", + "cmd-down": "menu::SelectLast", + "enter": "menu::Confirm", + "space": "git::ToggleStaged", + "cmd-shift-space": "git::StageAll", + "ctrl-shift-space": "git::UnstageAll", + "alt-down": "git_panel::FocusEditor" + } + }, + { + "context": "GitPanel && CommitEditor > Editor", + "use_key_equivalents": true, + "bindings": { + "alt-up": "git_panel::FocusChanges", + "escape": "git_panel::FocusChanges", + "cmd-enter": "git::CommitChanges", + "cmd-alt-enter": "git::CommitAllChanges" + } + }, { "context": "CollabPanel && not_editing", "use_key_equivalents": true, diff --git a/crates/collab/src/tests/random_project_collaboration_tests.rs b/crates/collab/src/tests/random_project_collaboration_tests.rs index 8b8837fc19a85..23b380ddaaa3f 100644 --- a/crates/collab/src/tests/random_project_collaboration_tests.rs +++ b/crates/collab/src/tests/random_project_collaboration_tests.rs @@ -1221,7 +1221,7 @@ impl RandomizedTest for ProjectCollaborationTest { id, guest_project.remote_id(), ); - assert_eq!(guest_snapshot.repositories().collect::>(), host_snapshot.repositories().collect::>(), + assert_eq!(guest_snapshot.repositories().iter().collect::>(), host_snapshot.repositories().iter().collect::>(), "{} has different repositories than the host for worktree {:?} and project {:?}", client.username, host_snapshot.abs_path(), diff --git a/crates/editor/src/git/project_diff.rs b/crates/editor/src/git/project_diff.rs index f06841e4453ea..4acadad41e938 100644 --- a/crates/editor/src/git/project_diff.rs +++ b/crates/editor/src/git/project_diff.rs @@ -197,9 +197,10 @@ impl ProjectDiffEditor { let snapshot = worktree.read(cx).snapshot(); let applicable_entries = snapshot .repositories() + .iter() .flat_map(|entry| { entry.status().map(|git_entry| { - (git_entry.status, entry.join(git_entry.repo_path)) + (git_entry.combined_status(), entry.join(git_entry.repo_path)) }) }) .filter_map(|(status, path)| { diff --git a/crates/git/src/repository.rs b/crates/git/src/repository.rs index bb890150e578f..c5ce533026d9b 100644 --- a/crates/git/src/repository.rs +++ b/crates/git/src/repository.rs @@ -1,6 +1,7 @@ +use crate::status::GitStatusPair; use crate::GitHostingProviderRegistry; use crate::{blame::Blame, status::GitStatus}; -use anyhow::{Context, Result}; +use anyhow::{anyhow, Context, Result}; use collections::{HashMap, HashSet}; use git2::BranchType; use gpui::SharedString; @@ -15,6 +16,7 @@ use std::{ sync::Arc, }; use sum_tree::MapSeekTarget; +use util::command::new_std_command; use util::ResultExt; #[derive(Clone, Debug, Hash, PartialEq)] @@ -51,6 +53,8 @@ pub trait GitRepository: Send + Sync { /// Returns the path to the repository, typically the `.git` folder. fn dot_git_dir(&self) -> PathBuf; + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()>; } impl std::fmt::Debug for dyn GitRepository { @@ -152,7 +156,7 @@ impl GitRepository for RealGitRepository { Ok(_) => Ok(true), Err(e) => match e.code() { git2::ErrorCode::NotFound => Ok(false), - _ => Err(anyhow::anyhow!(e)), + _ => Err(anyhow!(e)), }, } } @@ -196,7 +200,7 @@ impl GitRepository for RealGitRepository { repo.set_head( revision .name() - .ok_or_else(|| anyhow::anyhow!("Branch name could not be retrieved"))?, + .ok_or_else(|| anyhow!("Branch name could not be retrieved"))?, )?; Ok(()) } @@ -228,6 +232,36 @@ impl GitRepository for RealGitRepository { self.hosting_provider_registry.clone(), ) } + + fn update_index(&self, stage: &[RepoPath], unstage: &[RepoPath]) -> Result<()> { + let working_directory = self + .repository + .lock() + .workdir() + .context("failed to read git work directory")? + .to_path_buf(); + if !stage.is_empty() { + let add = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["add", "--"]) + .args(stage.iter().map(|p| p.as_ref())) + .status()?; + if !add.success() { + return Err(anyhow!("Failed to stage files: {add}")); + } + } + if !unstage.is_empty() { + let rm = new_std_command(&self.git_binary_path) + .current_dir(&working_directory) + .args(["restore", "--staged", "--"]) + .args(unstage.iter().map(|p| p.as_ref())) + .status()?; + if !rm.success() { + return Err(anyhow!("Failed to unstage files: {rm}")); + } + } + Ok(()) + } } #[derive(Debug, Clone)] @@ -298,18 +332,24 @@ impl GitRepository for FakeGitRepository { let mut entries = state .worktree_statuses .iter() - .filter_map(|(repo_path, status)| { + .filter_map(|(repo_path, status_worktree)| { if path_prefixes .iter() .any(|path_prefix| repo_path.0.starts_with(path_prefix)) { - Some((repo_path.to_owned(), *status)) + Some(( + repo_path.to_owned(), + GitStatusPair { + index_status: None, + worktree_status: Some(*status_worktree), + }, + )) } else { None } }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(GitStatus { entries: entries.into(), @@ -363,6 +403,10 @@ impl GitRepository for FakeGitRepository { .with_context(|| format!("failed to get blame for {:?}", path)) .cloned() } + + fn update_index(&self, _stage: &[RepoPath], _unstage: &[RepoPath]) -> Result<()> { + unimplemented!() + } } fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { @@ -398,6 +442,7 @@ fn check_path_to_repo_path_errors(relative_file_path: &Path) -> Result<()> { pub enum GitFileStatus { Added, Modified, + // TODO conflicts should be represented by the GitStatusPair Conflict, Deleted, Untracked, @@ -426,6 +471,16 @@ impl GitFileStatus { _ => None, } } + + pub fn from_byte(byte: u8) -> Option { + match byte { + b'M' => Some(GitFileStatus::Modified), + b'A' => Some(GitFileStatus::Added), + b'D' => Some(GitFileStatus::Deleted), + b'?' => Some(GitFileStatus::Untracked), + _ => None, + } + } } pub static WORK_DIRECTORY_REPO_PATH: LazyLock = @@ -453,6 +508,12 @@ impl RepoPath { } } +impl std::fmt::Display for RepoPath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.0.to_string_lossy().fmt(f) + } +} + impl From<&Path> for RepoPath { fn from(value: &Path) -> Self { RepoPath::new(value.into()) diff --git a/crates/git/src/status.rs b/crates/git/src/status.rs index 0d62cfaae9df5..de574f5d2121a 100644 --- a/crates/git/src/status.rs +++ b/crates/git/src/status.rs @@ -2,9 +2,33 @@ use crate::repository::{GitFileStatus, RepoPath}; use anyhow::{anyhow, Result}; use std::{path::Path, process::Stdio, sync::Arc}; +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct GitStatusPair { + // Not both `None`. + pub index_status: Option, + pub worktree_status: Option, +} + +impl GitStatusPair { + pub fn is_staged(&self) -> Option { + match (self.index_status, self.worktree_status) { + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + (Some(GitFileStatus::Untracked), Some(GitFileStatus::Untracked)) => Some(false), + (Some(_), Some(_)) => None, + (None, None) => unreachable!(), + } + } + + // TODO reconsider uses of this + pub fn combined(&self) -> GitFileStatus { + self.index_status.or(self.worktree_status).unwrap() + } +} + #[derive(Clone)] pub struct GitStatus { - pub entries: Arc<[(RepoPath, GitFileStatus)]>, + pub entries: Arc<[(RepoPath, GitStatusPair)]>, } impl GitStatus { @@ -20,6 +44,7 @@ impl GitStatus { "status", "--porcelain=v1", "--untracked-files=all", + "--no-renames", "-z", ]) .args(path_prefixes.iter().map(|path_prefix| { @@ -47,36 +72,32 @@ impl GitStatus { let mut entries = stdout .split('\0') .filter_map(|entry| { - if entry.is_char_boundary(3) { - let (status, path) = entry.split_at(3); - let status = status.trim(); - Some(( - RepoPath(Path::new(path).into()), - match status { - "A" => GitFileStatus::Added, - "M" => GitFileStatus::Modified, - "D" => GitFileStatus::Deleted, - "??" => GitFileStatus::Untracked, - _ => return None, - }, - )) - } else { - None + let sep = entry.get(2..3)?; + if sep != " " { + return None; + }; + let path = &entry[3..]; + let status = entry[0..2].as_bytes(); + let index_status = GitFileStatus::from_byte(status[0]); + let worktree_status = GitFileStatus::from_byte(status[1]); + if (index_status, worktree_status) == (None, None) { + return None; } + let path = RepoPath(Path::new(path).into()); + Some(( + path, + GitStatusPair { + index_status, + worktree_status, + }, + )) }) .collect::>(); - entries.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + entries.sort_unstable_by(|(a, _), (b, _)| a.cmp(&b)); Ok(Self { entries: entries.into(), }) } - - pub fn get(&self, path: &Path) -> Option { - self.entries - .binary_search_by(|(repo_path, _)| repo_path.0.as_ref().cmp(path)) - .ok() - .map(|index| self.entries[index].1) - } } impl Default for GitStatus { diff --git a/crates/git_ui/Cargo.toml b/crates/git_ui/Cargo.toml index 120ca92857a04..0c357cb436f05 100644 --- a/crates/git_ui/Cargo.toml +++ b/crates/git_ui/Cargo.toml @@ -17,6 +17,7 @@ anyhow.workspace = true collections.workspace = true db.workspace = true editor.workspace = true +futures.workspace = true git.workspace = true gpui.workspace = true language.workspace = true @@ -27,6 +28,7 @@ serde.workspace = true serde_derive.workspace = true serde_json.workspace = true settings.workspace = true +sum_tree.workspace = true theme.workspace = true ui.workspace = true util.workspace = true diff --git a/crates/git_ui/TODO.md b/crates/git_ui/TODO.md deleted file mode 100644 index efbdcf494cc38..0000000000000 --- a/crates/git_ui/TODO.md +++ /dev/null @@ -1,45 +0,0 @@ -### General - -- [x] Disable staging and committing actions for read-only projects - -### List - -- [x] Add uniform list -- [x] Git status item -- [ ] Directory item -- [x] Scrollbar -- [ ] Add indent size setting -- [ ] Add tree settings - -### List Items - -- [x] Checkbox for staging -- [x] Git status icon -- [ ] Context menu - - [ ] Discard Changes - - --- - - [ ] Ignore - - [ ] Ignore directory - - --- - - [ ] Copy path - - [ ] Copy relative path - - --- - - [ ] Reveal in Finder - -### Commit Editor - -- [ ] Add commit editor -- [ ] Add commit message placeholder & add commit message to store -- [ ] Add a way to get the current collaborators & automatically add them to the commit message as co-authors -- [ ] Add action to clear commit message -- [x] Swap commit button between "Commit" and "Commit All" based on modifier key - -### Component Updates - -- [ ] ChangedLineCount (new) - - takes `lines_added: usize, lines_removed: usize`, returns a added/removed badge -- [x] GitStatusIcon (new) -- [ ] Checkbox - - update checkbox design -- [ ] ScrollIndicator - - shows a gradient overlay when more content is available to be scrolled diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index e57145f98880b..c6ff3fe4952f7 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -1,31 +1,21 @@ +use crate::{first_repository_in_project, first_worktree_repository}; use crate::{ - git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitStagedChanges, GitState, - RevertAll, StageAll, UnstageAll, + git_status_icon, settings::GitPanelSettings, CommitAllChanges, CommitChanges, GitState, + GitViewMode, RevertAll, StageAll, ToggleStaged, UnstageAll, }; use anyhow::{Context as _, Result}; use db::kvp::KEY_VALUE_STORE; use editor::Editor; -use git::{ - diff::DiffHunk, - repository::{GitFileStatus, RepoPath}, -}; +use git::repository::{GitFileStatus, RepoPath}; +use git::status::GitStatusPair; use gpui::*; use language::Buffer; -use menu::{SelectNext, SelectPrev}; -use project::{EntryKind, Fs, Project, ProjectEntryId, WorktreeId}; +use menu::{SelectFirst, SelectLast, SelectNext, SelectPrev}; +use project::{Fs, Project}; use serde::{Deserialize, Serialize}; use settings::Settings as _; -use std::{ - cell::OnceCell, - collections::HashSet, - ffi::OsStr, - ops::{Deref, Range}, - path::PathBuf, - rc::Rc, - sync::Arc, - time::Duration, - usize, -}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::{collections::HashSet, ops::Range, path::PathBuf, sync::Arc, time::Duration, usize}; use theme::ThemeSettings; use ui::{ prelude::*, Checkbox, Divider, DividerColor, ElevationIndex, Scrollbar, ScrollbarState, Tooltip, @@ -35,9 +25,18 @@ use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, }; -use worktree::StatusEntry; -actions!(git_panel, [ToggleFocus, OpenEntryMenu]); +actions!( + git_panel, + [ + Close, + ToggleFocus, + OpenMenu, + OpenSelected, + FocusEditor, + FocusChanges + ] +); const GIT_PANEL_KEY: &str = "GitPanel"; @@ -59,35 +58,21 @@ pub enum Event { Focus, } -#[derive(Default, Debug, PartialEq, Eq, Clone)] -pub enum ViewMode { - #[default] - List, - Tree, +#[derive(Serialize, Deserialize)] +struct SerializedGitPanel { + width: Option, } -pub struct GitStatusEntry {} - #[derive(Debug, PartialEq, Eq, Clone)] -struct EntryDetails { - filename: String, - display_name: String, - path: RepoPath, - kind: EntryKind, +pub struct GitListEntry { depth: usize, - is_expanded: bool, - status: Option, - hunks: Rc>>, - index: usize, -} - -#[derive(Serialize, Deserialize)] -struct SerializedGitPanel { - width: Option, + display_name: String, + repo_path: RepoPath, + status: GitStatusPair, + toggle_state: ToggleState, } pub struct GitPanel { - // workspace: WeakView, current_modifiers: Modifiers, focus_handle: FocusHandle, fs: Arc, @@ -96,53 +81,25 @@ pub struct GitPanel { project: Model, scroll_handle: UniformListScrollHandle, scrollbar_state: ScrollbarState, - selected_item: Option, - view_mode: ViewMode, + selected_entry: Option, show_scrollbar: bool, - // TODO Reintroduce expanded directories, once we're deriving directories from paths - // expanded_dir_ids: HashMap>, + rebuild_requested: Arc, git_state: Model, commit_editor: View, - // The entries that are currently shown in the panel, aka - // not hidden by folding or such - visible_entries: Vec, + /// The visible entries in the list, accounting for folding & expanded state. + /// + /// At this point it doesn't matter what repository the entry belongs to, + /// as only one repositories' entries are visible in the list at a time. + visible_entries: Vec, width: Option, - // git_diff_editor: Option>, - // git_diff_editor_updates: Task<()>, reveal_in_editor: Task<()>, } -#[derive(Debug, Clone)] -struct WorktreeEntries { - worktree_id: WorktreeId, - // TODO support multiple repositories per worktree - // work_directory: worktree::WorkDirectory, - visible_entries: Vec, - paths: Rc>>, -} - -#[derive(Debug, Clone)] -struct GitPanelEntry { - entry: worktree::StatusEntry, - hunks: Rc>>, -} - -impl Deref for GitPanelEntry { - type Target = worktree::StatusEntry; - - fn deref(&self) -> &Self::Target { - &self.entry - } -} - -impl WorktreeEntries { - fn paths(&self) -> &HashSet { - self.paths.get_or_init(|| { - self.visible_entries - .iter() - .map(|e| (e.entry.repo_path.clone())) - .collect() - }) +fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState { + match status.is_staged() { + Some(true) => ToggleState::Selected, + Some(false) => ToggleState::Unselected, + None => ToggleState::Indeterminate, } } @@ -155,12 +112,14 @@ impl GitPanel { } pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> View { - let git_state = GitState::get_global(cx); - let fs = workspace.app_state().fs.clone(); - // let weak_workspace = workspace.weak_handle(); let project = workspace.project().clone(); let language_registry = workspace.app_state().languages.clone(); + let git_state = GitState::get_global(cx); + let current_commit_message = { + let state = git_state.read(cx); + state.commit_message.clone() + }; let git_panel = cx.new_view(|cx: &mut ViewContext| { let focus_handle = cx.focus_handle(); @@ -169,36 +128,103 @@ impl GitPanel { this.hide_scrollbar(cx); }) .detach(); - cx.subscribe(&project, |this, _, event, cx| match event { - project::Event::WorktreeRemoved(_id) => { - // this.expanded_dir_ids.remove(id); - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeOrderChanged => { - this.update_visible_entries(None, None, cx); - cx.notify(); - } - project::Event::WorktreeUpdatedEntries(id, _) - | project::Event::WorktreeAdded(id) - | project::Event::WorktreeUpdatedGitRepositories(id) => { - this.update_visible_entries(Some(*id), None, cx); - cx.notify(); - } - project::Event::Closed => { - // this.git_diff_editor_updates = Task::ready(()); - this.reveal_in_editor = Task::ready(()); - // this.expanded_dir_ids.clear(); - this.visible_entries.clear(); - // this.git_diff_editor = None; - } - _ => {} + cx.subscribe(&project, move |this, project, event, cx| { + use project::Event; + + let first_worktree_id = project.read(cx).worktrees(cx).next().map(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + snapshot.id() + }); + let first_repo_in_project = first_repository_in_project(&project, cx); + + // TODO: Don't get another git_state here + // was running into a borrow issue + let git_state = GitState::get_global(cx); + + match event { + project::Event::WorktreeRemoved(id) => { + git_state.update(cx, |state, _| { + state.all_repositories.remove(id); + let Some((worktree_id, _, _)) = state.active_repository.as_ref() else { + return; + }; + if worktree_id == id { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeOrderChanged => { + // activate the new first worktree if the first was moved + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + Event::WorktreeAdded(id) => { + git_state.update(cx, |state, cx| { + let Some(worktree) = project.read(cx).worktree_for_id(*id, cx) else { + return; + }; + let snapshot = worktree.read(cx).snapshot(); + state + .all_repositories + .insert(*id, snapshot.repositories().clone()); + }); + let Some(first_id) = first_worktree_id else { + return; + }; + git_state.update(cx, |state, _| { + if !state + .active_repository + .as_ref() + .is_some_and(|(id, _, _)| id == &first_id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedEntries(id, _) => { + git_state.update(cx, |state, _| { + if state + .active_repository + .as_ref() + .is_some_and(|(active_id, _, _)| active_id == id) + { + state.active_repository = first_repo_in_project; + this.schedule_update(); + } + }); + } + project::Event::WorktreeUpdatedGitRepositories(_) => { + let Some(first) = first_repo_in_project else { + return; + }; + git_state.update(cx, |state, _| { + state.active_repository = Some(first); + this.schedule_update(); + }); + } + project::Event::Closed => { + this.reveal_in_editor = Task::ready(()); + this.visible_entries.clear(); + // TODO cancel/clear task? + } + _ => {} + }; }) .detach(); - let state = git_state.read(cx); - let current_commit_message = state.commit_message.clone(); - let commit_editor = cx.new_view(|cx| { let theme = ThemeSettings::get_global(cx); @@ -220,7 +246,6 @@ impl GitPanel { } else { commit_editor.set_text("", cx); } - // commit_editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx); commit_editor.set_use_autoclose(false); commit_editor.set_show_gutter(false, cx); commit_editor.set_show_wrap_guides(false, cx); @@ -250,29 +275,59 @@ impl GitPanel { let scroll_handle = UniformListScrollHandle::new(); + git_state.update(cx, |state, cx| { + let mut visible_worktrees = project.read(cx).visible_worktrees(cx); + let Some(first_worktree) = visible_worktrees.next() else { + return; + }; + drop(visible_worktrees); + let snapshot = first_worktree.read(cx).snapshot(); + + if let Some((repo, git_repo)) = + first_worktree_repository(&project, snapshot.id(), cx) + { + state.activate_repository(snapshot.id(), repo, git_repo); + } + }); + + let rebuild_requested = Arc::new(AtomicBool::new(false)); + let flag = rebuild_requested.clone(); + let handle = cx.view().downgrade(); + cx.spawn(|_, mut cx| async move { + loop { + cx.background_executor().timer(UPDATE_DEBOUNCE).await; + if flag.load(Ordering::Relaxed) { + if let Some(this) = handle.upgrade() { + this.update(&mut cx, |this, cx| { + this.update_visible_entries(cx); + }) + .ok(); + } + flag.store(false, Ordering::Relaxed); + } + } + }) + .detach(); + let mut git_panel = Self { - // workspace: weak_workspace, focus_handle: cx.focus_handle(), fs, pending_serialization: Task::ready(None), visible_entries: Vec::new(), current_modifiers: cx.modifiers(), - // expanded_dir_ids: Default::default(), width: Some(px(360.)), scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()), scroll_handle, - selected_item: None, - view_mode: ViewMode::default(), + selected_entry: None, show_scrollbar: !Self::should_autohide_scrollbar(cx), hide_scrollbar_task: None, - // git_diff_editor: Some(diff_display_editor(cx)), - // git_diff_editor_updates: Task::ready(()), + rebuild_requested, commit_editor, git_state, reveal_in_editor: Task::ready(()), project, }; - git_panel.update_visible_entries(None, None, cx); + git_panel.schedule_update(); git_panel }); @@ -280,6 +335,7 @@ impl GitPanel { } fn serialize(&mut self, cx: &mut ViewContext) { + // TODO: we can store stage status here let width = self.width; self.pending_serialization = cx.background_executor().spawn( async move { @@ -295,14 +351,31 @@ impl GitPanel { ); } - fn dispatch_context(&self) -> KeyContext { + fn dispatch_context(&self, cx: &ViewContext) -> KeyContext { let mut dispatch_context = KeyContext::new_with_defaults(); dispatch_context.add("GitPanel"); - dispatch_context.add("menu"); + + if self.is_focused(cx) { + dispatch_context.add("menu"); + dispatch_context.add("ChangesList"); + } + + if self.commit_editor.read(cx).is_focused(cx) { + dispatch_context.add("CommitEditor"); + } dispatch_context } + fn is_focused(&self, cx: &ViewContext) -> bool { + cx.focused() + .map_or(false, |focused| self.focus_handle == focused) + } + + fn close_panel(&mut self, _: &Close, cx: &mut ViewContext) { + cx.emit(PanelEvent::Close); + } + fn focus_in(&mut self, cx: &mut ViewContext) { if !self.focus_handle.contains_focused(cx) { cx.emit(Event::Focus); @@ -347,119 +420,195 @@ impl GitPanel { } fn calculate_depth_and_difference( - entry: &StatusEntry, - visible_worktree_entries: &HashSet, + repo_path: &RepoPath, + visible_entries: &HashSet, ) -> (usize, usize) { - let (depth, difference) = entry - .repo_path - .ancestors() - .skip(1) // Skip the entry itself - .find_map(|ancestor| { - if let Some(parent_entry) = visible_worktree_entries.get(ancestor) { - let entry_path_components_count = entry.repo_path.components().count(); - let parent_path_components_count = parent_entry.components().count(); - let difference = entry_path_components_count - parent_path_components_count; - let depth = parent_entry - .ancestors() - .skip(1) - .filter(|ancestor| visible_worktree_entries.contains(*ancestor)) - .count(); - Some((depth + 1, difference)) - } else { - None - } - }) - .unwrap_or((0, 0)); + let ancestors = repo_path.ancestors().skip(1); + for ancestor in ancestors { + if let Some(parent_entry) = visible_entries.get(ancestor) { + let entry_component_count = repo_path.components().count(); + let parent_component_count = parent_entry.components().count(); + + let difference = entry_component_count - parent_component_count; + + let parent_depth = parent_entry + .ancestors() + .skip(1) // Skip the parent itself + .filter(|ancestor| visible_entries.contains(*ancestor)) + .count(); - (depth, difference) + return (parent_depth + 1, difference); + } + } + + (0, 0) } - fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); + fn scroll_to_selected_entry(&mut self, cx: &mut ViewContext) { + if let Some(selected_entry) = self.selected_entry { + self.scroll_handle + .scroll_to_item(selected_entry, ScrollStrategy::Center); + } + + cx.notify(); + } + + fn select_first(&mut self, _: &SelectFirst, cx: &mut ViewContext) { + if self.visible_entries.first().is_some() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + } + } + + fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { + let item_count = self.visible_entries.len(); if item_count == 0 { return; } - let selection = match self.selected_item { - Some(i) => { - if i < item_count - 1 { - self.selected_item = Some(i + 1); - i + 1 - } else { - self.selected_item = Some(0); - 0 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); - }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry > 0 { + selected_entry - 1 + } else { + self.selected_entry = Some(item_count - 1); + item_count - 1 + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); } cx.notify(); } - fn select_prev(&mut self, _: &SelectPrev, cx: &mut ViewContext) { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum::(); + fn select_next(&mut self, _: &SelectNext, cx: &mut ViewContext) { + let item_count = self.visible_entries.len(); if item_count == 0 { return; } - let selection = match self.selected_item { - Some(i) => { - if i > 0 { - self.selected_item = Some(i - 1); - i - 1 - } else { - self.selected_item = Some(item_count - 1); - item_count - 1 - } - } - None => { - self.selected_item = Some(0); - 0 - } - }; - self.scroll_handle - .scroll_to_item(selection, ScrollStrategy::Center); - let mut hunks = None; - self.for_each_visible_entry(selection..selection + 1, cx, |_, entry, _| { - hunks = Some(entry.hunks.clone()); + if let Some(selected_entry) = self.selected_entry { + let new_selected_entry = if selected_entry < item_count - 1 { + selected_entry + 1 + } else { + selected_entry + }; + + self.selected_entry = Some(new_selected_entry); + + self.scroll_to_selected_entry(cx); + } + + cx.notify(); + } + + fn select_last(&mut self, _: &SelectLast, cx: &mut ViewContext) { + if self.visible_entries.last().is_some() { + self.selected_entry = Some(self.visible_entries.len() - 1); + self.scroll_to_selected_entry(cx); + } + } + + fn focus_editor(&mut self, _: &FocusEditor, cx: &mut ViewContext) { + self.commit_editor.update(cx, |editor, cx| { + editor.focus(cx); }); - if let Some(hunks) = hunks { - self.reveal_entry_in_git_editor(hunks, false, Some(UPDATE_DEBOUNCE), cx); + cx.notify(); + } + + fn select_first_entry(&mut self, cx: &mut ViewContext) { + if !self.no_entries() && self.selected_entry.is_none() { + self.selected_entry = Some(0); + self.scroll_to_selected_entry(cx); + cx.notify(); } + } + + fn focus_changes_list(&mut self, _: &FocusChanges, cx: &mut ViewContext) { + self.select_first_entry(cx); + cx.focus_self(); cx.notify(); } -} -impl GitPanel { - fn stage_all(&mut self, _: &StageAll, _cx: &mut ViewContext) { - // TODO: Implement stage all - println!("Stage all triggered"); + fn get_selected_entry(&self) -> Option<&GitListEntry> { + self.selected_entry + .and_then(|i| self.visible_entries.get(i)) + } + + fn toggle_staged_for_entry(&self, entry: &GitListEntry, cx: &mut ViewContext) { + self.git_state + .clone() + .update(cx, |state, _| match entry.status.is_staged() { + Some(true) | None => state.unstage_entry(entry.repo_path.clone()), + Some(false) => state.stage_entry(entry.repo_path.clone()), + }); + cx.notify(); + } + + fn toggle_staged_for_selected(&mut self, _: &ToggleStaged, cx: &mut ViewContext) { + if let Some(selected_entry) = self.get_selected_entry() { + self.toggle_staged_for_entry(&selected_entry, cx); + } + } + + fn open_selected(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { + println!("Open Selected triggered!"); + let selected_entry = self.selected_entry; + + if let Some(entry) = selected_entry.and_then(|i| self.visible_entries.get(i)) { + self.open_entry(entry); + + cx.notify(); + } + } + + fn open_entry(&self, entry: &GitListEntry) { + // TODO: Open entry or entry's changes. + println!("Open {} triggered!", entry.repo_path); + + // cx.emit(project_panel::Event::OpenedEntry { + // entry_id, + // focus_opened_item, + // allow_preview, + // }); + // + // workspace + // .open_path_preview( + // ProjectPath { + // worktree_id, + // path: file_path.clone(), + // }, + // None, + // focus_opened_item, + // allow_preview, + // cx, + // ) + // .detach_and_prompt_err("Failed to open file", cx, move |e, _| { + // match e.error_code() { + // ErrorCode::Disconnected => if is_via_ssh { + // Some("Disconnected from SSH host".to_string()) + // } else { + // Some("Disconnected from remote project".to_string()) + // }, + // ErrorCode::UnsharedItem => Some(format!( + // "{} is not shared by the host. This could be because it has been marked as `private`", + // file_path.display() + // )), + // _ => None, + // } + // }); } - fn unstage_all(&mut self, _: &UnstageAll, _cx: &mut ViewContext) { - // TODO: Implement unstage all - println!("Unstage all triggered"); + fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext) { + self.git_state.update(cx, |state, _| state.stage_all()); + } + + fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext) { + self.git_state.update(cx, |state, _| { + state.unstage_all(); + }); } fn discard_all(&mut self, _: &RevertAll, _cx: &mut ViewContext) { @@ -468,14 +617,14 @@ impl GitPanel { } fn clear_message(&mut self, cx: &mut ViewContext) { - let git_state = self.git_state.clone(); - git_state.update(cx, |state, _cx| state.clear_message()); + self.git_state + .update(cx, |state, _cx| state.clear_commit_message()); self.commit_editor .update(cx, |editor, cx| editor.set_text("", cx)); } /// Commit all staged changes - fn commit_staged_changes(&mut self, _: &CommitStagedChanges, cx: &mut ViewContext) { + fn commit_changes(&mut self, _: &CommitChanges, cx: &mut ViewContext) { self.clear_message(cx); // TODO: Implement commit all staged @@ -500,345 +649,100 @@ impl GitPanel { } fn entry_count(&self) -> usize { - self.visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum() + self.visible_entries.len() } fn for_each_visible_entry( &self, range: Range, cx: &mut ViewContext, - mut callback: impl FnMut(usize, EntryDetails, &mut ViewContext), + mut callback: impl FnMut(usize, GitListEntry, &mut ViewContext), ) { - let mut ix = 0; - for worktree_entries in &self.visible_entries { - if ix >= range.end { - return; - } + let visible_entries = &self.visible_entries; - if ix + worktree_entries.visible_entries.len() <= range.start { - ix += worktree_entries.visible_entries.len(); - continue; - } + for (ix, entry) in visible_entries + .iter() + .enumerate() + .skip(range.start) + .take(range.end - range.start) + { + let status = entry.status.clone(); + let filename = entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| entry.repo_path.to_string_lossy().into_owned()); + + let details = GitListEntry { + repo_path: entry.repo_path.clone(), + status, + depth: 0, + display_name: filename, + toggle_state: entry.toggle_state, + }; - let end_ix = range.end.min(ix + worktree_entries.visible_entries.len()); - // let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - if let Some(worktree) = self - .project - .read(cx) - .worktree_for_id(worktree_entries.worktree_id, cx) - { - let snapshot = worktree.read(cx).snapshot(); - let root_name = OsStr::new(snapshot.root_name()); - // let expanded_entry_ids = self - // .expanded_dir_ids - // .get(&snapshot.id()) - // .map(Vec::as_slice) - // .unwrap_or(&[]); - - let entry_range = range.start.saturating_sub(ix)..end_ix - ix; - let entries = worktree_entries.paths(); - - let index_start = entry_range.start; - for (i, entry) in worktree_entries.visible_entries[entry_range] - .iter() - .enumerate() - { - let index = index_start + i; - let status = entry.status; - let is_expanded = true; //expanded_entry_ids.binary_search(&entry.id).is_ok(); - - let (depth, difference) = Self::calculate_depth_and_difference(entry, entries); - - let filename = match difference { - diff if diff > 1 => entry - .repo_path - .iter() - .skip(entry.repo_path.components().count() - diff) - .collect::() - .to_str() - .unwrap_or_default() - .to_string(), - _ => entry - .repo_path - .file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| root_name.to_string_lossy().to_string()), - }; - - let details = EntryDetails { - filename, - display_name: entry.repo_path.to_string_lossy().into_owned(), - // TODO get it from StatusEntry? - kind: EntryKind::File, - is_expanded, - path: entry.repo_path.clone(), - status: Some(status), - hunks: entry.hunks.clone(), - depth, - index, - }; - callback(ix, details, cx); - } - } - ix = end_ix; + callback(ix, details, cx); } } - // TODO: Update expanded directory state - // TODO: Updates happen in the main loop, could be long for large workspaces + fn schedule_update(&mut self) { + self.rebuild_requested.store(true, Ordering::Relaxed); + } + #[track_caller] - fn update_visible_entries( - &mut self, - for_worktree: Option, - _new_selected_entry: Option<(WorktreeId, ProjectEntryId)>, - cx: &mut ViewContext, - ) { - let project = self.project.read(cx); - let mut old_entries_removed = false; - let mut after_update = Vec::new(); - self.visible_entries - .retain(|worktree_entries| match for_worktree { - Some(for_worktree) => { - if worktree_entries.worktree_id == for_worktree { - old_entries_removed = true; - false - } else if old_entries_removed { - after_update.push(worktree_entries.clone()); - false - } else { - true - } - } - None => false, - }); - for worktree in project.visible_worktrees(cx) { - let snapshot = worktree.read(cx).snapshot(); - let worktree_id = snapshot.id(); + fn update_visible_entries(&mut self, cx: &mut ViewContext) { + let git_state = self.git_state.read(cx); - if for_worktree.is_some() && for_worktree != Some(worktree_id) { - continue; - } + self.visible_entries.clear(); - let mut visible_worktree_entries = Vec::new(); - // Only use the first repository for now - let repositories = snapshot.repositories().take(1); - // let mut work_directory = None; - for repository in repositories { - visible_worktree_entries.extend(repository.status()); - // work_directory = Some(worktree::WorkDirectory::clone(repository)); - } + let Some((_, repo, _)) = git_state.active_repository().as_ref() else { + // Just clear entries if no repository is active. + cx.notify(); + return; + }; - // TODO use the GitTraversal - // let mut visible_worktree_entries = snapshot - // .entries(false, 0) - // .filter(|entry| !entry.is_external) - // .filter(|entry| entry.git_status.is_some()) - // .cloned() - // .collect::>(); - // snapshot.propagate_git_statuses(&mut visible_worktree_entries); - // project::sort_worktree_entries(&mut visible_worktree_entries); - - if !visible_worktree_entries.is_empty() { - self.visible_entries.push(WorktreeEntries { - worktree_id, - // work_directory: work_directory.unwrap(), - visible_entries: visible_worktree_entries - .into_iter() - .map(|entry| GitPanelEntry { - entry, - hunks: Rc::default(), - }) - .collect(), - paths: Rc::default(), - }); - } + // First pass - collect all paths + let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); + + // Second pass - create entries with proper depth calculation + for entry in repo.status() { + let (depth, difference) = + Self::calculate_depth_and_difference(&entry.repo_path, &path_set); + let toggle_state = status_to_toggle_state(&entry.status); + + let display_name = if difference > 1 { + // Show partial path for deeply nested files + entry + .repo_path + .as_ref() + .iter() + .skip(entry.repo_path.components().count() - difference) + .collect::() + .to_string_lossy() + .into_owned() + } else { + // Just show filename + entry + .repo_path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_default() + }; + + let entry = GitListEntry { + depth, + display_name, + repo_path: entry.repo_path, + status: entry.status, + toggle_state, + }; + + self.visible_entries.push(entry); } - self.visible_entries.extend(after_update); - - // TODO re-implement this - // if let Some((worktree_id, entry_id)) = new_selected_entry { - // self.selected_item = self.visible_entries.iter().enumerate().find_map( - // |(worktree_index, worktree_entries)| { - // if worktree_entries.worktree_id == worktree_id { - // worktree_entries - // .visible_entries - // .iter() - // .position(|entry| entry.id == entry_id) - // .map(|entry_index| { - // worktree_index * worktree_entries.visible_entries.len() - // + entry_index - // }) - // } else { - // None - // } - // }, - // ); - // } - - // let project = self.project.downgrade(); - // self.git_diff_editor_updates = cx.spawn(|git_panel, mut cx| async move { - // cx.background_executor() - // .timer(UPDATE_DEBOUNCE) - // .await; - // let Some(project_buffers) = git_panel - // .update(&mut cx, |git_panel, cx| { - // futures::future::join_all(git_panel.visible_entries.iter_mut().flat_map( - // |worktree_entries| { - // worktree_entries - // .visible_entries - // .iter() - // .filter_map(|entry| { - // let git_status = entry.status; - // let entry_hunks = entry.hunks.clone(); - // let (entry_path, unstaged_changes_task) = - // project.update(cx, |project, cx| { - // let entry_path = ProjectPath { - // worktree_id: worktree_entries.worktree_id, - // path: worktree_entries.work_directory.unrelativize(&entry.repo_path)?, - // }; - // let open_task = - // project.open_path(entry_path.clone(), cx); - // let unstaged_changes_task = - // cx.spawn(|project, mut cx| async move { - // let (_, opened_model) = open_task - // .await - // .context("opening buffer")?; - // let buffer = opened_model - // .downcast::() - // .map_err(|_| { - // anyhow::anyhow!( - // "accessing buffer for entry" - // ) - // })?; - // // TODO added files have noop changes and those are not expanded properly in the multi buffer - // let unstaged_changes = project - // .update(&mut cx, |project, cx| { - // project.open_unstaged_changes( - // buffer.clone(), - // cx, - // ) - // })? - // .await - // .context("opening unstaged changes")?; - - // let hunks = cx.update(|cx| { - // entry_hunks - // .get_or_init(|| { - // match git_status { - // GitFileStatus::Added => { - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let entire_buffer_range = - // buffer_snapshot.anchor_after(0) - // ..buffer_snapshot - // .anchor_before( - // buffer_snapshot.len(), - // ); - // let entire_buffer_point_range = - // entire_buffer_range - // .clone() - // .to_point(&buffer_snapshot); - - // vec![DiffHunk { - // row_range: entire_buffer_point_range - // .start - // .row - // ..entire_buffer_point_range - // .end - // .row, - // buffer_range: entire_buffer_range, - // diff_base_byte_range: 0..0, - // }] - // } - // GitFileStatus::Modified => { - // let buffer_snapshot = - // buffer.read(cx).snapshot(); - // unstaged_changes.read(cx) - // .diff_to_buffer - // .hunks_in_row_range( - // 0..BufferRow::MAX, - // &buffer_snapshot, - // ) - // .collect() - // } - // // TODO support these - // GitFileStatus::Conflict | GitFileStatus::Deleted | GitFileStatus::Untracked => Vec::new(), - // } - // }).clone() - // })?; - - // anyhow::Ok((buffer, unstaged_changes, hunks)) - // }); - // Some((entry_path, unstaged_changes_task)) - // }).ok()??; - // Some((entry_path, unstaged_changes_task)) - // }) - // .map(|(entry_path, open_task)| async move { - // (entry_path, open_task.await) - // }) - // .collect::>() - // }, - // )) - // }) - // .ok() - // else { - // return; - // }; - - // let project_buffers = project_buffers.await; - // if project_buffers.is_empty() { - // return; - // } - // let mut change_sets = Vec::with_capacity(project_buffers.len()); - // if let Some(buffer_update_task) = git_panel - // .update(&mut cx, |git_panel, cx| { - // let editor = git_panel.git_diff_editor.clone()?; - // let multi_buffer = editor.read(cx).buffer().clone(); - // let mut buffers_with_ranges = Vec::with_capacity(project_buffers.len()); - // for (buffer_path, open_result) in project_buffers { - // if let Some((buffer, unstaged_changes, diff_hunks)) = open_result - // .with_context(|| format!("opening buffer {buffer_path:?}")) - // .log_err() - // { - // change_sets.push(unstaged_changes); - // buffers_with_ranges.push(( - // buffer, - // diff_hunks - // .into_iter() - // .map(|hunk| hunk.buffer_range) - // .collect(), - // )); - // } - // } - - // Some(multi_buffer.update(cx, |multi_buffer, cx| { - // multi_buffer.clear(cx); - // multi_buffer.push_multiple_excerpts_with_context_lines( - // buffers_with_ranges, - // DEFAULT_MULTIBUFFER_CONTEXT, - // cx, - // ) - // })) - // }) - // .ok().flatten() - // { - // buffer_update_task.await; - // git_panel - // .update(&mut cx, |git_panel, cx| { - // if let Some(diff_editor) = git_panel.git_diff_editor.as_ref() { - // diff_editor.update(cx, |editor, cx| { - // for change_set in change_sets { - // editor.add_change_set(change_set, cx); - // } - // }); - // } - // }) - // .ok(); - // } - // }); + // Sort entries by path to maintain consistent order + self.visible_entries + .sort_by(|a, b| a.repo_path.cmp(&b.repo_path)); cx.notify(); } @@ -860,6 +764,7 @@ impl GitPanel { } } +// GitPanel –– Render impl GitPanel { pub fn panel_button( &self, @@ -947,14 +852,14 @@ impl GitPanel { let focus_handle = focus_handle_1.clone(); Tooltip::for_action_in( "Commit all staged changes", - &CommitStagedChanges, + &CommitChanges, &focus_handle, cx, ) }) - .on_click(cx.listener(|this, _: &ClickEvent, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })); + .on_click( + cx.listener(|this, _: &ClickEvent, cx| this.commit_changes(&CommitChanges, cx)), + ); let commit_all_button = self .panel_button("commit-all-changes", "Commit All") @@ -1063,26 +968,16 @@ impl GitPanel { } fn render_entries(&self, cx: &mut ViewContext) -> impl IntoElement { - let item_count = self - .visible_entries - .iter() - .map(|worktree_entries| worktree_entries.visible_entries.len()) - .sum(); - let selected_entry = self.selected_item; + let entry_count = self.entry_count(); h_flex() .size_full() .overflow_hidden() .child( - uniform_list(cx.view().clone(), "entries", item_count, { + uniform_list(cx.view().clone(), "entries", entry_count, { move |git_panel, range, cx| { let mut items = Vec::with_capacity(range.end - range.start); - git_panel.for_each_visible_entry(range, cx, |id, details, cx| { - items.push(git_panel.render_entry( - id, - Some(details.index) == selected_entry, - details, - cx, - )); + git_panel.for_each_visible_entry(range, cx, |ix, details, cx| { + items.push(git_panel.render_entry(ix, details, cx)); }); items } @@ -1099,19 +994,21 @@ impl GitPanel { fn render_entry( &self, ix: usize, - selected: bool, - details: EntryDetails, + entry_details: GitListEntry, cx: &ViewContext, ) -> impl IntoElement { - let view_mode = self.view_mode.clone(); - let checkbox_id = ElementId::Name(format!("checkbox_{}", ix).into()); - let is_staged = ToggleState::Selected; + let state = self.git_state.clone(); + let repo_path = entry_details.repo_path.clone(); + let selected = self.selected_entry == Some(ix); + + // TODO revisit, maybe use a different status here? + let status = entry_details.status.combined(); + let entry_id = ElementId::Name(format!("entry_{}", entry_details.display_name).into()); + let checkbox_id = + ElementId::Name(format!("checkbox_{}", entry_details.display_name).into()); + let view_mode = state.read(cx).list_view_mode.clone(); let handle = cx.view().downgrade(); - // TODO: At this point, an entry should really have a status. - // Is this fixed with the new git status stuff? - let status = details.status.unwrap_or(GitFileStatus::Untracked); - let end_slot = h_flex() .invisible() .when(selected, |this| this.visible()) @@ -1127,7 +1024,7 @@ impl GitPanel { ); let mut entry = h_flex() - .id(("git-panel-entry", ix)) + .id(entry_id) .group("git-panel-entry") .h(px(28.)) .w_full() @@ -1140,8 +1037,8 @@ impl GitPanel { this.hover(|this| this.bg(cx.theme().colors().ghost_element_hover)) }); - if view_mode == ViewMode::Tree { - entry = entry.pl(px(12. + 12. * details.depth as f32)) + if view_mode == GitViewMode::Tree { + entry = entry.pl(px(12. + 12. * entry_details.depth as f32)) } else { entry = entry.pl(px(12.)) } @@ -1151,129 +1048,70 @@ impl GitPanel { } entry = entry - .child(Checkbox::new(checkbox_id, is_staged)) + .child( + Checkbox::new(checkbox_id, entry_details.toggle_state) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let handle = handle.clone(); + let repo_path = repo_path.clone(); + move |toggle, cx| { + let Some(this) = handle.upgrade() else { + return; + }; + this.update(cx, |this, _| { + this.visible_entries[ix].toggle_state = *toggle; + }); + state.update(cx, { + let repo_path = repo_path.clone(); + move |state, _| match toggle { + ToggleState::Selected | ToggleState::Indeterminate => { + state.stage_entry(repo_path); + } + ToggleState::Unselected => state.unstage_entry(repo_path), + } + }); + } + }), + ) .child(git_status_icon(status)) .child( h_flex() - .gap_1p5() .when(status == GitFileStatus::Deleted, |this| { this.text_color(cx.theme().colors().text_disabled) .line_through() }) - .child(details.display_name.clone()), + .when_some(repo_path.parent(), |this, parent| { + let parent_str = parent.to_string_lossy(); + if !parent_str.is_empty() { + this.child( + div() + .when(status != GitFileStatus::Deleted, |this| { + this.text_color(cx.theme().colors().text_muted) + }) + .child(format!("{}/", parent_str)), + ) + } else { + this + } + }) + .child(div().child(entry_details.display_name.clone())), ) .child(div().flex_1()) .child(end_slot) - // TODO: Only fire this if the entry is not currently revealed, otherwise the ui flashes - .on_click(move |e, cx| { + .on_click(move |_, cx| { + // TODO: add `select_entry` method then do after that + cx.dispatch_action(Box::new(OpenSelected)); + handle - .update(cx, |git_panel, cx| { - git_panel.selected_item = Some(details.index); - let change_focus = e.down.click_count > 1; - git_panel.reveal_entry_in_git_editor( - details.hunks.clone(), - change_focus, - None, - cx, - ); + .update(cx, |git_panel, _| { + git_panel.selected_entry = Some(ix); }) .ok(); }); entry } - - fn reveal_entry_in_git_editor( - &mut self, - _hunks: Rc>>, - _change_focus: bool, - _debounce: Option, - _cx: &mut ViewContext, - ) { - // let workspace = self.workspace.clone(); - // let Some(diff_editor) = self.git_diff_editor.clone() else { - // return; - // }; - // self.reveal_in_editor = cx.spawn(|_, mut cx| async move { - // if let Some(debounce) = debounce { - // cx.background_executor().timer(debounce).await; - // } - - // let Some(editor) = workspace - // .update(&mut cx, |workspace, cx| { - // let git_diff_editor = workspace - // .items_of_type::(cx) - // .find(|editor| &diff_editor == editor); - // match git_diff_editor { - // Some(existing_editor) => { - // workspace.activate_item(&existing_editor, true, change_focus, cx); - // existing_editor - // } - // None => { - // workspace.active_pane().update(cx, |pane, cx| { - // pane.add_item( - // ` diff_editor.boxed_clone(), - // true, - // change_focus, - // None, - // cx, - // ) - // }); - // diff_editor.clone() - // } - // } - // }) - // .ok() - // else { - // return; - // }; - - // if let Some(first_hunk) = hunks.get().and_then(|hunks| hunks.first()) { - // let hunk_buffer_range = &first_hunk.buffer_range; - // if let Some(buffer_id) = hunk_buffer_range - // .start - // .buffer_id - // .or_else(|| first_hunk.buffer_range.end.buffer_id) - // { - // editor - // .update(&mut cx, |editor, cx| { - // let multi_buffer = editor.buffer().read(cx); - // let buffer = multi_buffer.buffer(buffer_id)?; - // let buffer_snapshot = buffer.read(cx).snapshot(); - // let (excerpt_id, _) = multi_buffer - // .excerpts_for_buffer(&buffer, cx) - // .into_iter() - // .find(|(_, excerpt)| { - // hunk_buffer_range - // .start - // .cmp(&excerpt.context.start, &buffer_snapshot) - // .is_ge() - // && hunk_buffer_range - // .end - // .cmp(&excerpt.context.end, &buffer_snapshot) - // .is_le() - // })?; - // let multi_buffer_hunk_start = multi_buffer - // .snapshot(cx) - // .anchor_in_excerpt(excerpt_id, hunk_buffer_range.start)?; - // editor.change_selections( - // Some(Autoscroll::Strategy(AutoscrollStrategy::Center)), - // cx, - // |s| { - // s.select_ranges(Some( - // multi_buffer_hunk_start..multi_buffer_hunk_start, - // )) - // }, - // ); - // cx.notify(); - // Some(()) - // }) - // .ok() - // .flatten(); - // } - // } - // }); - } } impl Render for GitPanel { @@ -1282,24 +1120,35 @@ impl Render for GitPanel { v_flex() .id("git_panel") - .key_context(self.dispatch_context()) + .key_context(self.dispatch_context(cx)) .track_focus(&self.focus_handle) .on_modifiers_changed(cx.listener(Self::handle_modifiers_changed)) .when(!project.is_read_only(cx), |this| { - this.on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) - .on_action( - cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx)), - ) - .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) - .on_action(cx.listener(|this, &CommitStagedChanges, cx| { - this.commit_staged_changes(&CommitStagedChanges, cx) - })) - .on_action(cx.listener(|this, &CommitAllChanges, cx| { - this.commit_all_changes(&CommitAllChanges, cx) - })) + this.on_action(cx.listener(|this, &ToggleStaged, cx| { + this.toggle_staged_for_selected(&ToggleStaged, cx) + })) + .on_action(cx.listener(|this, &StageAll, cx| this.stage_all(&StageAll, cx))) + .on_action(cx.listener(|this, &UnstageAll, cx| this.unstage_all(&UnstageAll, cx))) + .on_action(cx.listener(|this, &RevertAll, cx| this.discard_all(&RevertAll, cx))) + .on_action( + cx.listener(|this, &CommitChanges, cx| this.commit_changes(&CommitChanges, cx)), + ) + .on_action(cx.listener(|this, &CommitAllChanges, cx| { + this.commit_all_changes(&CommitAllChanges, cx) + })) + }) + .when(self.is_focused(cx), |this| { + this.on_action(cx.listener(Self::select_first)) + .on_action(cx.listener(Self::select_next)) + .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::select_last)) + .on_action(cx.listener(Self::close_panel)) }) - .on_action(cx.listener(Self::select_next)) - .on_action(cx.listener(Self::select_prev)) + .on_action(cx.listener(Self::open_selected)) + .on_action(cx.listener(Self::focus_changes_list)) + .on_action(cx.listener(Self::focus_editor)) + .on_action(cx.listener(Self::toggle_staged_for_selected)) + // .on_action(cx.listener(|this, &OpenSelected, cx| this.open_selected(&OpenSelected, cx))) .on_hover(cx.listener(|this, hovered, cx| { if *hovered { this.show_scrollbar = true; @@ -1384,14 +1233,3 @@ impl Panel for GitPanel { 2 } } - -// fn diff_display_editor(cx: &mut WindowContext) -> View { -// cx.new_view(|cx| { -// let multi_buffer = cx.new_model(|_| { -// MultiBuffer::new(language::Capability::ReadWrite).with_title("Project diff".to_string()) -// }); -// let mut editor = Editor::for_multibuffer(multi_buffer, None, true, cx); -// editor.set_expand_all_diff_hunks(); -// editor -// }) -// } diff --git a/crates/git_ui/src/git_ui.rs b/crates/git_ui/src/git_ui.rs index 89a47d884c76e..cf9effc11188e 100644 --- a/crates/git_ui/src/git_ui.rs +++ b/crates/git_ui/src/git_ui.rs @@ -1,56 +1,282 @@ use ::settings::Settings; -use git::repository::GitFileStatus; -use gpui::{actions, AppContext, Context, Global, Hsla, Model}; +use collections::HashMap; +use futures::{future::FusedFuture, select, FutureExt}; +use git::repository::{GitFileStatus, GitRepository, RepoPath}; +use gpui::{actions, AppContext, Context, Global, Hsla, Model, ModelContext}; +use project::{Project, WorktreeId}; use settings::GitPanelSettings; +use std::sync::mpsc; +use std::{ + pin::{pin, Pin}, + sync::Arc, + time::Duration, +}; +use sum_tree::SumTree; use ui::{Color, Icon, IconName, IntoElement, SharedString}; +use worktree::RepositoryEntry; pub mod git_panel; mod settings; +const GIT_TASK_DEBOUNCE: Duration = Duration::from_millis(50); + actions!( - git_ui, + git, [ + StageFile, + UnstageFile, + ToggleStaged, + // Revert actions are currently in the editor crate: + // editor::RevertFile, + // editor::RevertSelectedHunks StageAll, UnstageAll, RevertAll, - CommitStagedChanges, + CommitChanges, CommitAllChanges, - ClearMessage + ClearCommitMessage ] ); pub fn init(cx: &mut AppContext) { GitPanelSettings::register(cx); - let git_state = cx.new_model(|_cx| GitState::new()); + let git_state = cx.new_model(GitState::new); cx.set_global(GlobalGitState(git_state)); } +#[derive(Default, Debug, PartialEq, Eq, Clone)] +pub enum GitViewMode { + #[default] + List, + Tree, +} + struct GlobalGitState(Model); impl Global for GlobalGitState {} +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum StatusAction { + Stage, + Unstage, +} + pub struct GitState { + /// The current commit message being composed. commit_message: Option, + + /// When a git repository is selected, this is used to track which repository's changes + /// are currently being viewed or modified in the UI. + active_repository: Option<(WorktreeId, RepositoryEntry, Arc)>, + + updater_tx: mpsc::Sender<(Arc, Vec, StatusAction)>, + + all_repositories: HashMap>, + + list_view_mode: GitViewMode, } impl GitState { - pub fn new() -> Self { + pub fn new(cx: &mut ModelContext<'_, Self>) -> Self { + let (updater_tx, updater_rx) = mpsc::channel(); + cx.spawn(|_, cx| async move { + // Long-running task to periodically update git indices based on messages from the panel. + + // We read messages from the channel in batches that refer to the same repository. + // When we read a message whose repository is different from the current batch's repository, + // the batch is finished, and since we can't un-receive this last message, we save it + // to begin the next batch. + let mut leftover_message: Option<( + Arc, + Vec, + StatusAction, + )> = None; + let mut git_task = None; + loop { + let mut timer = cx.background_executor().timer(GIT_TASK_DEBOUNCE).fuse(); + let _result = { + let mut task: Pin<&mut dyn FusedFuture>> = + match git_task.as_mut() { + Some(task) => pin!(task), + // If no git task is running, just wait for the timeout. + None => pin!(std::future::pending().fuse()), + }; + select! { + result = task => { + // Task finished. + git_task = None; + Some(result) + } + _ = timer => None, + } + }; + + // TODO handle failure of the git command + + if git_task.is_none() { + // No git task running now; let's see if we should launch a new one. + let mut to_stage = Vec::new(); + let mut to_unstage = Vec::new(); + let mut current_repo = leftover_message.as_ref().map(|msg| msg.0.clone()); + for (git_repo, paths, action) in leftover_message + .take() + .into_iter() + .chain(updater_rx.try_iter()) + { + if current_repo + .as_ref() + .map_or(false, |repo| !Arc::ptr_eq(repo, &git_repo)) + { + // End of a batch, save this for the next one. + leftover_message = Some((git_repo.clone(), paths, action)); + break; + } else if current_repo.is_none() { + // Start of a batch. + current_repo = Some(git_repo); + } + + if action == StatusAction::Stage { + to_stage.extend(paths); + } else { + to_unstage.extend(paths); + } + } + + // TODO handle the same path being staged and unstaged + + if to_stage.is_empty() && to_unstage.is_empty() { + continue; + } + + if let Some(git_repo) = current_repo { + git_task = Some( + cx.background_executor() + .spawn(async move { git_repo.update_index(&to_stage, &to_unstage) }) + .fuse(), + ); + } + } + } + }) + .detach(); GitState { commit_message: None, + active_repository: None, + updater_tx, + list_view_mode: GitViewMode::default(), + all_repositories: HashMap::default(), } } - pub fn set_message(&mut self, message: Option) { + pub fn get_global(cx: &mut AppContext) -> Model { + cx.global::().0.clone() + } + + pub fn activate_repository( + &mut self, + worktree_id: WorktreeId, + active_repository: RepositoryEntry, + git_repo: Arc, + ) { + self.active_repository = Some((worktree_id, active_repository, git_repo)); + } + + pub fn active_repository( + &self, + ) -> Option<&(WorktreeId, RepositoryEntry, Arc)> { + self.active_repository.as_ref() + } + + pub fn commit_message(&mut self, message: Option) { self.commit_message = message; } - pub fn clear_message(&mut self) { + pub fn clear_commit_message(&mut self) { self.commit_message = None; } - pub fn get_global(cx: &mut AppContext) -> Model { - cx.global::().0.clone() + pub fn stage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Stage)); + } + } + + pub fn unstage_entry(&mut self, repo_path: RepoPath) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = + self.updater_tx + .send((git_repo.clone(), vec![repo_path], StatusAction::Unstage)); + } + } + + pub fn stage_entries(&mut self, entries: Vec) { + if let Some((_, _, git_repo)) = self.active_repository.as_ref() { + let _ = self + .updater_tx + .send((git_repo.clone(), entries, StatusAction::Stage)); + } } + + fn act_on_all(&mut self, action: StatusAction) { + if let Some((_, active_repository, git_repo)) = self.active_repository.as_ref() { + let _ = self.updater_tx.send(( + git_repo.clone(), + active_repository + .status() + .map(|entry| entry.repo_path) + .collect(), + action, + )); + } + } + + pub fn stage_all(&mut self) { + self.act_on_all(StatusAction::Stage); + } + + pub fn unstage_all(&mut self) { + self.act_on_all(StatusAction::Unstage); + } +} + +pub fn first_worktree_repository( + project: &Model, + worktree_id: WorktreeId, + cx: &mut AppContext, +) -> Option<(RepositoryEntry, Arc)> { + project + .read(cx) + .worktree_for_id(worktree_id, cx) + .and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((repo, git_repo)) + }) +} + +pub fn first_repository_in_project( + project: &Model, + cx: &mut AppContext, +) -> Option<(WorktreeId, RepositoryEntry, Arc)> { + project.read(cx).worktrees(cx).next().and_then(|worktree| { + let snapshot = worktree.read(cx).snapshot(); + let repo = snapshot.repositories().iter().next()?.clone(); + let git_repo = worktree + .read(cx) + .as_local()? + .get_local_repo(&repo)? + .repo() + .clone(); + Some((snapshot.id(), repo, git_repo)) + }) } const ADDED_COLOR: Hsla = Hsla { diff --git a/crates/ui/src/components/button/button_like.rs b/crates/ui/src/components/button/button_like.rs index 26ef2fc19f1d8..f0c69c79c49b7 100644 --- a/crates/ui/src/components/button/button_like.rs +++ b/crates/ui/src/components/button/button_like.rs @@ -487,6 +487,7 @@ impl RenderOnce for ButtonLike { self.base .h_flex() .id(self.id.clone()) + .font_ui(cx) .group("") .flex_none() .h(self.height.unwrap_or(self.size.rems().into())) diff --git a/crates/worktree/src/worktree.rs b/crates/worktree/src/worktree.rs index b2ced35e75794..3f8c113db674c 100644 --- a/crates/worktree/src/worktree.rs +++ b/crates/worktree/src/worktree.rs @@ -21,6 +21,7 @@ use fuzzy::CharBag; use git::GitHostingProviderRegistry; use git::{ repository::{GitFileStatus, GitRepository, RepoPath}, + status::GitStatusPair, COOKIES, DOT_GIT, FSMONITOR_DAEMON, GITIGNORE, }; use gpui::{ @@ -193,8 +194,8 @@ pub struct RepositoryEntry { /// - my_sub_folder_1/project_root/changed_file_1 /// - my_sub_folder_2/changed_file_2 pub(crate) statuses_by_path: SumTree, - pub(crate) work_directory_id: ProjectEntryId, - pub(crate) work_directory: WorkDirectory, + pub work_directory_id: ProjectEntryId, + pub work_directory: WorkDirectory, pub(crate) branch: Option>, } @@ -225,6 +226,12 @@ impl RepositoryEntry { self.statuses_by_path.iter().cloned() } + pub fn status_for_path(&self, path: &RepoPath) -> Option { + self.statuses_by_path + .get(&PathKey(path.0.clone()), &()) + .cloned() + } + pub fn initial_update(&self) -> proto::RepositoryEntry { proto::RepositoryEntry { work_directory_id: self.work_directory_id.to_proto(), @@ -234,7 +241,7 @@ impl RepositoryEntry { .iter() .map(|entry| proto::StatusEntry { repo_path: entry.repo_path.to_string_lossy().to_string(), - status: git_status_to_proto(entry.status), + status: status_pair_to_proto(entry.status.clone()), }) .collect(), removed_statuses: Default::default(), @@ -259,7 +266,7 @@ impl RepositoryEntry { current_new_entry = new_statuses.next(); } Ordering::Equal => { - if new_entry.status != old_entry.status { + if new_entry.combined_status() != old_entry.combined_status() { updated_statuses.push(new_entry.to_proto()); } current_old_entry = old_statuses.next(); @@ -2360,7 +2367,7 @@ impl Snapshot { let repo_path = repo.relativize(path).unwrap(); repo.statuses_by_path .get(&PathKey(repo_path.0), &()) - .map(|entry| entry.status) + .map(|entry| entry.combined_status()) }) } @@ -2574,8 +2581,8 @@ impl Snapshot { .map(|repo| repo.status().collect()) } - pub fn repositories(&self) -> impl Iterator { - self.repositories.iter() + pub fn repositories(&self) -> &SumTree { + &self.repositories } /// Get the repository whose work directory corresponds to the given path. @@ -2609,7 +2616,7 @@ impl Snapshot { entries: impl 'a + Iterator, ) -> impl 'a + Iterator)> { let mut containing_repos = Vec::<&RepositoryEntry>::new(); - let mut repositories = self.repositories().peekable(); + let mut repositories = self.repositories().iter().peekable(); entries.map(move |entry| { while let Some(repository) = containing_repos.last() { if repository.directory_contains(&entry.path) { @@ -3626,14 +3633,31 @@ pub type UpdatedGitRepositoriesSet = Arc<[(Arc, GitRepositoryChange)]>; #[derive(Clone, Debug, PartialEq, Eq)] pub struct StatusEntry { pub repo_path: RepoPath, - pub status: GitFileStatus, + pub status: GitStatusPair, } impl StatusEntry { + // TODO revisit uses of this + pub fn combined_status(&self) -> GitFileStatus { + self.status.combined() + } + + pub fn index_status(&self) -> Option { + self.status.index_status + } + + pub fn worktree_status(&self) -> Option { + self.status.worktree_status + } + + pub fn is_staged(&self) -> Option { + self.status.is_staged() + } + fn to_proto(&self) -> proto::StatusEntry { proto::StatusEntry { repo_path: self.repo_path.to_proto(), - status: git_status_to_proto(self.status), + status: status_pair_to_proto(self.status.clone()), } } } @@ -3641,11 +3665,10 @@ impl StatusEntry { impl TryFrom for StatusEntry { type Error = anyhow::Error; fn try_from(value: proto::StatusEntry) -> Result { - Ok(Self { - repo_path: RepoPath(Path::new(&value.repo_path).into()), - status: git_status_from_proto(Some(value.status)) - .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?, - }) + let repo_path = RepoPath(Path::new(&value.repo_path).into()); + let status = status_pair_from_proto(value.status) + .ok_or_else(|| anyhow!("Unable to parse status value {}", value.status))?; + Ok(Self { repo_path, status }) } } @@ -3729,7 +3752,7 @@ impl sum_tree::Item for StatusEntry { fn summary(&self, _: &::Context) -> Self::Summary { PathSummary { max_path: self.repo_path.0.clone(), - item_summary: match self.status { + item_summary: match self.combined_status() { GitFileStatus::Added => GitStatuses { added: 1, ..Default::default() @@ -4820,15 +4843,15 @@ impl BackgroundScanner { for (repo_path, status) in &*status.entries { paths.remove_repo_path(repo_path); - if cursor.seek_forward(&PathTarget::Path(&repo_path), Bias::Left, &()) { - if cursor.item().unwrap().status == *status { + if cursor.seek_forward(&PathTarget::Path(repo_path), Bias::Left, &()) { + if &cursor.item().unwrap().status == status { continue; } } changed_path_statuses.push(Edit::Insert(StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), })); } @@ -5257,7 +5280,7 @@ impl BackgroundScanner { new_entries_by_path.insert_or_replace( StatusEntry { repo_path: repo_path.clone(), - status: *status, + status: status.clone(), }, &(), ); @@ -5771,7 +5794,7 @@ impl<'a> GitTraversal<'a> { } else if entry.is_file() { // For a file entry, park the cursor on the corresponding status if statuses.seek_forward(&PathTarget::Path(repo_path.as_ref()), Bias::Left, &()) { - self.current_entry_status = Some(statuses.item().unwrap().status); + self.current_entry_status = Some(statuses.item().unwrap().combined_status()); } } } @@ -6136,19 +6159,23 @@ impl<'a> TryFrom<(&'a CharBag, &PathMatcher, proto::Entry)> for Entry { } } -fn git_status_from_proto(git_status: Option) -> Option { - git_status.and_then(|status| { - proto::GitStatus::from_i32(status).map(|status| match status { - proto::GitStatus::Added => GitFileStatus::Added, - proto::GitStatus::Modified => GitFileStatus::Modified, - proto::GitStatus::Conflict => GitFileStatus::Conflict, - proto::GitStatus::Deleted => GitFileStatus::Deleted, - }) +// TODO pass the status pair all the way through +fn status_pair_from_proto(proto: i32) -> Option { + let proto = proto::GitStatus::from_i32(proto)?; + let worktree_status = match proto { + proto::GitStatus::Added => GitFileStatus::Added, + proto::GitStatus::Modified => GitFileStatus::Modified, + proto::GitStatus::Conflict => GitFileStatus::Conflict, + proto::GitStatus::Deleted => GitFileStatus::Deleted, + }; + Some(GitStatusPair { + index_status: None, + worktree_status: Some(worktree_status), }) } -fn git_status_to_proto(status: GitFileStatus) -> i32 { - match status { +fn status_pair_to_proto(status: GitStatusPair) -> i32 { + match status.combined() { GitFileStatus::Added => proto::GitStatus::Added as i32, GitFileStatus::Modified => proto::GitStatus::Modified as i32, GitFileStatus::Conflict => proto::GitStatus::Conflict as i32, diff --git a/crates/worktree/src/worktree_tests.rs b/crates/worktree/src/worktree_tests.rs index eebb5f9360f47..5f8144347d93f 100644 --- a/crates/worktree/src/worktree_tests.rs +++ b/crates/worktree/src/worktree_tests.rs @@ -2179,7 +2179,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project1")); assert_eq!( tree.status_for_file(Path::new("projects/project1/a")), @@ -2200,7 +2200,7 @@ async fn test_rename_work_directory(cx: &mut TestAppContext) { cx.read(|cx| { let tree = tree.read(cx); - let repo = tree.repositories().next().unwrap(); + let repo = tree.repositories().iter().next().unwrap(); assert_eq!(repo.path.as_ref(), Path::new("projects/project2")); assert_eq!( tree.status_for_file(Path::new("projects/project2/a")), @@ -2380,8 +2380,8 @@ async fn test_file_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo_entry = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo_entry = snapshot.repositories().iter().next().unwrap(); assert_eq!(repo_entry.path.as_ref(), Path::new("project")); assert!(repo_entry.location_in_repo.is_none()); @@ -2554,16 +2554,16 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { // Check that the right git state is observed on startup tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); assert_eq!(entries.len(), 3); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); assert_eq!(entries[2].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[2].status, GitFileStatus::Deleted); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Deleted)); }); std::fs::write(work_dir.join("c.txt"), "some changes").unwrap(); @@ -2576,19 +2576,19 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repository = snapshot.repositories().next().unwrap(); + let repository = snapshot.repositories().iter().next().unwrap(); let entries = repository.status().collect::>(); std::assert_eq!(entries.len(), 4, "entries: {entries:?}"); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Modified); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[1].repo_path.as_ref(), Path::new("b.txt")); - assert_eq!(entries[1].status, GitFileStatus::Untracked); + assert_eq!(entries[1].worktree_status(), Some(GitFileStatus::Untracked)); // Status updated assert_eq!(entries[2].repo_path.as_ref(), Path::new("c.txt")); - assert_eq!(entries[2].status, GitFileStatus::Modified); + assert_eq!(entries[2].worktree_status(), Some(GitFileStatus::Modified)); assert_eq!(entries[3].repo_path.as_ref(), Path::new("d.txt")); - assert_eq!(entries[3].status, GitFileStatus::Deleted); + assert_eq!(entries[3].worktree_status(), Some(GitFileStatus::Deleted)); }); git_add("a.txt", &repo); @@ -2609,7 +2609,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - let repo = snapshot.repositories().next().unwrap(); + let repo = snapshot.repositories().iter().next().unwrap(); let entries = repo.status().collect::>(); // Deleting an untracked entry, b.txt, should leave no status @@ -2621,7 +2621,7 @@ async fn test_git_repository_status(cx: &mut TestAppContext) { &entries ); assert_eq!(entries[0].repo_path.as_ref(), Path::new("a.txt")); - assert_eq!(entries[0].status, GitFileStatus::Deleted); + assert_eq!(entries[0].worktree_status(), Some(GitFileStatus::Deleted)); }); } @@ -2676,8 +2676,8 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { // Ensure that the git status is loaded correctly tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert_eq!(snapshot.repositories().count(), 1); - let repo = snapshot.repositories().next().unwrap(); + assert_eq!(snapshot.repositories().iter().count(), 1); + let repo = snapshot.repositories().iter().next().unwrap(); // Path is blank because the working directory of // the git repository is located at the root of the project assert_eq!(repo.path.as_ref(), Path::new("")); @@ -2707,7 +2707,7 @@ async fn test_repository_subfolder_git_status(cx: &mut TestAppContext) { tree.read_with(cx, |tree, _cx| { let snapshot = tree.snapshot(); - assert!(snapshot.repositories().next().is_some()); + assert!(snapshot.repositories().iter().next().is_some()); assert_eq!(snapshot.status_for_file("c.txt"), None); assert_eq!(snapshot.status_for_file("d/e.txt"), None); From b59a9f1f42b3d38f654b2624a17d201d544b444d Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 10:48:50 -0700 Subject: [PATCH 10/25] Document why rust-analyzer doesn't show action name in action docs (#23072) rust-analyzer does not support derive_macro expansion in attributes - https://github.com/rust-lang/rust-analyzer/issues/8092. This could be worked around via a proc_macro, but I think it'd be best to just require docs for every action. Release Notes: - N/A --- crates/gpui/src/action.rs | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index bc7388e513ee1..06029676650bd 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -252,9 +252,10 @@ impl ActionRegistry { macro_rules! actions { ($namespace:path, [ $($name:ident),* $(,)? ]) => { $( - #[doc = "The `"] + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] + #[doc = "action generated by `gpui::actions!`"] #[derive(::std::clone::Clone,::std::cmp::PartialEq, ::std::default::Default)] pub struct $name; @@ -281,9 +282,10 @@ macro_rules! actions { #[macro_export] macro_rules! action_as { ($namespace:path, $name:ident as $visual_name:ident) => { - #[doc = "The `"] + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] + #[doc = "action generated by `gpui::action_as!`"] #[derive(::std::clone::Clone, ::std::cmp::PartialEq, ::std::default::Default)] pub struct $name; @@ -311,9 +313,10 @@ macro_rules! action_as { #[macro_export] macro_rules! action_aliases { ($namespace:path, $name:ident, [$($alias:ident),* $(,)?]) => { - #[doc = "The `"] + // Unfortunately rust-analyzer doesn't display the name due to + // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "` action, see [`gpui::actions!`]"] + #[doc = "action, generated by `gpui::action_aliases!`"] #[derive( ::std::cmp::PartialEq, ::std::clone::Clone, From c1c767a5bdc6a8f572ff643c3d0ea42fffca7865 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 14:09:27 -0500 Subject: [PATCH 11/25] assistant2: Make `Esc` cancel current completion (#23076) This PR makes it so pressing `Esc` in Assistant2 will cancel the current completion. Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 6 ++++++ crates/assistant2/src/assistant_panel.rs | 6 ++++++ crates/assistant2/src/thread.rs | 11 +++++++++++ 3 files changed, 23 insertions(+) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index e56d766ea168f..7633d044a13b1 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -80,6 +80,12 @@ impl ActiveThread { self.thread.read(cx).summary_or_default() } + pub fn cancel_last_completion(&mut self, cx: &mut AppContext) -> bool { + self.last_error.take(); + self.thread + .update(cx, |thread, _cx| thread.cancel_last_completion()) + } + pub fn last_error(&self) -> Option { self.last_error.clone() } diff --git a/crates/assistant2/src/assistant_panel.rs b/crates/assistant2/src/assistant_panel.rs index 894064842a88c..4c6993312e197 100644 --- a/crates/assistant2/src/assistant_panel.rs +++ b/crates/assistant2/src/assistant_panel.rs @@ -143,6 +143,11 @@ impl AssistantPanel { &self.thread_store } + fn cancel(&mut self, _: &editor::actions::Cancel, cx: &mut ViewContext) { + self.thread + .update(cx, |thread, cx| thread.cancel_last_completion(cx)); + } + fn new_thread(&mut self, cx: &mut ViewContext) { let thread = self .thread_store @@ -611,6 +616,7 @@ impl Render for AssistantPanel { .key_context("AssistantPanel2") .justify_between() .size_full() + .on_action(cx.listener(Self::cancel)) .on_action(cx.listener(|this, _: &NewThread, cx| { this.new_thread(cx); })) diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index c3933cf4590e7..707d8be514b09 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -502,6 +502,17 @@ impl Thread { }; } } + + /// Cancels the last pending completion, if there are any pending. + /// + /// Returns whether a completion was canceled. + pub fn cancel_last_completion(&mut self) -> bool { + if let Some(_last_completion) = self.pending_completions.pop() { + true + } else { + false + } + } } #[derive(Debug, Clone)] From 2f762955cd0abce4b4ff894caea9c4f066e3b74c Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 12:26:28 -0700 Subject: [PATCH 12/25] Take a reference in LSP notify (#23077) In current code this doesn't have benefit. In preparation for avoiding a clone of workspace configuration. Having the interface this way may make opportunities for efficiency clearer in the future Release Notes: - N/A --- crates/collab/src/tests/editor_tests.rs | 4 +-- crates/collab/src/tests/integration_tests.rs | 14 +++++------ crates/copilot/src/copilot.rs | 12 ++++----- crates/editor/src/inlay_hint_cache.rs | 4 +-- .../src/test/editor_lsp_test_context.rs | 2 +- crates/language_tools/src/lsp_log.rs | 2 +- crates/language_tools/src/lsp_log_tests.rs | 2 +- crates/lsp/src/lsp.rs | 22 ++++++++-------- crates/project/src/lsp_store.rs | 25 ++++++++++--------- crates/project/src/project_tests.rs | 16 ++++++------ 10 files changed, 52 insertions(+), 51 deletions(-) diff --git a/crates/collab/src/tests/editor_tests.rs b/crates/collab/src/tests/editor_tests.rs index 773ee97a9d2ed..350b1d41bec89 100644 --- a/crates/collab/src/tests/editor_tests.rs +++ b/crates/collab/src/tests/editor_tests.rs @@ -1007,7 +1007,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes fake_language_server.start_progress("the-token").await; executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { @@ -1041,7 +1041,7 @@ async fn test_language_server_statuses(cx_a: &mut TestAppContext, cx_b: &mut Tes }); executor.advance_clock(SERVER_PROGRESS_THROTTLE_TIMEOUT); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Report( lsp::WorkDoneProgressReport { diff --git a/crates/collab/src/tests/integration_tests.rs b/crates/collab/src/tests/integration_tests.rs index ce5b2d5ad65bf..30c4cedacbb12 100644 --- a/crates/collab/src/tests/integration_tests.rs +++ b/crates/collab/src/tests/integration_tests.rs @@ -3900,7 +3900,7 @@ async fn test_collaborating_with_diagnostics( .receive_notification::() .await; fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -3920,7 +3920,7 @@ async fn test_collaborating_with_diagnostics( .await .unwrap(); fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -3994,7 +3994,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting more errors for a file. fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![ @@ -4088,7 +4088,7 @@ async fn test_collaborating_with_diagnostics( // Simulate a language server reporting no errors for a file. fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/a/a.rs").unwrap(), version: None, diagnostics: vec![], @@ -4183,7 +4183,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }) .await .unwrap(); - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin { @@ -4194,7 +4194,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }); for file_name in file_names { fake_language_server.notify::( - lsp::PublishDiagnosticsParams { + &lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path(Path::new("/test").join(file_name)).unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -4207,7 +4207,7 @@ async fn test_collaborating_with_lsp_progress_updates_and_diagnostics_ordering( }, ); } - fake_language_server.notify::(lsp::ProgressParams { + fake_language_server.notify::(&lsp::ProgressParams { token: lsp::NumberOrString::String("the-disk-based-token".to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd { message: None }, diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index bc424d2d5af38..72e42514883ba 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -270,7 +270,7 @@ impl RegisteredBuffer { server .lsp .notify::( - lsp::DidChangeTextDocumentParams { + &lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( buffer.uri.clone(), buffer.snapshot_version, @@ -659,7 +659,7 @@ impl Copilot { let snapshot = buffer.read(cx).snapshot(); server .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem { uri: uri.clone(), language_id: language_id.clone(), @@ -707,7 +707,7 @@ impl Copilot { server .lsp .notify::( - lsp::DidSaveTextDocumentParams { + &lsp::DidSaveTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new( registered_buffer.uri.clone(), ), @@ -727,14 +727,14 @@ impl Copilot { server .lsp .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(old_uri), }, )?; server .lsp .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( registered_buffer.uri.clone(), registered_buffer.language_id.clone(), @@ -759,7 +759,7 @@ impl Copilot { server .lsp .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(buffer.uri), }, ) diff --git a/crates/editor/src/inlay_hint_cache.rs b/crates/editor/src/inlay_hint_cache.rs index 739fb98226b77..327ebbb5e2659 100644 --- a/crates/editor/src/inlay_hint_cache.rs +++ b/crates/editor/src/inlay_hint_cache.rs @@ -1479,7 +1479,7 @@ pub mod tests { .await .expect("work done progress create request failed"); cx.executor().run_until_parked(); - fake_server.notify::(lsp::ProgressParams { + fake_server.notify::(&lsp::ProgressParams { token: lsp::ProgressToken::String(progress_token.to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::Begin( lsp::WorkDoneProgressBegin::default(), @@ -1504,7 +1504,7 @@ pub mod tests { }) .unwrap(); - fake_server.notify::(lsp::ProgressParams { + fake_server.notify::(&lsp::ProgressParams { token: lsp::ProgressToken::String(progress_token.to_string()), value: lsp::ProgressParamsValue::WorkDone(lsp::WorkDoneProgress::End( lsp::WorkDoneProgressEnd::default(), diff --git a/crates/editor/src/test/editor_lsp_test_context.rs b/crates/editor/src/test/editor_lsp_test_context.rs index 3831ca963fbb7..23e37a1267bdb 100644 --- a/crates/editor/src/test/editor_lsp_test_context.rs +++ b/crates/editor/src/test/editor_lsp_test_context.rs @@ -331,7 +331,7 @@ impl EditorLspTestContext { } pub fn notify(&self, params: T::Params) { - self.lsp.notify::(params); + self.lsp.notify::(¶ms); } #[cfg(target_os = "windows")] diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index 01ef202227d63..e55a6e2b8eca1 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -963,7 +963,7 @@ impl LspLogView { }); server - .notify::(SetTraceParams { value: level }) + .notify::(&SetTraceParams { value: level }) .ok(); } } diff --git a/crates/language_tools/src/lsp_log_tests.rs b/crates/language_tools/src/lsp_log_tests.rs index ad3cc87f2d042..cbcb900c3f28b 100644 --- a/crates/language_tools/src/lsp_log_tests.rs +++ b/crates/language_tools/src/lsp_log_tests.rs @@ -71,7 +71,7 @@ async fn test_lsp_logs(cx: &mut TestAppContext) { let log_view = window.root(cx).unwrap(); let mut cx = VisualTestContext::from_window(*window, cx); - language_server.notify::(lsp::LogMessageParams { + language_server.notify::(&lsp::LogMessageParams { message: "hello from the server".into(), typ: lsp::MessageType::INFO, }); diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 315794853080a..3999240d89510 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -815,7 +815,7 @@ impl LanguageServer { } self.capabilities = RwLock::new(response.capabilities); - self.notify::(InitializedParams {})?; + self.notify::(&InitializedParams {})?; Ok(Arc::new(self)) }) } @@ -835,7 +835,7 @@ impl LanguageServer { &executor, (), ); - let exit = Self::notify_internal::(&outbound_tx, ()); + let exit = Self::notify_internal::(&outbound_tx, &()); outbound_tx.close(); let server = self.server.clone(); @@ -1146,7 +1146,7 @@ impl LanguageServer { if let Some(outbound_tx) = outbound_tx.upgrade() { Self::notify_internal::( &outbound_tx, - CancelParams { + &CancelParams { id: NumberOrString::Number(id), }, ) @@ -1174,13 +1174,13 @@ impl LanguageServer { /// Sends a RPC notification to the language server. /// /// [LSP Specification](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#notificationMessage) - pub fn notify(&self, params: T::Params) -> Result<()> { + pub fn notify(&self, params: &T::Params) -> Result<()> { Self::notify_internal::(&self.outbound_tx, params) } fn notify_internal( outbound_tx: &channel::Sender, - params: T::Params, + params: &T::Params, ) -> Result<()> { let message = serde_json::to_string(&Notification { jsonrpc: JSON_RPC_VERSION, @@ -1372,7 +1372,7 @@ impl LanguageServer { #[cfg(any(test, feature = "test-support"))] impl FakeLanguageServer { /// See [`LanguageServer::notify`]. - pub fn notify(&self, params: T::Params) { + pub fn notify(&self, params: &T::Params) { self.server.notify::(params).ok(); } @@ -1480,7 +1480,7 @@ impl FakeLanguageServer { }) .await .unwrap(); - self.notify::(ProgressParams { + self.notify::(&ProgressParams { token: NumberOrString::String(token), value: ProgressParamsValue::WorkDone(WorkDoneProgress::Begin(progress)), }); @@ -1488,7 +1488,7 @@ impl FakeLanguageServer { /// Simulate that the server has completed work and notifies about that with the specified token. pub fn end_progress(&self, token: impl Into) { - self.notify::(ProgressParams { + self.notify::(&ProgressParams { token: NumberOrString::String(token.into()), value: ProgressParamsValue::WorkDone(WorkDoneProgress::End(Default::default())), }); @@ -1540,7 +1540,7 @@ mod tests { let server = cx.update(|cx| server.initialize(None, cx)).await.unwrap(); server - .notify::(DidOpenTextDocumentParams { + .notify::(&DidOpenTextDocumentParams { text_document: TextDocumentItem::new( Url::from_str("file://a/b").unwrap(), "rust".to_string(), @@ -1558,11 +1558,11 @@ mod tests { "file://a/b" ); - fake.notify::(ShowMessageParams { + fake.notify::(&ShowMessageParams { typ: MessageType::ERROR, message: "ok".to_string(), }); - fake.notify::(PublishDiagnosticsParams { + fake.notify::(&PublishDiagnosticsParams { uri: Url::from_str("file://b/c").unwrap(), version: Some(5), diagnostics: vec![], diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index c4c01214a056e..61294f0b0e6c2 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -302,7 +302,7 @@ impl LocalLspStore { language_server .notify::( - lsp::DidChangeConfigurationParams { + &lsp::DidChangeConfigurationParams { settings: workspace_config, }, ) @@ -1922,7 +1922,7 @@ impl LocalLspStore { }; server - .notify::(lsp::DidOpenTextDocumentParams { + .notify::(&lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( uri.clone(), adapter.language_id(&language.name()), @@ -1968,7 +1968,7 @@ impl LocalLspStore { for (_, language_server) in self.language_servers_for_buffer(buffer, cx) { language_server .notify::( - lsp::DidCloseTextDocumentParams { + &lsp::DidCloseTextDocumentParams { text_document: lsp::TextDocumentIdentifier::new(file_url.clone()), }, ) @@ -5068,7 +5068,7 @@ impl LspStore { language_server .notify::( - lsp::DidChangeTextDocumentParams { + &lsp::DidChangeTextDocumentParams { text_document: lsp::VersionedTextDocumentIdentifier::new( uri.clone(), next_version, @@ -5104,7 +5104,7 @@ impl LspStore { }; server .notify::( - lsp::DidSaveTextDocumentParams { + &lsp::DidSaveTextDocumentParams { text_document: text_document.clone(), text, }, @@ -5174,7 +5174,7 @@ impl LspStore { server .notify::( - lsp::DidChangeConfigurationParams { settings }, + &lsp::DidChangeConfigurationParams { settings }, ) .ok(); } @@ -6215,7 +6215,7 @@ impl LspStore { if filter.should_send_did_rename(&old_uri, is_dir) { language_server - .notify::(RenameFilesParams { + .notify::(&RenameFilesParams { files: vec![FileRename { old_uri: old_uri.clone(), new_uri: new_uri.clone(), @@ -6322,7 +6322,7 @@ impl LspStore { if !changes.is_empty() { server .notify::( - lsp::DidChangeWatchedFilesParams { changes }, + &lsp::DidChangeWatchedFilesParams { changes }, ) .log_err(); } @@ -7534,7 +7534,7 @@ impl LspStore { let uri = lsp::Url::from_file_path(file.abs_path(cx)).unwrap(); language_server .notify::( - lsp::DidOpenTextDocumentParams { + &lsp::DidOpenTextDocumentParams { text_document: lsp::TextDocumentItem::new( uri, adapter.language_id(&language.name()), @@ -7636,10 +7636,11 @@ impl LspStore { continue; } } + if progress.is_cancellable { server .notify::( - WorkDoneProgressCancelParams { + &WorkDoneProgressCancelParams { token: lsp::NumberOrString::String(token.clone()), }, ) @@ -7649,7 +7650,7 @@ impl LspStore { if progress.is_cancellable { server .notify::( - WorkDoneProgressCancelParams { + &WorkDoneProgressCancelParams { token: lsp::NumberOrString::String(token.clone()), }, ) @@ -7784,7 +7785,7 @@ impl LspStore { }; if !params.changes.is_empty() { server - .notify::(params) + .notify::(¶ms) .log_err(); } } diff --git a/crates/project/src/project_tests.rs b/crates/project/src/project_tests.rs index 005cd34cba2b2..b216d15b1eb41 100644 --- a/crates/project/src/project_tests.rs +++ b/crates/project/src/project_tests.rs @@ -1269,7 +1269,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { } ); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -1321,7 +1321,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { }); // Ensure publishing empty diagnostics twice only results in one update event. - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: Default::default(), @@ -1334,7 +1334,7 @@ async fn test_disk_based_diagnostics_progress(cx: &mut gpui::TestAppContext) { } ); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: Default::default(), @@ -1453,7 +1453,7 @@ async fn test_restarting_server_with_diagnostics_published(cx: &mut gpui::TestAp // Publish diagnostics let fake_server = fake_servers.next().await.unwrap(); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: Url::from_file_path("/dir/a.rs").unwrap(), version: None, diagnostics: vec![lsp::Diagnostic { @@ -1534,7 +1534,7 @@ async fn test_restarted_server_reporting_invalid_buffer_version(cx: &mut gpui::T // Before restarting the server, report diagnostics with an unknown buffer version. let fake_server = fake_servers.next().await.unwrap(); - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(10000), diagnostics: Vec::new(), @@ -1784,7 +1784,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { assert!(change_notification_1.text_document.version > open_notification.text_document.version); // Report some diagnostics for the initial version of the buffer - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ @@ -1870,7 +1870,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { }); // Ensure overlapping diagnostics are highlighted correctly. - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(open_notification.text_document.version), diagnostics: vec![ @@ -1962,7 +1962,7 @@ async fn test_transforming_diagnostics(cx: &mut gpui::TestAppContext) { ); // Handle out-of-order diagnostics - fake_server.notify::(lsp::PublishDiagnosticsParams { + fake_server.notify::(&lsp::PublishDiagnosticsParams { uri: lsp::Url::from_file_path("/dir/a.rs").unwrap(), version: Some(change_notification_2.text_document.version), diagnostics: vec![ From 2179be1855569a79280581e45c8be780c0c72778 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 14:29:50 -0500 Subject: [PATCH 13/25] assistant2: Add an indicator when a response is streaming in (#23078) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds an indicator to the Assistant message to indicate that it is still streaming: Screenshot 2025-01-13 at 2 10 33 PM Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 38 ++++++++++++++++++++++++-- crates/assistant2/src/thread.rs | 4 +++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 7633d044a13b1..58dc92ebe2cd3 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -1,11 +1,13 @@ use std::sync::Arc; +use std::time::Duration; use assistant_tool::ToolWorkingSet; use collections::HashMap; use gpui::{ - list, AbsoluteLength, AnyElement, AppContext, DefiniteLength, EdgesRefinement, Empty, Length, - ListAlignment, ListOffset, ListState, Model, StyleRefinement, Subscription, - TextStyleRefinement, UnderlineStyle, View, WeakView, + list, percentage, AbsoluteLength, Animation, AnimationExt, AnyElement, AppContext, + DefiniteLength, EdgesRefinement, Empty, Length, ListAlignment, ListOffset, ListState, Model, + StyleRefinement, Subscription, TextStyleRefinement, Transformation, UnderlineStyle, View, + WeakView, }; use language::LanguageRegistry; use language_model::Role; @@ -248,6 +250,7 @@ impl ActiveThread { return Empty.into_any(); }; + let is_streaming_completion = self.thread.read(cx).is_streaming(); let context = self.thread.read(cx).context_for_message(message_id); let colors = cx.theme().colors(); @@ -290,6 +293,35 @@ impl ActiveThread { ), ) .child(div().p_2p5().text_ui(cx).child(markdown.clone())) + .when( + message.role == Role::Assistant && is_streaming_completion, + |parent| { + parent.child( + h_flex() + .gap_1() + .p_2p5() + .child( + Icon::new(IconName::ArrowCircle) + .size(IconSize::Small) + .color(Color::Muted) + .with_animation( + "arrow-circle", + Animation::new(Duration::from_secs(2)).repeat(), + |icon, delta| { + icon.transform(Transformation::rotate( + percentage(delta), + )) + }, + ), + ) + .child( + Label::new("Generating…") + .size(LabelSize::Small) + .color(Color::Muted), + ), + ) + }, + ) .when_some(context, |parent, context| { if !context.is_empty() { parent.child( diff --git a/crates/assistant2/src/thread.rs b/crates/assistant2/src/thread.rs index 707d8be514b09..b5a42da85b325 100644 --- a/crates/assistant2/src/thread.rs +++ b/crates/assistant2/src/thread.rs @@ -132,6 +132,10 @@ impl Thread { self.messages.iter() } + pub fn is_streaming(&self) -> bool { + !self.pending_completions.is_empty() + } + pub fn tools(&self) -> &Arc { &self.tools } From bd3c7d6cbffda730c0c2c7e468a1deecb61a3312 Mon Sep 17 00:00:00 2001 From: Cole Miller Date: Mon, 13 Jan 2025 15:13:14 -0500 Subject: [PATCH 14/25] git: Fully implement "all staged" checkbox (#23079) Also includes some improvements to the "stage/unstage all" actions and buttons. Release Notes: - N/A --- crates/git_ui/src/git_panel.rs | 167 +++++++++++++++++++++------------ 1 file changed, 109 insertions(+), 58 deletions(-) diff --git a/crates/git_ui/src/git_panel.rs b/crates/git_ui/src/git_panel.rs index c6ff3fe4952f7..e52aba0108667 100644 --- a/crates/git_ui/src/git_panel.rs +++ b/crates/git_ui/src/git_panel.rs @@ -69,7 +69,7 @@ pub struct GitListEntry { display_name: String, repo_path: RepoPath, status: GitStatusPair, - toggle_state: ToggleState, + is_staged: Option, } pub struct GitPanel { @@ -91,18 +91,11 @@ pub struct GitPanel { /// At this point it doesn't matter what repository the entry belongs to, /// as only one repositories' entries are visible in the list at a time. visible_entries: Vec, + all_staged: Option, width: Option, reveal_in_editor: Task<()>, } -fn status_to_toggle_state(status: &GitStatusPair) -> ToggleState { - match status.is_staged() { - Some(true) => ToggleState::Selected, - Some(false) => ToggleState::Unselected, - None => ToggleState::Indeterminate, - } -} - impl GitPanel { pub fn load( workspace: WeakView, @@ -314,6 +307,7 @@ impl GitPanel { fs, pending_serialization: Task::ready(None), visible_entries: Vec::new(), + all_staged: None, current_modifiers: cx.modifiers(), width: Some(px(360.)), scrollbar_state: ScrollbarState::new(scroll_handle.clone()).parent_view(cx.view()), @@ -602,10 +596,26 @@ impl GitPanel { } fn stage_all(&mut self, _: &StageAll, cx: &mut ViewContext) { - self.git_state.update(cx, |state, _| state.stage_all()); + let to_stage = self + .visible_entries + .iter_mut() + .filter_map(|entry| { + let is_unstaged = !entry.is_staged.unwrap_or(false); + entry.is_staged = Some(true); + is_unstaged.then(|| entry.repo_path.clone()) + }) + .collect(); + self.all_staged = Some(true); + self.git_state + .update(cx, |state, _| state.stage_entries(to_stage)); } fn unstage_all(&mut self, _: &UnstageAll, cx: &mut ViewContext) { + // This should only be called when all entries are staged. + for entry in &mut self.visible_entries { + entry.is_staged = Some(false); + } + self.all_staged = Some(false); self.git_state.update(cx, |state, _| { state.unstage_all(); }); @@ -639,11 +649,6 @@ impl GitPanel { println!("Commit all changes triggered"); } - fn all_staged(&self) -> bool { - // TODO: Implement all_staged - true - } - fn no_entries(&self) -> bool { self.visible_entries.is_empty() } @@ -678,7 +683,7 @@ impl GitPanel { status, depth: 0, display_name: filename, - toggle_state: entry.toggle_state, + is_staged: entry.is_staged, }; callback(ix, details, cx); @@ -705,10 +710,19 @@ impl GitPanel { let path_set = HashSet::from_iter(repo.status().map(|entry| entry.repo_path)); // Second pass - create entries with proper depth calculation - for entry in repo.status() { + let mut all_staged = None; + for (ix, entry) in repo.status().enumerate() { let (depth, difference) = Self::calculate_depth_and_difference(&entry.repo_path, &path_set); - let toggle_state = status_to_toggle_state(&entry.status); + let is_staged = entry.status.is_staged(); + all_staged = if ix == 0 { + is_staged + } else { + match (all_staged, is_staged) { + (None, _) | (_, None) => None, + (Some(a), Some(b)) => (a == b).then_some(a), + } + }; let display_name = if difference > 1 { // Show partial path for deeply nested files @@ -734,11 +748,12 @@ impl GitPanel { display_name, repo_path: entry.repo_path, status: entry.status, - toggle_state, + is_staged, }; self.visible_entries.push(entry); } + self.all_staged = all_staged; // Sort entries by path to maintain consistent order self.visible_entries @@ -805,7 +820,11 @@ impl GitPanel { .child( h_flex() .gap_2() - .child(Checkbox::new("all-changes", true.into()).disabled(true)) + .child(Checkbox::new( + "all-changes", + self.all_staged + .map_or(ToggleState::Indeterminate, ToggleState::from), + )) .child(div().text_buffer(cx).text_ui_sm(cx).child(changes_string)), ) .child(div().flex_grow()) @@ -814,27 +833,50 @@ impl GitPanel { .gap_2() .child( IconButton::new("discard-changes", IconName::Undo) - .tooltip(move |cx| { + .tooltip({ let focus_handle = focus_handle.clone(); - - Tooltip::for_action_in( - "Discard all changes", - &RevertAll, - &focus_handle, - cx, - ) + move |cx| { + Tooltip::for_action_in( + "Discard all changes", + &RevertAll, + &focus_handle, + cx, + ) + } }) .icon_size(IconSize::Small) .disabled(true), ) - .child(if self.all_staged() { - self.panel_button("unstage-all", "Unstage All").on_click( - cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(RevertAll))), - ) + .child(if self.all_staged.unwrap_or(false) { + self.panel_button("unstage-all", "Unstage All") + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Unstage all changes", + &UnstageAll, + &focus_handle, + cx, + ) + } + }) + .on_click( + cx.listener(move |this, _, cx| this.unstage_all(&UnstageAll, cx)), + ) } else { - self.panel_button("stage-all", "Stage All").on_click( - cx.listener(move |_, _, cx| cx.dispatch_action(Box::new(StageAll))), - ) + self.panel_button("stage-all", "Stage All") + .tooltip({ + let focus_handle = focus_handle.clone(); + move |cx| { + Tooltip::for_action_in( + "Stage all changes", + &StageAll, + &focus_handle, + cx, + ) + } + }) + .on_click(cx.listener(move |this, _, cx| this.stage_all(&StageAll, cx))) }), ) } @@ -1049,30 +1091,39 @@ impl GitPanel { entry = entry .child( - Checkbox::new(checkbox_id, entry_details.toggle_state) - .fill() - .elevation(ElevationIndex::Surface) - .on_click({ - let handle = handle.clone(); - let repo_path = repo_path.clone(); - move |toggle, cx| { - let Some(this) = handle.upgrade() else { - return; - }; - this.update(cx, |this, _| { - this.visible_entries[ix].toggle_state = *toggle; - }); - state.update(cx, { - let repo_path = repo_path.clone(); - move |state, _| match toggle { - ToggleState::Selected | ToggleState::Indeterminate => { - state.stage_entry(repo_path); - } - ToggleState::Unselected => state.unstage_entry(repo_path), + Checkbox::new( + checkbox_id, + entry_details + .is_staged + .map_or(ToggleState::Indeterminate, ToggleState::from), + ) + .fill() + .elevation(ElevationIndex::Surface) + .on_click({ + let handle = handle.clone(); + let repo_path = repo_path.clone(); + move |toggle, cx| { + let Some(this) = handle.upgrade() else { + return; + }; + this.update(cx, |this, _| { + this.visible_entries[ix].is_staged = match *toggle { + ToggleState::Selected => Some(true), + ToggleState::Unselected => Some(false), + ToggleState::Indeterminate => None, + } + }); + state.update(cx, { + let repo_path = repo_path.clone(); + move |state, _| match toggle { + ToggleState::Selected | ToggleState::Indeterminate => { + state.stage_entry(repo_path); } - }); - } - }), + ToggleState::Unselected => state.unstage_entry(repo_path), + } + }); + } + }), ) .child(git_status_icon(status)) .child( From 85b727c1a28b05d466169a7c6279e8b91fb84e87 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Mon, 13 Jan 2025 15:38:44 -0500 Subject: [PATCH 15/25] Remove inaccurate comments (#23056) These comments are inaccurate. Even if `convert_case` provided a way to customize which boundaries were used (which is now does, it 0.7.1), they would be removed from the string and replaced with the new boundary character (`-`, `_`, ...), and we'd lose the ability to reconstruct the text the way the author formatted it. This is not a hack, this is the way we have to do it. Release Notes: - N/A --- crates/editor/src/editor.rs | 4 ---- crates/editor/src/editor_tests.rs | 2 -- 2 files changed, 6 deletions(-) diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 22d9650beb067..4170f70d2b806 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -6203,8 +6203,6 @@ impl Editor { pub fn convert_to_title_case(&mut self, _: &ConvertToTitleCase, cx: &mut ViewContext) { self.manipulate_text(cx, |text| { - // Hack to get around the fact that to_case crate doesn't support '\n' as a word boundary - // https://github.com/rutrum/convert-case/issues/16 text.split('\n') .map(|line| line.to_case(Case::Title)) .join("\n") @@ -6225,8 +6223,6 @@ impl Editor { cx: &mut ViewContext, ) { self.manipulate_text(cx, |text| { - // Hack to get around the fact that to_case crate doesn't support '\n' as a word boundary - // https://github.com/rutrum/convert-case/issues/16 text.split('\n') .map(|line| line.to_case(Case::UpperCamel)) .join("\n") diff --git a/crates/editor/src/editor_tests.rs b/crates/editor/src/editor_tests.rs index e477013e68735..5770d0cf1a5e2 100644 --- a/crates/editor/src/editor_tests.rs +++ b/crates/editor/src/editor_tests.rs @@ -3705,7 +3705,6 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { "}); // Test multiple line, single selection case - // Test code hack that covers the fact that to_case crate doesn't support '\n' as a word boundary cx.set_state(indoc! {" «The quick brown fox jumps over @@ -3719,7 +3718,6 @@ async fn test_manipulate_text(cx: &mut TestAppContext) { "}); // Test multiple line, single selection case - // Test code hack that covers the fact that to_case crate doesn't support '\n' as a word boundary cx.set_state(indoc! {" «The quick brown fox jumps over From b633f62aa67340567f01c44247830809d35a8ef5 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 13:42:08 -0700 Subject: [PATCH 16/25] Add test that JSON schema generation works + actions build from no input (#23049) Release Notes: - N/A --- Cargo.lock | 1 - crates/languages/Cargo.toml | 1 - crates/languages/src/json.rs | 14 +------ crates/settings/src/keymap_file.rs | 14 ++++++- crates/zed/src/zed.rs | 67 ++++++++++++++++++++++++++++++ 5 files changed, 81 insertions(+), 16 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 3531ac8877ac3..0a544dcf65b0d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6819,7 +6819,6 @@ dependencies = [ "regex", "rope", "rust-embed", - "schemars", "serde", "serde_json", "settings", diff --git a/crates/languages/Cargo.toml b/crates/languages/Cargo.toml index fffe0698554a8..951423056e641 100644 --- a/crates/languages/Cargo.toml +++ b/crates/languages/Cargo.toml @@ -56,7 +56,6 @@ project.workspace = true regex.workspace = true rope.workspace = true rust-embed.workspace = true -schemars.workspace = true serde.workspace = true serde_json.workspace = true settings.workspace = true diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 07a28d6abd04a..6370b4c414120 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -10,7 +10,6 @@ use language::{LanguageRegistry, LanguageToolchainStore, LspAdapter, LspAdapterD use lsp::{LanguageServerBinary, LanguageServerName}; use node_runtime::NodeRuntime; use project::{lsp_store::language_server_settings, ContextProviderWithTasks}; -use schemars::gen::SchemaSettings; use serde_json::{json, Value}; use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; use smol::{ @@ -76,6 +75,7 @@ impl JsonLspAdapter { } fn get_workspace_config(language_names: Vec, cx: &mut AppContext) -> Value { + let keymap_schema = KeymapFile::generate_json_schema_for_registered_actions(cx); let font_names = &cx.text_system().all_font_names(); let settings_schema = cx.global::().json_schema( &SettingsJsonSchemaParams { @@ -115,7 +115,7 @@ impl JsonLspAdapter { }, { "fileMatch": [schema_file_match(paths::keymap_file())], - "schema": Self::generate_keymap_schema(cx), + "schema": keymap_schema, }, { "fileMatch": [ @@ -129,16 +129,6 @@ impl JsonLspAdapter { } }) } - - fn generate_keymap_schema(cx: &mut AppContext) -> Value { - let mut generator = SchemaSettings::draft07() - .with(|settings| settings.option_add_null_type = false) - .into_generator(); - - let action_schemas = cx.action_schemas(&mut generator); - let deprecations = cx.action_deprecations(); - KeymapFile::generate_json_schema(generator, action_schemas, deprecations) - } } #[async_trait(?Send)] diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 9b8a17c23e18c..e939bcef1528a 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -3,7 +3,7 @@ use anyhow::{anyhow, Context, Result}; use collections::{BTreeMap, HashMap}; use gpui::{Action, AppContext, KeyBinding, SharedString}; use schemars::{ - gen::SchemaGenerator, + gen::{SchemaGenerator, SchemaSettings}, schema::{ArrayValidation, InstanceType, Metadata, Schema, SchemaObject, SubschemaValidation}, JsonSchema, }; @@ -139,7 +139,17 @@ impl KeymapFile { Ok(()) } - pub fn generate_json_schema( + pub fn generate_json_schema_for_registered_actions(cx: &mut AppContext) -> Value { + let mut generator = SchemaSettings::draft07() + .with(|settings| settings.option_add_null_type = false) + .into_generator(); + + let action_schemas = cx.action_schemas(&mut generator); + let deprecations = cx.action_deprecations(); + KeymapFile::generate_json_schema(generator, action_schemas, deprecations) + } + + fn generate_json_schema( generator: SchemaGenerator, action_schemas: Vec<(SharedString, Option)>, deprecations: &HashMap, diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e8dd1bcba69a..28eb82daa049b 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -3502,6 +3502,73 @@ mod tests { assert_key_bindings_for(workspace.into(), cx, vec![("6", &Deploy)], line!()); } + #[gpui::test] + async fn test_generate_keymap_json_schema_for_registered_actions( + cx: &mut gpui::TestAppContext, + ) { + init_keymap_test(cx); + cx.update(|cx| { + // Make sure it doesn't panic. + KeymapFile::generate_json_schema_for_registered_actions(cx); + }); + } + + /// Actions that don't build from empty input won't work from command palette invocation. + #[gpui::test] + async fn test_actions_build_with_empty_input(cx: &mut gpui::TestAppContext) { + init_keymap_test(cx); + cx.update(|cx| { + let all_actions = cx.all_action_names(); + let mut failing_names = Vec::new(); + let mut errors = Vec::new(); + for action in all_actions { + match action.to_string().as_str() { + "vim::FindCommand" + | "vim::Literal" + | "vim::ResizePane" + | "vim::SwitchMode" + | "vim::PushOperator" + | "vim::Number" + | "vim::SelectRegister" + | "terminal::SendText" + | "terminal::SendKeystroke" + | "app_menu::OpenApplicationMenu" + | "app_menu::NavigateApplicationMenuInDirection" + | "picker::ConfirmInput" + | "editor::HandleInput" + | "editor::FoldAtLevel" + | "pane::ActivateItem" + | "workspace::ActivatePane" + | "workspace::ActivatePaneInDirection" + | "workspace::MoveItemToPane" + | "workspace::MoveItemToPaneInDirection" + | "workspace::OpenTerminal" + | "workspace::SwapPaneInDirection" + | "workspace::SendKeystrokes" + | "zed::OpenBrowser" + | "zed::OpenZedUrl" => {} + _ => { + let result = cx.build_action(action, None); + match &result { + Ok(_) => {} + Err(err) => { + failing_names.push(action); + errors.push(format!("{action} failed to build: {err:?}")); + } + } + } + } + } + if errors.len() > 0 { + panic!( + "Failed to build actions using {{}} as input: {:?}. Errors:\n{}", + failing_names, + errors.join("\n") + ); + } + }); + } + #[gpui::test] fn test_bundled_settings_and_themes(cx: &mut AppContext) { cx.text_system() From d4e91c1898ee60e990d7a2ae50e82d24b34e805a Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 13:56:22 -0700 Subject: [PATCH 17/25] Add support for namespace changes in action deprecations (#23086) cc @cole-miller Release Notes: - N/A --- crates/editor/src/actions.rs | 4 ++-- crates/gpui/src/action.rs | 20 ++++++++------------ 2 files changed, 10 insertions(+), 14 deletions(-) diff --git a/crates/editor/src/actions.rs b/crates/editor/src/actions.rs index 43226f0f62a87..97a4aefa0e0cb 100644 --- a/crates/editor/src/actions.rs +++ b/crates/editor/src/actions.rs @@ -1,6 +1,6 @@ //! This module contains all actions supported by [`Editor`]. use super::*; -use gpui::{action_aliases, action_as}; +use gpui::{action_as, action_with_deprecated_aliases}; use schemars::JsonSchema; use util::serde::default_true; @@ -394,4 +394,4 @@ gpui::actions!( action_as!(go_to_line, ToggleGoToLine as Toggle); -action_aliases!(editor, OpenSelectedFilename, [OpenFile]); +action_with_deprecated_aliases!(editor, OpenSelectedFilename, ["editor::OpenFile"]); diff --git a/crates/gpui/src/action.rs b/crates/gpui/src/action.rs index 06029676650bd..ee285b896bf04 100644 --- a/crates/gpui/src/action.rs +++ b/crates/gpui/src/action.rs @@ -286,7 +286,9 @@ macro_rules! action_as { // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] #[doc = "action generated by `gpui::action_as!`"] - #[derive(::std::clone::Clone, ::std::cmp::PartialEq, ::std::default::Default)] + #[derive( + ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, + )] pub struct $name; gpui::__impl_action!( @@ -311,21 +313,15 @@ macro_rules! action_as { /// Defines and registers a unit struct that can be used as an action, with some deprecated aliases. #[macro_export] -macro_rules! action_aliases { - ($namespace:path, $name:ident, [$($alias:ident),* $(,)?]) => { +macro_rules! action_with_deprecated_aliases { + ($namespace:path, $name:ident, [$($alias:literal),* $(,)?]) => { // Unfortunately rust-analyzer doesn't display the name due to // https://github.com/rust-lang/rust-analyzer/issues/8092 #[doc = stringify!($name)] - #[doc = "action, generated by `gpui::action_aliases!`"] + #[doc = "action, generated by `gpui::action_with_deprecated_aliases!`"] #[derive( - ::std::cmp::PartialEq, - ::std::clone::Clone, - ::std::default::Default, - ::std::fmt::Debug, - gpui::private::serde_derive::Deserialize, - gpui::private::schemars::JsonSchema, + ::std::clone::Clone, ::std::default::Default, ::std::fmt::Debug, ::std::cmp::PartialEq, )] - #[serde(crate = "gpui::private::serde")] pub struct $name; gpui::__impl_action!( @@ -345,7 +341,7 @@ macro_rules! action_aliases { }, fn deprecated_aliases() -> &'static [&'static str] { &[ - $(concat!(stringify!($namespace), "::", stringify!($alias))),* + $($alias),* ] } ); From 7c2c409f6dbc199e3e24658e2d0a26363274aa68 Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 14:00:03 -0700 Subject: [PATCH 18/25] Show configuration in language server debug logs (#23084) Release Notes: - Added configuration sent on initialization to the `Server Info` section of the language server logs. --- crates/copilot/src/copilot.rs | 9 ++++++++- crates/language_tools/src/lsp_log.rs | 6 +++++- crates/languages/src/json.rs | 2 ++ crates/lsp/src/lsp.rs | 25 ++++++++++++++++++++++++- crates/prettier/src/prettier.rs | 9 ++++++++- crates/project/src/lsp_store.rs | 15 ++++++++++----- crates/settings/src/keymap_file.rs | 2 ++ 7 files changed, 59 insertions(+), 9 deletions(-) diff --git a/crates/copilot/src/copilot.rs b/crates/copilot/src/copilot.rs index 72e42514883ba..67280765f6643 100644 --- a/crates/copilot/src/copilot.rs +++ b/crates/copilot/src/copilot.rs @@ -460,7 +460,14 @@ impl Copilot { server .on_notification::(|_, _| { /* Silence the notification */ }) .detach(); - let server = cx.update(|cx| server.initialize(None, cx))?.await?; + + let initialize_params = None; + let configuration = lsp::DidChangeConfigurationParams { + settings: Default::default(), + }; + let server = cx + .update(|cx| server.initialize(initialize_params, configuration.into(), cx))? + .await?; let status = server .request::(request::CheckStatusParams { diff --git a/crates/language_tools/src/lsp_log.rs b/crates/language_tools/src/lsp_log.rs index e55a6e2b8eca1..184a06f21589a 100644 --- a/crates/language_tools/src/lsp_log.rs +++ b/crates/language_tools/src/lsp_log.rs @@ -732,13 +732,17 @@ impl LspLogView { * Running in project: {PATH:?} -* Capabilities: {CAPABILITIES}", +* Capabilities: {CAPABILITIES} + +* Configuration: {CONFIGURATION}", NAME = server.name(), ID = server.server_id(), BINARY = server.binary(), PATH = server.root_path(), CAPABILITIES = serde_json::to_string_pretty(&server.capabilities()) .unwrap_or_else(|e| format!("Failed to serialize capabilities: {e}")), + CONFIGURATION = serde_json::to_string_pretty(server.configuration()) + .unwrap_or_else(|e| format!("Failed to serialize configuration: {e}")), ); editor.set_text(server_info, cx); editor.set_read_only(true); diff --git a/crates/languages/src/json.rs b/crates/languages/src/json.rs index 6370b4c414120..a783195db32bb 100644 --- a/crates/languages/src/json.rs +++ b/crates/languages/src/json.rs @@ -88,6 +88,8 @@ impl JsonLspAdapter { let tsconfig_schema = serde_json::Value::from_str(TSCONFIG_SCHEMA).unwrap(); let package_json_schema = serde_json::Value::from_str(PACKAGE_JSON_SCHEMA).unwrap(); + // This can be viewed via `debug: open language server logs` -> `json-language-server` -> + // `Server Info` serde_json::json!({ "json": { "format": { diff --git a/crates/lsp/src/lsp.rs b/crates/lsp/src/lsp.rs index 3999240d89510..e9fa1caac2398 100644 --- a/crates/lsp/src/lsp.rs +++ b/crates/lsp/src/lsp.rs @@ -84,6 +84,10 @@ pub struct LanguageServer { process_name: Arc, binary: LanguageServerBinary, capabilities: RwLock, + /// Configuration sent to the server, stored for display in the language server logs + /// buffer. This is represented as the message sent to the LSP in order to avoid cloning it (can + /// be large in cases like sending schemas to the json server). + configuration: Arc, code_action_kinds: Option>, notification_handlers: Arc>>, response_handlers: Arc>>>, @@ -459,6 +463,11 @@ impl LanguageServer { .log_err() }); + let configuration = DidChangeConfigurationParams { + settings: Value::Null, + } + .into(); + Self { server_id, notification_handlers, @@ -472,6 +481,7 @@ impl LanguageServer { .unwrap_or_default(), binary, capabilities: Default::default(), + configuration, code_action_kinds, next_id: Default::default(), outbound_tx, @@ -800,6 +810,7 @@ impl LanguageServer { pub fn initialize( mut self, initialize_params: Option, + configuration: Arc, cx: &AppContext, ) -> Task>> { let params = if let Some(params) = initialize_params { @@ -814,6 +825,7 @@ impl LanguageServer { self.process_name = info.name.into(); } self.capabilities = RwLock::new(response.capabilities); + self.configuration = configuration; self.notify::(&InitializedParams {})?; Ok(Arc::new(self)) @@ -1049,6 +1061,10 @@ impl LanguageServer { update(self.capabilities.write().deref_mut()); } + pub fn configuration(&self) -> &Value { + &self.configuration.settings + } + /// Get the id of the running language server. pub fn server_id(&self) -> LanguageServerId { self.server_id @@ -1538,7 +1554,14 @@ mod tests { }) .detach(); - let server = cx.update(|cx| server.initialize(None, cx)).await.unwrap(); + let initialize_params = None; + let configuration = DidChangeConfigurationParams { + settings: Default::default(), + }; + let server = cx + .update(|cx| server.initialize(initialize_params, configuration.into(), cx)) + .await + .unwrap(); server .notify::(&DidOpenTextDocumentParams { text_document: TextDocumentItem::new( diff --git a/crates/prettier/src/prettier.rs b/crates/prettier/src/prettier.rs index d4c1654d92577..b9fcd8df0e245 100644 --- a/crates/prettier/src/prettier.rs +++ b/crates/prettier/src/prettier.rs @@ -282,8 +282,15 @@ impl Prettier { cx.clone(), ) .context("prettier server creation")?; + + let initialize_params = None; + let configuration = lsp::DidChangeConfigurationParams { + settings: Default::default(), + }; let server = cx - .update(|cx| executor.spawn(server.initialize(None, cx)))? + .update(|cx| { + executor.spawn(server.initialize(initialize_params, configuration.into(), cx)) + })? .await .context("prettier server initialization")?; Ok(Self::Real(RealPrettier { diff --git a/crates/project/src/lsp_store.rs b/crates/project/src/lsp_store.rs index 61294f0b0e6c2..a7afdc6637163 100644 --- a/crates/project/src/lsp_store.rs +++ b/crates/project/src/lsp_store.rs @@ -286,9 +286,17 @@ impl LocalLspStore { Self::setup_lsp_messages(this.clone(), &language_server, delegate, adapter); + let did_change_configuration_params = + Arc::new(lsp::DidChangeConfigurationParams { + settings: workspace_config, + }); let language_server = cx .update(|cx| { - language_server.initialize(Some(initialization_params), cx) + language_server.initialize( + Some(initialization_params), + did_change_configuration_params.clone(), + cx, + ) })? .await .inspect_err(|_| { @@ -302,9 +310,7 @@ impl LocalLspStore { language_server .notify::( - &lsp::DidChangeConfigurationParams { - settings: workspace_config, - }, + &did_change_configuration_params, ) .ok(); @@ -7636,7 +7642,6 @@ impl LspStore { continue; } } - if progress.is_cancellable { server .notify::( diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index e939bcef1528a..21dc144c64927 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -265,6 +265,8 @@ impl KeymapFile { .definitions .insert("KeymapAction".to_owned(), action_schema); + // This and other json schemas can be viewed via `debug: open language server logs` -> + // `json-language-server` -> `Server Info`. serde_json::to_value(root_schema).unwrap() } From 4054d4a5b73be1829dc35b937ab25a6b94fbfb16 Mon Sep 17 00:00:00 2001 From: Agus Zubiaga Date: Mon, 13 Jan 2025 18:00:20 -0300 Subject: [PATCH 19/25] assistant2: Fix inline context picker and handle dismiss (#23081) The new `ContextMenu`-based `ContextPicker` requires initialization when opened, but we were only doing this for the `ContextStrip` picker, not the inline one. Additionally, because we have a wrapper element around ContextMenu, we need to propagate the `DismissEvent` so that it properly closes when Escape is pressed. Release Notes: - N/A --- crates/assistant2/src/context_picker.rs | 26 ++++++++++++++----- .../directory_context_picker.rs | 3 +-- .../context_picker/fetch_context_picker.rs | 3 +-- .../src/context_picker/file_context_picker.rs | 3 +-- .../context_picker/thread_context_picker.rs | 3 +-- crates/assistant2/src/context_strip.rs | 2 +- crates/assistant2/src/message_editor.rs | 8 +++++- crates/ui/src/components/context_menu.rs | 11 +++++++- 8 files changed, 42 insertions(+), 17 deletions(-) diff --git a/crates/assistant2/src/context_picker.rs b/crates/assistant2/src/context_picker.rs index 85fc282ca2177..4f8e9fd3064f9 100644 --- a/crates/assistant2/src/context_picker.rs +++ b/crates/assistant2/src/context_picker.rs @@ -65,14 +65,15 @@ impl ContextPicker { } } - pub fn reset_mode(&mut self, cx: &mut ViewContext) { - self.mode = ContextPickerMode::Default(self.build(cx)); + pub fn init(&mut self, cx: &mut ViewContext) { + self.mode = ContextPickerMode::Default(self.build_menu(cx)); + cx.notify(); } - fn build(&mut self, cx: &mut ViewContext) -> View { + fn build_menu(&mut self, cx: &mut ViewContext) -> View { let context_picker = cx.view().clone(); - ContextMenu::build(cx, move |menu, cx| { + let menu = ContextMenu::build(cx, move |menu, cx| { let kind_entry = |kind: &'static ContextKind| { let context_picker = context_picker.clone(); @@ -90,11 +91,24 @@ impl ContextPicker { .enumerate() .map(|(ix, entry)| self.recent_menu_item(context_picker.clone(), ix, entry)); - menu.when(has_recent, |menu| menu.label("Recent")) + let menu = menu + .when(has_recent, |menu| menu.label("Recent")) .extend(recent_entries) .when(has_recent, |menu| menu.separator()) - .extend(ContextKind::all().into_iter().map(kind_entry)) + .extend(ContextKind::all().into_iter().map(kind_entry)); + + match self.confirm_behavior { + ConfirmBehavior::KeepOpen => menu.keep_open_on_confirm(), + ConfirmBehavior::Close => menu, + } + }); + + cx.subscribe(&menu, move |_, _, _: &DismissEvent, cx| { + cx.emit(DismissEvent); }) + .detach(); + + menu } fn select_kind(&mut self, kind: ContextKind, cx: &mut ViewContext) { diff --git a/crates/assistant2/src/context_picker/directory_context_picker.rs b/crates/assistant2/src/context_picker/directory_context_picker.rs index 969e0e7f43bd4..fce39fec6d919 100644 --- a/crates/assistant2/src/context_picker/directory_context_picker.rs +++ b/crates/assistant2/src/context_picker/directory_context_picker.rs @@ -221,8 +221,7 @@ impl PickerDelegate for DirectoryContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/fetch_context_picker.rs b/crates/assistant2/src/context_picker/fetch_context_picker.rs index dd23ed7c9fb3c..7bb089e82b920 100644 --- a/crates/assistant2/src/context_picker/fetch_context_picker.rs +++ b/crates/assistant2/src/context_picker/fetch_context_picker.rs @@ -222,8 +222,7 @@ impl PickerDelegate for FetchContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/file_context_picker.rs b/crates/assistant2/src/context_picker/file_context_picker.rs index 282c4c70b982b..533f793bed025 100644 --- a/crates/assistant2/src/context_picker/file_context_picker.rs +++ b/crates/assistant2/src/context_picker/file_context_picker.rs @@ -239,8 +239,7 @@ impl PickerDelegate for FileContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_picker/thread_context_picker.rs b/crates/assistant2/src/context_picker/thread_context_picker.rs index af52ec7172811..bea32d3d043ee 100644 --- a/crates/assistant2/src/context_picker/thread_context_picker.rs +++ b/crates/assistant2/src/context_picker/thread_context_picker.rs @@ -176,8 +176,7 @@ impl PickerDelegate for ThreadContextPickerDelegate { fn dismissed(&mut self, cx: &mut ViewContext>) { self.context_picker - .update(cx, |this, cx| { - this.reset_mode(cx); + .update(cx, |_, cx| { cx.emit(DismissEvent); }) .ok(); diff --git a/crates/assistant2/src/context_strip.rs b/crates/assistant2/src/context_strip.rs index 10ac4edd7e4ee..d9689192c907e 100644 --- a/crates/assistant2/src/context_strip.rs +++ b/crates/assistant2/src/context_strip.rs @@ -168,7 +168,7 @@ impl Render for ContextStrip { PopoverMenu::new("context-picker") .menu(move |cx| { context_picker.update(cx, |this, cx| { - this.reset_mode(cx); + this.init(cx); }); Some(context_picker.clone()) diff --git a/crates/assistant2/src/message_editor.rs b/crates/assistant2/src/message_editor.rs index cd4c5899ee773..f1092f16deb93 100644 --- a/crates/assistant2/src/message_editor.rs +++ b/crates/assistant2/src/message_editor.rs @@ -281,7 +281,13 @@ impl Render for MessageEditor { }) .child( PopoverMenu::new("inline-context-picker") - .menu(move |_cx| Some(inline_context_picker.clone())) + .menu(move |cx| { + inline_context_picker.update(cx, |this, cx| { + this.init(cx); + }); + + Some(inline_context_picker.clone()) + }) .attach(gpui::Corner::TopLeft) .anchor(gpui::Corner::BottomLeft) .offset(gpui::Point { diff --git a/crates/ui/src/components/context_menu.rs b/crates/ui/src/components/context_menu.rs index 53f2a62bb3b50..38d151c8b0ed3 100644 --- a/crates/ui/src/components/context_menu.rs +++ b/crates/ui/src/components/context_menu.rs @@ -112,6 +112,7 @@ pub struct ContextMenu { delayed: bool, clicked: bool, _on_blur_subscription: Subscription, + keep_open_on_confirm: bool, } impl FocusableView for ContextMenu { @@ -144,6 +145,7 @@ impl ContextMenu { delayed: false, clicked: false, _on_blur_subscription, + keep_open_on_confirm: true, }, cx, ) @@ -304,6 +306,11 @@ impl ContextMenu { self } + pub fn keep_open_on_confirm(mut self) -> Self { + self.keep_open_on_confirm = true; + self + } + pub fn confirm(&mut self, _: &menu::Confirm, cx: &mut ViewContext) { let context = self.action_context.as_ref(); if let Some( @@ -318,7 +325,9 @@ impl ContextMenu { (handler)(context, cx) } - cx.emit(DismissEvent); + if !self.keep_open_on_confirm { + cx.emit(DismissEvent); + } } pub fn cancel(&mut self, _: &menu::Cancel, cx: &mut ViewContext) { From ac2d3eec91538979a27c8c048c5d68a8311c0311 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 16:02:45 -0500 Subject: [PATCH 20/25] Remove commented-out code (#23089) This PR removes some commented-out code from the codebase. Release Notes: - N/A --- crates/rpc/src/proto_client.rs | 1 - crates/theme/src/styles/colors.rs | 6 ------ 2 files changed, 7 deletions(-) diff --git a/crates/rpc/src/proto_client.rs b/crates/rpc/src/proto_client.rs index 9288416d5720b..e1e2c5ed09795 100644 --- a/crates/rpc/src/proto_client.rs +++ b/crates/rpc/src/proto_client.rs @@ -5,7 +5,6 @@ use futures::{ Future, FutureExt as _, }; use gpui::{AnyModel, AnyWeakModel, AsyncAppContext, Model}; -// pub use prost::Message; use proto::{ error::ErrorExt as _, AnyTypedEnvelope, EntityMessage, Envelope, EnvelopedMessage, RequestMessage, TypedEnvelope, diff --git a/crates/theme/src/styles/colors.rs b/crates/theme/src/styles/colors.rs index 62ab46610ace4..4717c63f5d786 100644 --- a/crates/theme/src/styles/colors.rs +++ b/crates/theme/src/styles/colors.rs @@ -57,8 +57,6 @@ pub struct ThemeColors { pub element_disabled: Hsla, /// Background Color. Used for the area that shows where a dragged element will be dropped. pub drop_target_background: Hsla, - /// Border Color. Used to show the area that shows where a dragged element will be dropped. - // pub drop_target_border: Hsla, /// Used for the background of a ghost element that should have the same background as the surface it's on. /// /// Elements might include: Buttons, Inputs, Checkboxes, Radio Buttons... @@ -140,16 +138,12 @@ pub struct ThemeColors { pub scrollbar_track_background: Hsla, /// The border color of the scrollbar track. pub scrollbar_track_border: Hsla, - // /// The opacity of the scrollbar status marks, like diagnostic states and git status. - // todo() - // pub scrollbar_status_opacity: Hsla, // === // Editor // === pub editor_foreground: Hsla, pub editor_background: Hsla, - // pub editor_inactive_background: Hsla, pub editor_gutter_background: Hsla, pub editor_subheader_background: Hsla, pub editor_active_line_background: Hsla, From 867c069b99c738af8549f0e9df5b7221f8cdf32e Mon Sep 17 00:00:00 2001 From: Piotr Osiewicz <24362066+osiewicz@users.noreply.github.com> Date: Mon, 13 Jan 2025 22:08:46 +0100 Subject: [PATCH 21/25] editor: Adjust offset of the opened jump target in the multibuffer (#23091) This PR fixes an issue with jumping from multi_buffer to a file; namely, the scroll offset of the opened buffer used to match the position within the multibuffer, but it broke a while back. This is because we were opening a buffer without providing the data about the origin scroll offset. Closes #ISSUE Release Notes: - Fixed a bug where the relative position of an excerpt within the multibuffer was not accounted for while jumping to the buffer, causing the clicked line to drastically change position on screen. --- crates/editor/src/element.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/editor/src/element.rs b/crates/editor/src/element.rs index 0a468deb901a9..c5a64c7651bd9 100644 --- a/crates/editor/src/element.rs +++ b/crates/editor/src/element.rs @@ -543,8 +543,29 @@ impl EditorElement { // and run the selection logic. modifiers.alt = false; } else { + let scroll_position_row = + position_map.scroll_pixel_position.y / position_map.line_height; + let display_row = (((event.position - gutter_hitbox.bounds.origin).y + + position_map.scroll_pixel_position.y) + / position_map.line_height) + as u32; + let multi_buffer_row = position_map + .snapshot + .display_point_to_point( + DisplayPoint::new(DisplayRow(display_row), 0), + Bias::Right, + ) + .row; + let line_offset_from_top = display_row - scroll_position_row as u32; // if double click is made without alt, open the corresponding excerp - editor.open_excerpts(&OpenExcerpts, cx); + editor.open_excerpts_common( + Some(JumpData::MultiBufferRow { + row: MultiBufferRow(multi_buffer_row), + line_offset_from_top, + }), + false, + cx, + ); return; } } From c599ba64bc3c87635df76c47b0dd7b5cb5632c40 Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 16:09:01 -0500 Subject: [PATCH 22/25] assistant2: Only show the streaming indicator on the last Assistant message (#23090) This PR is a follow-up to #23078 to ensure that the streaming indicator only shows up on the last Assistant message. Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 58dc92ebe2cd3..5ef33c9352c73 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -242,6 +242,7 @@ impl ActiveThread { fn render_message(&self, ix: usize, cx: &mut ViewContext) -> AnyElement { let message_id = self.messages[ix]; + let is_last_message = ix == self.messages.len() - 1; let Some(message) = self.thread.read(cx).message(message_id) else { return Empty.into_any(); }; @@ -294,7 +295,9 @@ impl ActiveThread { ) .child(div().p_2p5().text_ui(cx).child(markdown.clone())) .when( - message.role == Role::Assistant && is_streaming_completion, + message.role == Role::Assistant + && is_last_message + && is_streaming_completion, |parent| { parent.child( h_flex() From ae103fdf641dc3ef4dacd859acc99ddefd45c62a Mon Sep 17 00:00:00 2001 From: Michael Sloan Date: Mon, 13 Jan 2025 14:53:12 -0700 Subject: [PATCH 23/25] Fix confusing keymap json errors and hovers for nonexistent actions (#23098) Release Notes: - N/A --- crates/settings/src/keymap_file.rs | 120 ++++++++++++++++++++--------- 1 file changed, 84 insertions(+), 36 deletions(-) diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 21dc144c64927..b8c9cff21f393 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -1,10 +1,10 @@ use crate::{settings_store::parse_json_with_comments, SettingsAssets}; use anyhow::{anyhow, Context, Result}; use collections::{BTreeMap, HashMap}; -use gpui::{Action, AppContext, KeyBinding, SharedString}; +use gpui::{Action, AppContext, KeyBinding, NoAction, SharedString}; use schemars::{ gen::{SchemaGenerator, SchemaSettings}, - schema::{ArrayValidation, InstanceType, Metadata, Schema, SchemaObject, SubschemaValidation}, + schema::{ArrayValidation, InstanceType, Schema, SchemaObject, SubschemaValidation}, JsonSchema, }; use serde::Deserialize; @@ -161,21 +161,68 @@ impl KeymapFile { Some(input.into()) } - fn add_deprecation_notice(schema_object: &mut SchemaObject, new_name: &SharedString) { + fn add_deprecation(schema_object: &mut SchemaObject, message: String) { schema_object.extensions.insert( // deprecationMessage is not part of the JSON Schema spec, // but json-language-server recognizes it. "deprecationMessage".to_owned(), - format!("Deprecated, use {new_name}").into(), + Value::String(message), ); } + fn add_deprecation_preferred_name(schema_object: &mut SchemaObject, new_name: &str) { + add_deprecation(schema_object, format!("Deprecated, use {new_name}")); + } + + fn add_description(schema_object: &mut SchemaObject, description: String) { + schema_object + .metadata + .get_or_insert(Default::default()) + .description = Some(description); + } + let empty_object: SchemaObject = SchemaObject { instance_type: set(InstanceType::Object), ..Default::default() }; - let mut keymap_action_alternatives = Vec::new(); + // This is a workaround for a json-language-server issue where it matches the first + // alternative that matches the value's shape and uses that for documentation. + // + // In the case of the array validations, it would even provide an error saying that the name + // must match the name of the first alternative. + let mut plain_action = SchemaObject { + instance_type: set(InstanceType::String), + const_value: Some(Value::String("".to_owned())), + ..Default::default() + }; + let no_action_message = "No action named this."; + add_description(&mut plain_action, no_action_message.to_owned()); + add_deprecation(&mut plain_action, no_action_message.to_owned()); + let mut matches_action_name = SchemaObject { + const_value: Some(Value::String("".to_owned())), + ..Default::default() + }; + let no_action_message = "No action named this that takes input."; + add_description(&mut matches_action_name, no_action_message.to_owned()); + add_deprecation(&mut matches_action_name, no_action_message.to_owned()); + let action_with_input = SchemaObject { + instance_type: set(InstanceType::Array), + array: set(ArrayValidation { + items: set(vec![ + matches_action_name.into(), + // Accept any value, as we want this to be the preferred match when there is a + // typo in the name. + Schema::Bool(true), + ]), + min_items: Some(2), + max_items: Some(2), + ..Default::default() + }), + ..Default::default() + }; + let mut keymap_action_alternatives = vec![plain_action.into(), action_with_input.into()]; + for (name, action_schema) in action_schemas.iter() { let schema = if let Some(Schema::Object(schema)) = action_schema { Some(schema.clone()) @@ -183,60 +230,61 @@ impl KeymapFile { None }; - // If the type has a description, also apply it to the value. Ideally it would be - // removed and applied to the overall array, but `json-language-server` does not show - // these descriptions. let description = schema.as_ref().and_then(|schema| { schema .metadata .as_ref() - .and_then(|metadata| metadata.description.as_ref()) + .and_then(|metadata| metadata.description.clone()) }); - let mut matches_action_name = SchemaObject { - const_value: Some(Value::String(name.to_string())), - ..Default::default() + + let deprecation = if name == NoAction.name() { + Some("null") + } else { + deprecations.get(name).map(|new_name| new_name.as_ref()) }; - if let Some(description) = description { - matches_action_name.metadata = set(Metadata { - description: Some(description.clone()), - ..Default::default() - }); - } // Add an alternative for plain action names. - let deprecation = deprecations.get(name); let mut plain_action = SchemaObject { instance_type: set(InstanceType::String), const_value: Some(Value::String(name.to_string())), ..Default::default() }; if let Some(new_name) = deprecation { - add_deprecation_notice(&mut plain_action, new_name); + add_deprecation_preferred_name(&mut plain_action, new_name); + } + if let Some(description) = description.clone() { + add_description(&mut plain_action, description); } keymap_action_alternatives.push(plain_action.into()); - // When all fields are skipped or an empty struct is added with impl_actions! / - // impl_actions_as! an empty struct is produced. The action should be invoked without - // data in this case. + // Add an alternative for actions with data specified as a [name, data] array. + // + // When a struct with no deserializable fields is added with impl_actions! / + // impl_actions_as! an empty object schema is produced. The action should be invoked + // without data in this case. if let Some(schema) = schema { if schema != empty_object { - let mut action_with_data = SchemaObject { - instance_type: set(InstanceType::Array), - array: Some( - ArrayValidation { - items: set(vec![matches_action_name.into(), schema.into()]), - min_items: Some(2), - max_items: Some(2), - ..Default::default() - } - .into(), - ), + let mut matches_action_name = SchemaObject { + const_value: Some(Value::String(name.to_string())), ..Default::default() }; + if let Some(description) = description.clone() { + add_description(&mut matches_action_name, description.to_string()); + } if let Some(new_name) = deprecation { - add_deprecation_notice(&mut action_with_data, new_name); + add_deprecation_preferred_name(&mut matches_action_name, new_name); } - keymap_action_alternatives.push(action_with_data.into()); + let action_with_input = SchemaObject { + instance_type: set(InstanceType::Array), + array: set(ArrayValidation { + items: set(vec![matches_action_name.into(), schema.into()]), + min_items: Some(2), + max_items: Some(2), + ..Default::default() + }), + ..Default::default() + }; + keymap_action_alternatives.push(action_with_input.into()); } } } From 830f45e56abf4f78e62253f2f0f658b5ba4a4dca Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 17:03:45 -0500 Subject: [PATCH 24/25] assistant2: Add floating indicator when a response is streaming (#23096) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR adds a separate indicator at the bottom of the thread that shows when a response is being streamed (as well as how to cancel it): Screenshot 2025-01-13 at 4 19 07 PM Release Notes: - N/A --- crates/assistant2/src/active_thread.rs | 27 ++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/assistant2/src/active_thread.rs b/crates/assistant2/src/active_thread.rs index 5ef33c9352c73..86a1eb50fec49 100644 --- a/crates/assistant2/src/active_thread.rs +++ b/crates/assistant2/src/active_thread.rs @@ -344,7 +344,30 @@ impl ActiveThread { } impl Render for ActiveThread { - fn render(&mut self, _cx: &mut ViewContext) -> impl IntoElement { - list(self.list_state.clone()).flex_1().py_1() + fn render(&mut self, cx: &mut ViewContext) -> impl IntoElement { + let is_streaming_completion = self.thread.read(cx).is_streaming(); + + v_flex() + .size_full() + .child(list(self.list_state.clone()).flex_grow()) + .child( + h_flex() + .absolute() + .bottom_1() + .flex_shrink() + .justify_center() + .w_full() + .when(is_streaming_completion, |parent| { + parent.child( + h_flex() + .gap_2() + .p_1p5() + .rounded_md() + .bg(cx.theme().colors().elevated_surface_background) + .child(Label::new("Generating…").size(LabelSize::Small)) + .child(Label::new("esc to cancel").size(LabelSize::Small)), + ) + }), + ) } } From 93f117b21a07cb0e5c41682658339a473794d2da Mon Sep 17 00:00:00 2001 From: Marshall Bowers Date: Mon, 13 Jan 2025 17:25:58 -0500 Subject: [PATCH 25/25] Improve registration for Assistant code action providers (#23099) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This PR is a follow-up to https://github.com/zed-industries/zed/pull/22911 to further improve the registration of code action providers for the Assistant in order to prevent duplicates. The `CodeActionProvider` trait now has an `id` method that is used to return a unique ID for a code action provider. We use this to prevent registering duplicates of the same provider. The registration of the code action providers for Assistant1 and Assistant2 have also been reworked. Previously we were not call the registration function—and thus setting up the subscriptions—until we resolved the feature flags. However, this could lead to the registration happening too late for existing workspace items. We now perform the registration right away and then remove the undesired code action providers once the feature flags have been resolved. Release Notes: - N/A --- crates/assistant/src/inline_assistant.rs | 45 ++++++++++++------ crates/assistant2/src/inline_assistant.rs | 58 +++++++++++++++-------- crates/editor/src/editor.rs | 22 ++++++++- 3 files changed, 90 insertions(+), 35 deletions(-) diff --git a/crates/assistant/src/inline_assistant.rs b/crates/assistant/src/inline_assistant.rs index 4f43f92edec31..8acdaec50f4cf 100644 --- a/crates/assistant/src/inline_assistant.rs +++ b/crates/assistant/src/inline_assistant.rs @@ -72,16 +72,16 @@ pub fn init( ) { cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); cx.observe_new_views(|_, cx| { + let workspace = cx.view().clone(); + InlineAssistant::update_global(cx, |inline_assistant, cx| { + inline_assistant.register_workspace(&workspace, cx) + }); + cx.observe_flag::({ |is_assistant2_enabled, _view, cx| { - if is_assistant2_enabled { - // Assistant2 enabled, nothing to do for Assistant1. - } else { - let workspace = cx.view().clone(); - InlineAssistant::update_global(cx, |inline_assistant, cx| { - inline_assistant.register_workspace(&workspace, cx) - }) - } + InlineAssistant::update_global(cx, |inline_assistant, _cx| { + inline_assistant.is_assistant2_enabled = is_assistant2_enabled; + }); } }) .detach(); @@ -102,6 +102,7 @@ pub struct InlineAssistant { prompt_builder: Arc, telemetry: Arc, fs: Arc, + is_assistant2_enabled: bool, } impl Global for InlineAssistant {} @@ -123,6 +124,7 @@ impl InlineAssistant { prompt_builder, telemetry, fs, + is_assistant2_enabled: false, } } @@ -183,15 +185,22 @@ impl InlineAssistant { item: &dyn ItemHandle, cx: &mut WindowContext, ) { + let is_assistant2_enabled = self.is_assistant2_enabled; + if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - editor.push_code_action_provider( - Rc::new(AssistantCodeActionProvider { - editor: cx.view().downgrade(), - workspace: workspace.downgrade(), - }), - cx, - ); + if is_assistant2_enabled { + editor + .remove_code_action_provider(ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), cx); + } else { + editor.add_code_action_provider( + Rc::new(AssistantCodeActionProvider { + editor: cx.view().downgrade(), + workspace: workspace.downgrade(), + }), + cx, + ); + } }); } } @@ -3437,7 +3446,13 @@ struct AssistantCodeActionProvider { workspace: WeakView, } +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant"; + impl CodeActionProvider for AssistantCodeActionProvider { + fn id(&self) -> Arc { + ASSISTANT_CODE_ACTION_PROVIDER_ID.into() + } + fn code_actions( &self, buffer: &Model, diff --git a/crates/assistant2/src/inline_assistant.rs b/crates/assistant2/src/inline_assistant.rs index 4367b3641ac5d..73e539473f9bf 100644 --- a/crates/assistant2/src/inline_assistant.rs +++ b/crates/assistant2/src/inline_assistant.rs @@ -51,14 +51,16 @@ pub fn init( ) { cx.set_global(InlineAssistant::new(fs, prompt_builder, telemetry)); cx.observe_new_views(|_workspace: &mut Workspace, cx| { + let workspace = cx.view().clone(); + InlineAssistant::update_global(cx, |inline_assistant, cx| { + inline_assistant.register_workspace(&workspace, cx) + }); + cx.observe_flag::({ |is_assistant2_enabled, _view, cx| { - if is_assistant2_enabled { - let workspace = cx.view().clone(); - InlineAssistant::update_global(cx, |inline_assistant, cx| { - inline_assistant.register_workspace(&workspace, cx) - }) - } + InlineAssistant::update_global(cx, |inline_assistant, _cx| { + inline_assistant.is_assistant2_enabled = is_assistant2_enabled; + }); } }) .detach(); @@ -84,6 +86,7 @@ pub struct InlineAssistant { prompt_builder: Arc, telemetry: Arc, fs: Arc, + is_assistant2_enabled: bool, } impl Global for InlineAssistant {} @@ -105,6 +108,7 @@ impl InlineAssistant { prompt_builder, telemetry, fs, + is_assistant2_enabled: false, } } @@ -165,21 +169,31 @@ impl InlineAssistant { item: &dyn ItemHandle, cx: &mut WindowContext, ) { + let is_assistant2_enabled = self.is_assistant2_enabled; + if let Some(editor) = item.act_as::(cx) { editor.update(cx, |editor, cx| { - let thread_store = workspace - .read(cx) - .panel::(cx) - .map(|assistant_panel| assistant_panel.read(cx).thread_store().downgrade()); - - editor.push_code_action_provider( - Rc::new(AssistantCodeActionProvider { - editor: cx.view().downgrade(), - workspace: workspace.downgrade(), - thread_store, - }), - cx, - ); + if is_assistant2_enabled { + let thread_store = workspace + .read(cx) + .panel::(cx) + .map(|assistant_panel| assistant_panel.read(cx).thread_store().downgrade()); + + editor.add_code_action_provider( + Rc::new(AssistantCodeActionProvider { + editor: cx.view().downgrade(), + workspace: workspace.downgrade(), + thread_store, + }), + cx, + ); + + // Remove the Assistant1 code action provider, as it still might be registered. + editor.remove_code_action_provider("assistant".into(), cx); + } else { + editor + .remove_code_action_provider(ASSISTANT_CODE_ACTION_PROVIDER_ID.into(), cx); + } }); } } @@ -1581,7 +1595,13 @@ struct AssistantCodeActionProvider { thread_store: Option>, } +const ASSISTANT_CODE_ACTION_PROVIDER_ID: &str = "assistant2"; + impl CodeActionProvider for AssistantCodeActionProvider { + fn id(&self) -> Arc { + ASSISTANT_CODE_ACTION_PROVIDER_ID.into() + } + fn code_actions( &self, buffer: &Model, diff --git a/crates/editor/src/editor.rs b/crates/editor/src/editor.rs index 4170f70d2b806..0ca6796b37156 100644 --- a/crates/editor/src/editor.rs +++ b/crates/editor/src/editor.rs @@ -4299,15 +4299,29 @@ impl Editor { self.available_code_actions.take(); } - pub fn push_code_action_provider( + pub fn add_code_action_provider( &mut self, provider: Rc, cx: &mut ViewContext, ) { + if self + .code_action_providers + .iter() + .any(|existing_provider| existing_provider.id() == provider.id()) + { + return; + } + self.code_action_providers.push(provider); self.refresh_code_actions(cx); } + pub fn remove_code_action_provider(&mut self, id: Arc, cx: &mut ViewContext) { + self.code_action_providers + .retain(|provider| provider.id() != id); + self.refresh_code_actions(cx); + } + fn refresh_code_actions(&mut self, cx: &mut ViewContext) -> Option<()> { let buffer = self.buffer.read(cx); let newest_selection = self.selections.newest_anchor().clone(); @@ -13591,6 +13605,8 @@ pub trait CompletionProvider { } pub trait CodeActionProvider { + fn id(&self) -> Arc; + fn code_actions( &self, buffer: &Model, @@ -13609,6 +13625,10 @@ pub trait CodeActionProvider { } impl CodeActionProvider for Model { + fn id(&self) -> Arc { + "project".into() + } + fn code_actions( &self, buffer: &Model,