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

<!-- 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": {
    // ...
  },
},
```
This commit is contained in:
Joseph T. Lyons 2026-04-01 20:44:53 -04:00 committed by GitHub
parent 5a7a528516
commit 3941f4403c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 358 additions and 68 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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