From b7d35e528a574fa191bbf70560fea1cf9be65e8f Mon Sep 17 00:00:00 2001 From: Dino Date: Wed, 22 Apr 2026 15:09:09 +0100 Subject: [PATCH] settings: Add auto completion to command aliases setting (#54496) Update the JSON schema generated for the settings file in order to be able to provide the list of valid actions when editing the values for the `command_aliases` setting. While reviewing https://github.com/zed-industries/zed/pull/52892 , I noticed that, even though we already have support for this in the keymap file, we don't support it for the `command_aliases` setting, so went ahead and refactored this a bit such that the existing functionality for the keymap file JSON schema could also be re-used for the `command_aliases` setting. Here's a quick big-picture breakdown of the relevant changes: * Add `settings_content::ActionName` newtype, representing a simple named action without arguments. The `settings_content::ActionName::build_schema` function can be used to build the schema of all possible action names. * Add `settings_content::ActionWithArguments` newtype, representing an action with arguments. This was mostly done so as to keep both action without arguments and action with arguments newtypes together, even though we don't have `settings_content::ActionWithArguments::build_schema`, as it is only used by the keymap schema generation logic and probably doesn't warrant moving it here right now. * Update both `settings_content::WorkspaceSettingsContent::command_aliases` and `workspace::workspace_settings::WorkspaceSettings::command_aliases` to now be of type `HashMap` such that, when the json schema for `command_aliases` is generate, it'll now reference the `#/$defs/ActionName` schema. * Update `SettingsStore::json_schema` so as to populate the `#/$defs/ActionName` schema at runtime, replacing it with the actual list of valid action names. Self-Review Checklist: - [x] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [x] The content is consistent with the [UI/UX checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist) - [x] Tests cover the new/changed behavior - [x] Performance impact has been considered and is acceptable Release Notes: - Added support for auto-completing action names on `command_aliases` setting --- crates/command_palette/src/command_palette.rs | 2 +- crates/docs_preprocessor/src/main.rs | 15 +- .../src/json_schema_store.rs | 13 ++ crates/settings/src/keymap_file.rs | 42 ++-- crates/settings/src/settings_store.rs | 30 ++- crates/settings_content/src/action.rs | 202 ++++++++++++++++++ .../settings_content/src/settings_content.rs | 2 + crates/settings_content/src/workspace.rs | 8 +- crates/vim/src/test.rs | 4 +- crates/workspace/src/workspace_settings.rs | 4 +- 10 files changed, 294 insertions(+), 28 deletions(-) create mode 100644 crates/settings_content/src/action.rs diff --git a/crates/command_palette/src/command_palette.rs b/crates/command_palette/src/command_palette.rs index 5756dcd888a6f05deae5aa1c74366a929c758401..7b04981b929da1f50ee40da4d773c6618ede28e3 100644 --- a/crates/command_palette/src/command_palette.rs +++ b/crates/command_palette/src/command_palette.rs @@ -442,7 +442,7 @@ impl PickerDelegate for CommandPaletteDelegate { ) -> gpui::Task<()> { let settings = WorkspaceSettings::get_global(cx); if let Some(alias) = settings.command_aliases.get(&query) { - query = alias.to_string(); + query = alias.as_ref().to_owned(); } let workspace = self.workspace.clone(); diff --git a/crates/docs_preprocessor/src/main.rs b/crates/docs_preprocessor/src/main.rs index af451d43268568960e0727741a88c78d41c61c54..3eaae6a1d488ee9f28aab12980e0a2dbc1679d40 100644 --- a/crates/docs_preprocessor/src/main.rs +++ b/crates/docs_preprocessor/src/main.rs @@ -3,7 +3,7 @@ use mdbook::BookItem; use mdbook::book::{Book, Chapter}; use mdbook::preprocess::CmdPreprocessor; use regex::Regex; -use settings::{KeymapFile, SettingsStore}; +use settings::{KeymapFile, SettingsJsonSchemaParams, SettingsStore}; use std::borrow::Cow; use std::collections::{HashMap, HashSet}; use std::io::{self, Read}; @@ -369,7 +369,18 @@ fn find_binding_with_overlay( } fn template_and_validate_json_snippets(book: &mut Book, errors: &mut HashSet) { - let settings_schema = SettingsStore::json_schema(&Default::default()); + let params = SettingsJsonSchemaParams { + language_names: &[], + font_names: &[], + theme_names: &[], + icon_theme_names: &[], + lsp_adapter_names: &[], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), + }; + let settings_schema = SettingsStore::json_schema(¶ms); let settings_validator = jsonschema::validator_for(&settings_schema) .expect("failed to compile settings JSON schema"); diff --git a/crates/json_schema_store/src/json_schema_store.rs b/crates/json_schema_store/src/json_schema_store.rs index 629042f745dbee46b326778ad8c002e7e2464bd4..b0cd3c0b35c7ff4029ccec9e14e9edcb07141cd0 100644 --- a/crates/json_schema_store/src/json_schema_store.rs +++ b/crates/json_schema_store/src/json_schema_store.rs @@ -352,6 +352,11 @@ async fn resolve_dynamic_schema( let icon_theme_names = icon_theme_names.as_slice(); let theme_names = theme_names.as_slice(); + let action_names = cx.all_action_names(); + let action_documentation = cx.action_documentation(); + let deprecations = cx.deprecated_actions_to_preferred_actions(); + let deprecation_messages = cx.action_deprecation_messages(); + let mut schema = settings::SettingsStore::json_schema(&settings::SettingsJsonSchemaParams { language_names, @@ -359,6 +364,10 @@ async fn resolve_dynamic_schema( theme_names, icon_theme_names, lsp_adapter_names: &lsp_adapter_names, + action_names, + action_documentation, + deprecations, + deprecation_messages, }); inject_feature_flags_schema(&mut schema); schema @@ -387,6 +396,10 @@ async fn resolve_dynamic_schema( font_names: &[], theme_names: &[], icon_theme_names: &[], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); inject_feature_flags_schema(&mut schema); schema diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index f4529e305a4428b1ab9ead8671542108b963216b..82c6f6528b685e9da34c1d301e6a1ddfaaafbd44 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -19,6 +19,7 @@ use util::{ }; use crate::SettingsAssets; +use settings_content::{ActionName, ActionWithArguments}; use settings_json::{ append_top_level_array_value_in_json_text, parse_json_with_comments, replace_top_level_array_value_in_json_text, @@ -698,10 +699,17 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - let mut keymap_action_alternatives = vec![ - empty_action_name.clone(), - empty_action_name_with_input.clone(), - ]; + + let mut keymap_deprecations = deprecations.clone(); + keymap_deprecations.insert(NoAction.name(), "null"); + let action_name_schema = ActionName::build_schema( + action_schemas.iter().map(|(name, _)| *name), + action_documentation, + &keymap_deprecations, + deprecation_messages, + ); + + let mut action_with_arguments_alternatives = vec![empty_action_name_with_input.clone()]; let mut unbind_target_action_alternatives = vec![empty_action_name, empty_action_name_with_input]; @@ -731,7 +739,6 @@ impl KeymapFile { if let Some(description) = &description { add_description(&mut plain_action, description); } - keymap_action_alternatives.push(plain_action.clone()); if include_in_unbind_target_schema { unbind_target_action_alternatives.push(plain_action); } @@ -760,7 +767,7 @@ impl KeymapFile { "minItems": 2, "maxItems": 2 }); - keymap_action_alternatives.push(action_with_input.clone()); + action_with_arguments_alternatives.push(action_with_input.clone()); if include_in_unbind_target_schema { unbind_target_action_alternatives.push(action_with_input); } @@ -789,7 +796,7 @@ impl KeymapFile { "This action does not take input - just the action name string should be used." .to_string(), ); - keymap_action_alternatives.push(actions_with_empty_input); + action_with_arguments_alternatives.push(actions_with_empty_input); } if !empty_schema_unbind_target_action_names.is_empty() { @@ -812,17 +819,22 @@ impl KeymapFile { unbind_target_action_alternatives.push(actions_with_empty_input); } - // Placing null first causes json-language-server to default assuming actions should be - // null, so place it last. - keymap_action_alternatives.push(json_schema!({ - "type": "null" - })); + generator.definitions_mut().insert( + ActionName::schema_name().to_string(), + action_name_schema.to_value(), + ); + generator.definitions_mut().insert( + ActionWithArguments::schema_name().to_string(), + json!({ "anyOf": action_with_arguments_alternatives }), + ); generator.definitions_mut().insert( KeymapAction::schema_name().to_string(), - json!({ - "anyOf": keymap_action_alternatives - }), + json!({ "anyOf": [ + { "$ref": format!("#/$defs/{}", ActionName::schema_name().to_string()) }, + { "$ref": format!("#/$defs/{}", ActionWithArguments::schema_name().to_string()) }, + { "type": "null" } + ] }), ); generator.definitions_mut().insert( UnbindTargetAction::schema_name().to_string(), diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index a98a60c5e16625f2a42cc1ab85b9c8a43bf7b57c..bbd1ad0bd970cdbbc04c5659d4f64e101fa181d1 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -13,7 +13,7 @@ use gpui::{ use paths::{local_settings_file_relative_path, task_file_name}; use schemars::{JsonSchema, json_schema}; use serde_json::Value; -use settings_content::ParseStatus; +use settings_content::{ActionName, ParseStatus}; use std::{ any::{Any, TypeId, type_name}, fmt::Debug, @@ -272,13 +272,16 @@ pub trait AnySettingValue: 'static + Send + Sync { } /// Parameters that are used when generating some JSON schemas at runtime. -#[derive(Default)] pub struct SettingsJsonSchemaParams<'a> { pub language_names: &'a [String], pub font_names: &'a [String], pub theme_names: &'a [SharedString], pub icon_theme_names: &'a [SharedString], pub lsp_adapter_names: &'a [String], + pub action_names: &'a [&'a str], + pub action_documentation: &'a HashMap<&'a str, &'a str>, + pub deprecations: &'a HashMap<&'a str, &'a str>, + pub deprecation_messages: &'a HashMap<&'a str, &'a str>, } impl SettingsStore { @@ -1263,6 +1266,17 @@ impl SettingsStore { }); } + if !params.action_names.is_empty() { + replace_subschema::(&mut generator, || { + ActionName::build_schema( + params.action_names.iter().copied(), + params.action_documentation, + params.deprecations, + params.deprecation_messages, + ) + }); + } + generator .root_schema_for::() .to_value() @@ -2738,6 +2752,10 @@ mod tests { "rust-analyzer".to_string(), "typescript-language-server".to_string(), ], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); let properties = schema @@ -2789,6 +2807,10 @@ mod tests { "rust-analyzer".to_string(), "typescript-language-server".to_string(), ], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }); let properties = schema @@ -2837,6 +2859,10 @@ mod tests { theme_names: &["One Dark".into()], icon_theme_names: &["Zed Icons".into()], lsp_adapter_names: &["rust-analyzer".to_string()], + action_names: &[], + action_documentation: &HashMap::default(), + deprecations: &HashMap::default(), + deprecation_messages: &HashMap::default(), }; let user_schema = SettingsStore::json_schema(¶ms); diff --git a/crates/settings_content/src/action.rs b/crates/settings_content/src/action.rs new file mode 100644 index 0000000000000000000000000000000000000000..b70a15cb4b75c860f117259b046a7b6b39e2a020 --- /dev/null +++ b/crates/settings_content/src/action.rs @@ -0,0 +1,202 @@ +use std::borrow::Cow; +use std::fmt::{Display, Formatter, Result}; + +use collections::HashMap; +use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use settings_macros::MergeFrom; + +/// The name of a registered GPUI action, serialized as a plain JSON string, for +/// example, "editor::Cancel"` or `"workspace::CloseActiveItem"`. +/// +/// This newtype exists so that settings fields like `command_aliases`, or the +/// keymap file bindings, can request JSON-schema auto completion over the set +/// of actions known at runtime. +#[derive(Serialize, Deserialize, Default, MergeFrom, Clone, Debug, PartialEq)] +#[serde(transparent)] +pub struct ActionName(String); + +/// Small helper function to populate the schema's `deprecationMessage` field with the +/// provided deprecation message. +fn add_deprecation(schema: &mut Schema, message: String) { + schema.insert("deprecationMessage".into(), Value::String(message)); +} + +/// Small helper function to populate the schema's `description` field with the +/// provided description. +fn add_description(schema: &mut Schema, description: &str) { + schema.insert("description".into(), Value::String(description.to_string())); +} + +impl ActionName { + pub fn new(name: impl Into) -> Self { + Self(name.into()) + } + + /// Build the JSON schema to be used for `$defs/ActionName`, basically an + /// `anyOf` of all of the available actions with per-action documentation + /// and deprecation metadata attached. + pub fn build_schema<'a>( + action_names: impl IntoIterator, + action_documentation: &HashMap<&str, &str>, + deprecations: &HashMap<&str, &str>, + deprecation_messages: &HashMap<&str, &str>, + ) -> Schema { + let mut alternatives = Vec::new(); + + for action_name in action_names { + let mut entry = json_schema!({ + "type": "string", + "const": action_name + }); + + if let Some(message) = deprecation_messages.get(action_name) { + add_deprecation(&mut entry, message.to_string()); + } else if let Some(new_name) = deprecations.get(action_name) { + add_deprecation(&mut entry, format!("Deprecated, use {new_name}")); + } + + if let Some(description) = action_documentation.get(action_name) { + add_description(&mut entry, description); + } + + alternatives.push(entry); + } + + json_schema!({ "anyOf": alternatives }) + } +} + +impl Display for ActionName { + fn fmt(&self, formatter: &mut Formatter<'_>) -> Result { + write!(formatter, "{}", self.0) + } +} + +impl AsRef for ActionName { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl JsonSchema for ActionName { + /// The name under which this type should be stored in a generator's `$defs` + /// map when schemars encounters it during schema generation. + /// Keeping it stable as `"ActionName"` lets consumers reference it by + /// `#/$defs/ActionName` and lets [`util::schemars::replace_subschema`] look + /// it up at runtime to swap in the real schema. + fn schema_name() -> Cow<'static, str> { + "ActionName".into() + } + + /// Returns `true` as a placeholder. + /// + /// The real schema, an `anyOf` of every registered action name with action + /// documentation and deprecation metadata, cannot be produced here because + /// `JsonSchema::json_schema` receives no runtime context. It is instead + /// built by call sites that do have access to the GPUI action registry + /// using [`ActionName::build_schema`]. + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!(true) + } +} + +/// A GPUI action together with its input data, serialized as a two-element JSON +/// array of the form `["namespace::Name", { ... }]`, for example, +/// `["pane::ActivateItem", { "index": 0 }]`. +#[derive(Deserialize, Default)] +#[serde(transparent)] +pub struct ActionWithArguments(pub Value); + +impl JsonSchema for ActionWithArguments { + /// The name under which this type should be stored in a generator's `$defs` + /// map when schemars encounters it during schema generation. + /// Keeping it stable as `"ActionWithArguments"` lets consumers reference it + /// by `#/$defs/ActionWithArguments` and lets + /// [`util::schemars::replace_subschema`] look it up at runtime to swap in + /// the real schema. + fn schema_name() -> Cow<'static, str> { + "ActionWithArguments".into() + } + + /// Returns `true` as a placeholder. + /// + /// The real schema, an `anyOf` of every registered action name that + /// supports arguments, with action documentation and deprecation metadata, + /// cannot be produced here because `JsonSchema::json_schema` receives no + /// runtime context. At the time of writing, it is instead built by + /// [`KeymapFile::generate_json_schema`], where all of the runtime + /// information is available. + fn json_schema(_: &mut SchemaGenerator) -> Schema { + json_schema!(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn build_schema_produces_anyof_of_consts_per_name() { + let mut action_documentation = HashMap::default(); + let mut deprecations = HashMap::default(); + let mut deprecation_messages = HashMap::default(); + action_documentation.insert("editor::Cancel", "Cancel the current operation."); + deprecations.insert("workspace::CloseCurrentItem", "workspace::CloseActiveItem"); + deprecation_messages.insert("editor::Explode", "DO NOT USE!"); + + let schema = ActionName::build_schema( + [ + "editor::Cancel", + "editor::Explode", + "workspace::CloseCurrentItem", + "workspace::CloseActiveItem", + ], + &action_documentation, + &deprecations, + &deprecation_messages, + ); + + let value = schema.to_value(); + let values = value + .pointer("/anyOf") + .and_then(|v| v.as_array()) + .expect("anyOf should be present"); + assert_eq!(values.len(), 4); + + let (name, schema_type, description) = ( + values[0].get("const").and_then(Value::as_str), + values[0].get("type").and_then(Value::as_str), + values[0].get("description").and_then(Value::as_str), + ); + assert_eq!(name, Some("editor::Cancel")); + assert_eq!(schema_type, Some("string")); + assert_eq!(description, Some("Cancel the current operation.")); + + let (name, schema_type, message) = ( + values[1].get("const").and_then(Value::as_str), + values[1].get("type").and_then(Value::as_str), + values[1].get("deprecationMessage").and_then(Value::as_str), + ); + assert_eq!(name, Some("editor::Explode")); + assert_eq!(schema_type, Some("string")); + assert_eq!(message, Some("DO NOT USE!")); + + let (name, schema_type, message) = ( + values[2].get("const").and_then(Value::as_str), + values[2].get("type").and_then(Value::as_str), + values[2].get("deprecationMessage").and_then(Value::as_str), + ); + assert_eq!(name, Some("workspace::CloseCurrentItem")); + assert_eq!(schema_type, Some("string")); + assert_eq!(message, Some("Deprecated, use workspace::CloseActiveItem")); + + let (name, schema_type) = ( + values[3].get("const").and_then(Value::as_str), + values[3].get("type").and_then(Value::as_str), + ); + assert_eq!(name, Some("workspace::CloseActiveItem")); + assert_eq!(schema_type, Some("string")); + } +} diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index d949b81e2329daaaa7e5ad040898b5a732a8c52b..f8daefcfdb010e0c26edf7ad75f7117e5a9f368f 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -1,3 +1,4 @@ +mod action; mod agent; mod editor; mod extension; @@ -12,6 +13,7 @@ mod theme; mod title_bar; mod workspace; +pub use action::{ActionName, ActionWithArguments}; pub use agent::*; pub use editor::*; pub use extension::*; diff --git a/crates/settings_content/src/workspace.rs b/crates/settings_content/src/workspace.rs index 3e4f3b03de5c615132a9ad9b65e569a3a4cf8ee2..19e08e19f34dd1aa2526440aeafb3c33857d317f 100644 --- a/crates/settings_content/src/workspace.rs +++ b/crates/settings_content/src/workspace.rs @@ -6,8 +6,8 @@ use serde::{Deserialize, Serialize}; use settings_macros::{MergeFrom, with_fallible_options}; use crate::{ - CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, ShowIndentGuides, - ShowScrollbar, serialize_optional_f32_with_two_decimal_places, + ActionName, CenteredPaddingSettings, DelayMs, DockPosition, DockSide, InactiveOpacity, + ShowIndentGuides, ShowScrollbar, serialize_optional_f32_with_two_decimal_places, }; #[with_fallible_options] @@ -88,9 +88,9 @@ pub struct WorkspaceSettingsContent { /// Aliases for the command palette. When you type a key in this map, /// it will be assumed to equal the value. /// - /// Default: true + /// Default: {} #[serde(default)] - pub command_aliases: HashMap, + pub command_aliases: HashMap, /// Maximum open tabs in a pane. Will not close an unsaved /// tab. Set to `None` for unlimited tabs. /// diff --git a/crates/vim/src/test.rs b/crates/vim/src/test.rs index 7d4b469d3b94d5fd9636b5eb6a588b57eaf771c7..8b5570125c4bf672e6664d16f8b94c193fc0fc25 100644 --- a/crates/vim/src/test.rs +++ b/crates/vim/src/test.rs @@ -18,7 +18,7 @@ use gpui::{KeyBinding, Modifiers, MouseButton, TestAppContext, px}; use itertools::Itertools; use language::{CursorShape, Language, LanguageConfig, Point}; pub use neovim_backed_test_context::*; -use settings::SettingsStore; +use settings::{ActionName, SettingsStore}; use ui::Pixels; use util::{path, test::marked_text_ranges}; pub use vim_test_context::*; @@ -1926,7 +1926,7 @@ async fn test_command_alias(cx: &mut gpui::TestAppContext) { cx.update_global(|store: &mut SettingsStore, cx| { store.update_user_settings(cx, |s| { let mut aliases = HashMap::default(); - aliases.insert("Q".to_string(), "upper".to_string()); + aliases.insert("Q".to_string(), ActionName::new("upper")); s.workspace.command_aliases = aliases }); }); diff --git a/crates/workspace/src/workspace_settings.rs b/crates/workspace/src/workspace_settings.rs index f097f381d16a51f32e3079968334fa65e264498d..53ef067193ef80b04f314bd30437dfc16fda224d 100644 --- a/crates/workspace/src/workspace_settings.rs +++ b/crates/workspace/src/workspace_settings.rs @@ -4,7 +4,7 @@ use crate::DockPosition; use collections::HashMap; use serde::Deserialize; pub use settings::{ - AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, + ActionName, AutosaveSetting, BottomDockLayout, EncodingDisplayOptions, InactiveOpacity, PaneSplitDirectionHorizontal, PaneSplitDirectionVertical, RegisterSetting, RestoreOnStartupBehavior, Settings, }; @@ -25,7 +25,7 @@ pub struct WorkspaceSettings { pub drop_target_size: f32, pub use_system_path_prompts: bool, pub use_system_prompts: bool, - pub command_aliases: HashMap, + pub command_aliases: HashMap, pub max_tabs: Option, pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems, pub on_last_window_closed: settings::OnLastWindowClosed,