Settings profile base option (#52456)

Joseph T. Lyons created

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

<!-- Check before requesting review: -->
- [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": {
    // ...
  },
},
```

Change summary

assets/settings/default.json                                      |  30 
crates/migrator/src/migrations.rs                                 |   6 
crates/migrator/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 
crates/settings_content/src/settings_content.rs                   |  31 
crates/settings_profile_selector/src/settings_profile_selector.rs | 165 
docs/src/reference/all-settings.md                                |  65 
9 files changed, 358 insertions(+), 68 deletions(-)

Detailed changes

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"]
+  //         }
   //       }
   //     }
   //   }

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

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

crates/migrator/src/migrator.rs πŸ”—

@@ -248,6 +248,7 @@ pub fn migrate_settings(text: &str) -> Result<Option<String>> {
             &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,
+        );
+    }
 }

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::<ActiveSettingsProfileName>() else {
             return None;
         };

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

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<SettingsContent>,
+}
+
 #[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<String, SettingsContent>,
+    pub profiles: IndexMap<String, SettingsProfile>,
 }
 
 pub struct ExtensionsSettingsContent {

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<Workspace>, &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::<ActiveSettingsProfileName>());
-            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);

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