From 3941f4403c4ef95b747b027576e14f4c1c0fcd08 Mon Sep 17 00:00:00 2001 From: "Joseph T. Lyons" Date: Wed, 1 Apr 2026 20:44:53 -0400 Subject: [PATCH] Settings profile base option (#52456) ## Context This PR introduces a `base` field for settings profiles to allow profiles to either overlay `user` settings or to overlay `default`, which is simply zed's defaults (user settings are skipped). I'm not entirely sure I love `default` because it's a bit confusing (there's a setting called `default` but the default is `user`). Another idea I had was `factory` (`user` (default) or `factory`) - curious to hear from the reviewers. This will be useful for those of us who need to quickly flip to a default state, or a default state with some customizations on top. Additionally, from what I can tell, VS Code's profile system is more in line with what this PR is offering in Zed - profiles overlay the default settings, not the user's customization layer. So this will be familiar for those users. I've had no issue with the migrator, code is pretty simple there, but would love for @smitbarmase to review the migration to make sure I'm not missing something. ## Self-Review Checklist - [X] I've reviewed my own diff for quality, security, and reliability - [x] Unsafe blocks (if any) have justifying comments - [ ] 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: - Improved the flexibility of settings profiles by offering a way for profiles to lay atop of zed defaults, skipping user settings all together. Settings Profiles now take the following form. ```json5 "Your Profile": { "base": "user" // or "default" "settings": { // ... }, }, ``` --- assets/settings/default.json | 30 ++-- crates/migrator/src/migrations.rs | 6 + .../src/migrations/m_2026_04_01/settings.rs | 29 +++ crates/migrator/src/migrator.rs | 75 ++++++++ crates/settings/src/settings.rs | 4 +- crates/settings/src/settings_store.rs | 21 ++- .../settings_content/src/settings_content.rs | 31 +++- .../src/settings_profile_selector.rs | 165 +++++++++++++++--- docs/src/reference/all-settings.md | 65 ++++--- 9 files changed, 358 insertions(+), 68 deletions(-) create mode 100644 crates/migrator/src/migrations/m_2026_04_01/settings.rs diff --git a/assets/settings/default.json b/assets/settings/default.json index d8286685b502fea9d531d4f631f06c979c985be0..74a4e15a044fa5686441f2e8a587595936ea08fb 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -2532,21 +2532,31 @@ "format_dap_log_messages": true, "button": true, }, - // Configures any number of settings profiles that are temporarily applied on - // top of your existing user settings when selected from - // `settings profile selector: toggle`. + // Configures any number of settings profiles that are temporarily applied + // when selected from `settings profile selector: toggle`. + // + // Each profile has an optional `base` ("user" or "default") and a `settings` + // object. When `base` is "user" (the default), the profile applies on top of + // your user settings. When `base` is "default", user settings are ignored and + // the profile applies on top of Zed's defaults. + // // Examples: // "profiles": { // "Presenting": { - // "agent_ui_font_size": 20.0, - // "buffer_font_size": 20.0, - // "theme": "One Light", - // "ui_font_size": 20.0 + // "base": "default", + // "settings": { + // "agent_ui_font_size": 20.0, + // "buffer_font_size": 20.0, + // "theme": "One Light", + // "ui_font_size": 20.0 + // } // }, // "Python (ty)": { - // "languages": { - // "Python": { - // "language_servers": ["ty"] + // "settings": { + // "languages": { + // "Python": { + // "language_servers": ["ty"] + // } // } // } // } diff --git a/crates/migrator/src/migrations.rs b/crates/migrator/src/migrations.rs index bc779908da7542c0bec34f799482929e96362770..625bd27e91e117662f9a47edaaac2ddaa7d2ba1c 100644 --- a/crates/migrator/src/migrations.rs +++ b/crates/migrator/src/migrations.rs @@ -322,3 +322,9 @@ pub(crate) mod m_2026_03_30 { pub(crate) use settings::make_play_sound_when_agent_done_an_enum; } + +pub(crate) mod m_2026_04_01 { + mod settings; + + pub(crate) use settings::restructure_profiles_with_settings_key; +} diff --git a/crates/migrator/src/migrations/m_2026_04_01/settings.rs b/crates/migrator/src/migrations/m_2026_04_01/settings.rs new file mode 100644 index 0000000000000000000000000000000000000000..240572fa7754e29d43b23f178115878a99760729 --- /dev/null +++ b/crates/migrator/src/migrations/m_2026_04_01/settings.rs @@ -0,0 +1,29 @@ +use anyhow::Result; +use serde_json::Value; + +pub fn restructure_profiles_with_settings_key(value: &mut Value) -> Result<()> { + let Some(root_object) = value.as_object_mut() else { + return Ok(()); + }; + + let Some(profiles) = root_object.get_mut("profiles") else { + return Ok(()); + }; + + let Some(profiles_map) = profiles.as_object_mut() else { + return Ok(()); + }; + + for profile_value in profiles_map.values_mut() { + if profile_value + .as_object() + .is_some_and(|m| m.contains_key("settings") || m.contains_key("base")) + { + continue; + } + + *profile_value = serde_json::json!({ "settings": profile_value }); + } + + Ok(()) +} diff --git a/crates/migrator/src/migrator.rs b/crates/migrator/src/migrator.rs index 136ace8a12c03c831c3eebed97e2f5915ae6afa3..f49d102213c446be17c7d240d272cf4b516d912c 100644 --- a/crates/migrator/src/migrator.rs +++ b/crates/migrator/src/migrator.rs @@ -248,6 +248,7 @@ pub fn migrate_settings(text: &str) -> Result> { &SETTINGS_QUERY_2026_03_16, ), MigrationType::Json(migrations::m_2026_03_30::make_play_sound_when_agent_done_an_enum), + MigrationType::Json(migrations::m_2026_04_01::restructure_profiles_with_settings_key), ]; run_migrations(text, migrations) } @@ -4607,4 +4608,78 @@ mod tests { ), ); } + + #[test] + fn test_restructure_profiles_with_settings_key() { + assert_migrate_settings( + &r#" + { + "buffer_font_size": 14, + "profiles": { + "Presenting": { + "buffer_font_size": 20, + "theme": "One Light" + }, + "Minimal": { + "vim_mode": true + } + } + } + "# + .unindent(), + Some( + &r#" + { + "buffer_font_size": 14, + "profiles": { + "Presenting": { + "settings": { + "buffer_font_size": 20, + "theme": "One Light" + } + }, + "Minimal": { + "settings": { + "vim_mode": true + } + } + } + } + "# + .unindent(), + ), + ); + } + + #[test] + fn test_restructure_profiles_with_settings_key_already_migrated() { + assert_migrate_settings( + &r#" + { + "profiles": { + "Presenting": { + "settings": { + "buffer_font_size": 20 + } + } + } + } + "# + .unindent(), + None, + ); + } + + #[test] + fn test_restructure_profiles_with_settings_key_no_profiles() { + assert_migrate_settings( + &r#" + { + "buffer_font_size": 14 + } + "# + .unindent(), + None, + ); + } } diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs index 62b577c44520a6922798076cf085defea46d8688..1b75f9395e4f46ec5fd20231956d232c26005107 100644 --- a/crates/settings/src/settings.rs +++ b/crates/settings/src/settings.rs @@ -59,13 +59,13 @@ pub struct ActiveSettingsProfileName(pub String); impl Global for ActiveSettingsProfileName {} pub trait UserSettingsContentExt { - fn for_profile(&self, cx: &App) -> Option<&SettingsContent>; + fn for_profile(&self, cx: &App) -> Option<&SettingsProfile>; fn for_release_channel(&self) -> Option<&SettingsContent>; fn for_os(&self) -> Option<&SettingsContent>; } impl UserSettingsContentExt for UserSettingsContent { - fn for_profile(&self, cx: &App) -> Option<&SettingsContent> { + fn for_profile(&self, cx: &App) -> Option<&SettingsProfile> { let Some(active_profile) = cx.try_global::() else { return None; }; diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index daaa8eb6b89c8954ef335f59692fbb059195e627..577ba43e1dd566d32eeec8993ec135633146b020 100644 --- a/crates/settings/src/settings_store.rs +++ b/crates/settings/src/settings_store.rs @@ -36,8 +36,8 @@ use crate::{ LanguageToSettingsMap, LspSettings, LspSettingsMap, SemanticTokenRules, ThemeName, UserSettingsContentExt, VsCodeSettings, WorktreeId, settings_content::{ - ExtensionsSettingsContent, ProjectSettingsContent, RootUserSettings, SettingsContent, - UserSettingsContent, merge_from::MergeFrom, + ExtensionsSettingsContent, ProfileBase, ProjectSettingsContent, RootUserSettings, + SettingsContent, UserSettingsContent, merge_from::MergeFrom, }, }; @@ -1210,10 +1210,19 @@ impl SettingsStore { merged.merge_from_option(self.extension_settings.as_deref()); merged.merge_from_option(self.global_settings.as_deref()); if let Some(user_settings) = self.user_settings.as_ref() { - merged.merge_from(&user_settings.content); - merged.merge_from_option(user_settings.for_release_channel()); - merged.merge_from_option(user_settings.for_os()); - merged.merge_from_option(user_settings.for_profile(cx)); + let active_profile = user_settings.for_profile(cx); + let should_merge_user_settings = + active_profile.is_none_or(|profile| profile.base == ProfileBase::User); + + if should_merge_user_settings { + merged.merge_from(&user_settings.content); + merged.merge_from_option(user_settings.for_release_channel()); + merged.merge_from_option(user_settings.for_os()); + } + + if let Some(profile) = active_profile { + merged.merge_from(&profile.settings); + } } merged.merge_from_option(self.server_settings.as_deref()); diff --git a/crates/settings_content/src/settings_content.rs b/crates/settings_content/src/settings_content.rs index f8c64191dfe2602744e783f6d52484c45a7756d2..325e86e9e3af0fb888c2691f4be1b0fdeb06dfb4 100644 --- a/crates/settings_content/src/settings_content.rs +++ b/crates/settings_content/src/settings_content.rs @@ -265,6 +265,35 @@ settings_overrides! { pub struct PlatformOverrides { macos, linux, windows } } +/// Determines what settings a profile starts from before applying its overrides. +#[derive( + Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, MergeFrom, +)] +#[serde(rename_all = "snake_case")] +pub enum ProfileBase { + /// Apply profile settings on top of the user's current settings. + #[default] + User, + /// Apply profile settings on top of Zed's default settings, ignoring user customizations. + Default, +} + +/// A named settings profile that can temporarily override settings. +#[with_fallible_options] +#[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)] +pub struct SettingsProfile { + /// What base settings to start from before applying this profile's overrides. + /// + /// - `user`: Apply on top of user's settings (default) + /// - `default`: Apply on top of Zed's default settings, ignoring user customizations + #[serde(default)] + pub base: ProfileBase, + + /// The settings overrides for this profile. + #[serde(default)] + pub settings: Box, +} + #[with_fallible_options] #[derive(Debug, Default, PartialEq, Clone, Serialize, Deserialize, JsonSchema, MergeFrom)] pub struct UserSettingsContent { @@ -278,7 +307,7 @@ pub struct UserSettingsContent { pub platform_overrides: PlatformOverrides, #[serde(default)] - pub profiles: IndexMap, + pub profiles: IndexMap, } pub struct ExtensionsSettingsContent { diff --git a/crates/settings_profile_selector/src/settings_profile_selector.rs b/crates/settings_profile_selector/src/settings_profile_selector.rs index a948b603e04c43a6740853b7c37aebb2ba8d7ee9..c273a08ce7427880a02cb375561aaaade2607b83 100644 --- a/crates/settings_profile_selector/src/settings_profile_selector.rs +++ b/crates/settings_profile_selector/src/settings_profile_selector.rs @@ -291,7 +291,7 @@ mod tests { use zed_actions::settings_profile_selector; async fn init_test( - profiles_json: serde_json::Value, + user_settings_json: serde_json::Value, cx: &mut TestAppContext, ) -> (Entity, &mut VisualTestContext) { cx.update(|cx| { @@ -307,13 +307,8 @@ mod tests { cx.update(|cx| { SettingsStore::update_global(cx, |store, cx| { - let settings_json = json!({ - "buffer_font_size": 10.0, - "profiles": profiles_json, - }); - store - .set_user_settings(&settings_json.to_string(), cx) + .set_user_settings(&user_settings_json.to_string(), cx) .unwrap(); }); }); @@ -328,7 +323,6 @@ mod tests { cx.update(|_, cx| { assert!(!cx.has_global::()); - assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0)); }); (workspace, cx) @@ -354,15 +348,22 @@ mod tests { let classroom_and_streaming_profile_name = "Classroom / Streaming".to_string(); let demo_videos_profile_name = "Demo Videos".to_string(); - let profiles_json = json!({ - classroom_and_streaming_profile_name.clone(): { - "buffer_font_size": 20.0, - }, - demo_videos_profile_name.clone(): { - "buffer_font_size": 15.0 + let user_settings_json = json!({ + "buffer_font_size": 10.0, + "profiles": { + classroom_and_streaming_profile_name.clone(): { + "settings": { + "buffer_font_size": 20.0, + } + }, + demo_videos_profile_name.clone(): { + "settings": { + "buffer_font_size": 15.0 + } + } } }); - let (workspace, cx) = init_test(profiles_json.clone(), cx).await; + let (workspace, cx) = init_test(user_settings_json, cx).await; cx.dispatch_action(settings_profile_selector::Toggle); let picker = active_settings_profile_picker(&workspace, cx); @@ -575,24 +576,134 @@ mod tests { }); } + #[gpui::test] + async fn test_settings_profile_with_user_base(cx: &mut TestAppContext) { + let user_settings_json = json!({ + "buffer_font_size": 10.0, + "profiles": { + "Explicit User": { + "base": "user", + "settings": { + "buffer_font_size": 20.0 + } + }, + "Implicit User": { + "settings": { + "buffer_font_size": 20.0 + } + } + } + }); + let (workspace, cx) = init_test(user_settings_json, cx).await; + + // Select "Explicit User" (index 1) — profile applies on top of user settings. + cx.dispatch_action(settings_profile_selector::Toggle); + let picker = active_settings_profile_picker(&workspace, cx); + cx.dispatch_action(SelectNext); + + picker.read_with(cx, |picker, cx| { + assert_eq!( + picker.delegate.selected_profile_name.as_deref(), + Some("Explicit User") + ); + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0)); + }); + + cx.dispatch_action(Confirm); + + // Select "Implicit User" (index 2) — no base specified, same behavior. + cx.dispatch_action(settings_profile_selector::Toggle); + let picker = active_settings_profile_picker(&workspace, cx); + cx.dispatch_action(SelectNext); + + picker.read_with(cx, |picker, cx| { + assert_eq!( + picker.delegate.selected_profile_name.as_deref(), + Some("Implicit User") + ); + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(20.0)); + }); + + cx.dispatch_action(Confirm); + } + + #[gpui::test] + async fn test_settings_profile_with_default_base(cx: &mut TestAppContext) { + let user_settings_json = json!({ + "buffer_font_size": 10.0, + "profiles": { + "Clean Slate": { + "base": "default" + }, + "Custom on Defaults": { + "base": "default", + "settings": { + "buffer_font_size": 30.0 + } + } + } + }); + let (workspace, cx) = init_test(user_settings_json, cx).await; + + // User has buffer_font_size: 10, factory default is 15. + cx.update(|_, cx| { + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(10.0)); + }); + + // "Clean Slate" has base: "default" with no settings overrides, + // so we get the factory default (15), not the user's value (10). + cx.dispatch_action(settings_profile_selector::Toggle); + let picker = active_settings_profile_picker(&workspace, cx); + cx.dispatch_action(SelectNext); + + picker.read_with(cx, |picker, cx| { + assert_eq!( + picker.delegate.selected_profile_name.as_deref(), + Some("Clean Slate") + ); + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(15.0)); + }); + + // "Custom on Defaults" has base: "default" with buffer_font_size: 30, + // so the profile's override (30) applies on top of the factory default, + // not on top of the user's value (10). + cx.dispatch_action(SelectNext); + + picker.read_with(cx, |picker, cx| { + assert_eq!( + picker.delegate.selected_profile_name.as_deref(), + Some("Custom on Defaults") + ); + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(30.0)); + }); + + cx.dispatch_action(Confirm); + + cx.update(|_, cx| { + assert_eq!(ThemeSettings::get_global(cx).buffer_font_size(cx), px(30.0)); + }); + } + #[gpui::test] async fn test_settings_profile_selector_is_in_user_configuration_order( cx: &mut TestAppContext, ) { // Must be unique names (HashMap) - let profiles_json = json!({ - "z": {}, - "e": {}, - "d": {}, - " ": {}, - "r": {}, - "u": {}, - "l": {}, - "3": {}, - "s": {}, - "!": {}, + let user_settings_json = json!({ + "profiles": { + "z": { "settings": {} }, + "e": { "settings": {} }, + "d": { "settings": {} }, + " ": { "settings": {} }, + "r": { "settings": {} }, + "u": { "settings": {} }, + "l": { "settings": {} }, + "3": { "settings": {} }, + "s": { "settings": {} }, + "!": { "settings": {} }, + } }); - let (workspace, cx) = init_test(profiles_json.clone(), cx).await; + let (workspace, cx) = init_test(user_settings_json, cx).await; cx.dispatch_action(settings_profile_selector::Toggle); let picker = active_settings_profile_picker(&workspace, cx); diff --git a/docs/src/reference/all-settings.md b/docs/src/reference/all-settings.md index ce80fe78f4734135bd6bba0f3329a651059dbfdf..3c944e0807ff1a6b0cda46c3416ad4e2dbc5a279 100644 --- a/docs/src/reference/all-settings.md +++ b/docs/src/reference/all-settings.md @@ -3002,21 +3002,36 @@ If you wish to exclude certain hosts from using the proxy, set the `NO_PROXY` en ## Profiles -- Description: Configuration profiles that can be applied on top of existing settings +- Description: Configuration profiles that can be temporarily applied on top of existing settings or Zed's defaults. - Setting: `profiles` - Default: `{}` **Options** -Configuration object for defining settings profiles. Example: +Each profile is an object with the following optional fields: + +- `base`: What settings to start from before applying the profile's overrides. + - `"user"` (default): Apply on top of your current user settings. + - `"default"`: Apply on top of Zed's default settings, ignoring user customizations. +- `settings`: The settings overrides for this profile. + +Examples: ```json [settings] { "profiles": { - "presentation": { - "buffer_font_size": 20, - "ui_font_size": 18, - "theme": "One Light" + "Presentation": { + "settings": { + "buffer_font_size": 20, + "ui_font_size": 18, + "theme": "One Light" + } + }, + "Clean Slate": { + "base": "default", + "settings": { + "theme": "Ayu Dark" + } } } } @@ -5332,12 +5347,12 @@ For example, to use `Nerd Font` as a fallback, add the following to your setting ## Settings Profiles -- Description: Configure any number of settings profiles that are temporarily applied on top of your existing user settings when selected from `settings profile selector: toggle`. +- Description: Configure any number of settings profiles that are temporarily applied when selected from `settings profile selector: toggle`. - Setting: `profiles` - Default: `{}` In your `settings.json` file, add the `profiles` object. -Each key within this object is the name of a settings profile, and each value is an object that can include any of Zed's settings. +Each key within this object is the name of a settings profile. Each profile has an optional `base` field (`"user"` or `"default"`) and a `settings` object containing any of Zed's settings. Example: @@ -5345,24 +5360,30 @@ Example: { "profiles": { "Presenting (Dark)": { - "agent_buffer_font_size": 18.0, - "buffer_font_size": 18.0, - "theme": "One Dark", - "ui_font_size": 18.0 + "settings": { + "agent_buffer_font_size": 18.0, + "buffer_font_size": 18.0, + "theme": "One Dark", + "ui_font_size": 18.0 + } }, "Presenting (Light)": { - "agent_buffer_font_size": 18.0, - "buffer_font_size": 18.0, - "theme": "One Light", - "ui_font_size": 18.0 + "settings": { + "agent_buffer_font_size": 18.0, + "buffer_font_size": 18.0, + "theme": "One Light", + "ui_font_size": 18.0 + } }, "Writing": { - "agent_buffer_font_size": 15.0, - "buffer_font_size": 15.0, - "theme": "Catppuccin Frappé - No Italics", - "ui_font_size": 15.0, - "tab_bar": { "show": false }, - "toolbar": { "breadcrumbs": false } + "settings": { + "agent_buffer_font_size": 15.0, + "buffer_font_size": 15.0, + "theme": "Catppuccin Frappé - No Italics", + "ui_font_size": 15.0, + "tab_bar": { "show": false }, + "toolbar": { "breadcrumbs": false } + } } } }