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<String, ActionName>` 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
This commit is contained in:
Dino 2026-04-22 15:09:09 +01:00 committed by GitHub
parent 2eafa6e6aa
commit b7d35e528a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 294 additions and 28 deletions

View file

@ -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();

View file

@ -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<PreprocessorError>) {
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(&params);
let settings_validator = jsonschema::validator_for(&settings_schema)
.expect("failed to compile settings JSON schema");

View file

@ -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

View file

@ -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(),

View file

@ -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::<ActionName>(&mut generator, || {
ActionName::build_schema(
params.action_names.iter().copied(),
params.action_documentation,
params.deprecations,
params.deprecation_messages,
)
});
}
generator
.root_schema_for::<UserSettingsContent>()
.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(&params);

View file

@ -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<String>) -> 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<Item = &'a str>,
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<str> 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"));
}
}

View file

@ -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::*;

View file

@ -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<String, String>,
pub command_aliases: HashMap<String, ActionName>,
/// Maximum open tabs in a pane. Will not close an unsaved
/// tab. Set to `None` for unlimited tabs.
///

View file

@ -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
});
});

View file

@ -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<String, String>,
pub command_aliases: HashMap<String, ActionName>,
pub max_tabs: Option<NonZeroUsize>,
pub when_closing_with_no_tabs: settings::CloseWindowWhenNoItems,
pub on_last_window_closed: settings::OnLastWindowClosed,