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 } + } } } }