Introduce settings profiles (#35339)

Joseph T. Lyons created

Settings Profiles

- [X] Allow profiles to be defined, where each profile can be any of
Zed's settings
    - [X] Autocompletion of all settings
    - [X] Errors on invalid keys
- [X] Action brings up modal that shows user-defined profiles
- [X] Alphabetize profiles
- [X] Ability to filter down via keyboard, and navigate via arrow up and
down
- [X] Auto select Disabled option by default (first in list, after
alphabetizing user-defined profiles)
- [X] Automatically select active profile on next picker summoning
- [X] Persist settings until toggled off
- [X] Show live preview as you select from the profile picker
- [X] Tweaking a setting, while in a profile, updates the profile live
- [X] Make sure actions that live update Zed, such as `cmd-0`, `cmd-+`,
and `cmd--`, work while in a profile
- [X] Add a test to track state

Release Notes:

- Added the ability to configure settings profiles, via the "profiles"
key. Example:

```json
{
  "profiles": {
    "Streaming": {
      "agent_font_size": 20,
      "buffer_font_size": 20,
      "theme": "One Light",
      "ui_font_size": 20
    }
  }
}
```

To set a profile, use `settings profile selector: toggle`

Change summary

Cargo.lock                                                        |  20 
Cargo.toml                                                        |   4 
assets/settings/default.json                                      |   5 
crates/settings/src/settings.rs                                   |   8 
crates/settings/src/settings_store.rs                             |  83 
crates/settings_profile_selector/Cargo.toml                       |  33 
crates/settings_profile_selector/LICENSE-GPL                      |   1 
crates/settings_profile_selector/src/settings_profile_selector.rs | 548 +
crates/theme/src/settings.rs                                      |   1 
crates/zed/Cargo.toml                                             |   1 
crates/zed/src/main.rs                                            |   1 
crates/zed/src/zed.rs                                             |   1 
crates/zed_actions/src/lib.rs                                     |  10 
13 files changed, 698 insertions(+), 18 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14755,6 +14755,25 @@ dependencies = [
  "zlog",
 ]
 
+[[package]]
+name = "settings_profile_selector"
+version = "0.1.0"
+dependencies = [
+ "editor",
+ "fuzzy",
+ "gpui",
+ "language",
+ "menu",
+ "picker",
+ "project",
+ "serde_json",
+ "settings",
+ "ui",
+ "workspace",
+ "workspace-hack",
+ "zed_actions",
+]
+
 [[package]]
 name = "settings_ui"
 version = "0.1.0"
@@ -20321,6 +20340,7 @@ dependencies = [
  "serde_json",
  "session",
  "settings",
+ "settings_profile_selector",
  "settings_ui",
  "shellexpand 2.1.2",
  "smol",

Cargo.toml 🔗

@@ -119,6 +119,7 @@ members = [
     "crates/paths",
     "crates/picker",
     "crates/prettier",
+    "crates/settings_profile_selector",
     "crates/project",
     "crates/project_panel",
     "crates/project_symbols",
@@ -210,7 +211,7 @@ members = [
     #
 
     "tooling/workspace-hack",
-    "tooling/xtask",
+    "tooling/xtask", "crates/settings_profile_selector",
 ]
 default-members = ["crates/zed"]
 
@@ -342,6 +343,7 @@ picker = { path = "crates/picker" }
 plugin = { path = "crates/plugin" }
 plugin_macros = { path = "crates/plugin_macros" }
 prettier = { path = "crates/prettier" }
+settings_profile_selector = { path = "crates/settings_profile_selector" }
 project = { path = "crates/project" }
 project_panel = { path = "crates/project_panel" }
 project_symbols = { path = "crates/project_symbols" }

assets/settings/default.json 🔗

@@ -1877,5 +1877,8 @@
     "save_breakpoints": true,
     "dock": "bottom",
     "button": true
-  }
+  },
+  // Configures any number of settings profiles that are temporarily applied
+  // when selected from `settings profile selector: toggle`.
+  "profiles": []
 }

crates/settings/src/settings.rs 🔗

@@ -7,7 +7,7 @@ mod settings_json;
 mod settings_store;
 mod vscode_import;
 
-use gpui::App;
+use gpui::{App, Global};
 use rust_embed::RustEmbed;
 use std::{borrow::Cow, fmt, str};
 use util::asset_str;
@@ -27,6 +27,11 @@ pub use settings_store::{
 };
 pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};
 
+#[derive(Clone, Debug, PartialEq)]
+pub struct ActiveSettingsProfileName(pub String);
+
+impl Global for ActiveSettingsProfileName {}
+
 #[derive(Copy, Clone, PartialEq, Eq, Debug, Hash, PartialOrd, Ord)]
 pub struct WorktreeId(usize);
 
@@ -74,6 +79,7 @@ pub fn init(cx: &mut App) {
         .unwrap();
     cx.set_global(settings);
     BaseKeymap::register(cx);
+    SettingsStore::observe_active_settings_profile_name(cx).detach();
 }
 
 pub fn default_settings() -> Cow<'static, str> {

crates/settings/src/settings_store.rs 🔗

@@ -26,8 +26,8 @@ use util::{
 pub type EditorconfigProperties = ec4rs::Properties;
 
 use crate::{
-    ParameterizedJsonSchema, SettingsJsonSchemaParams, VsCodeSettings, WorktreeId,
-    parse_json_with_comments, update_value_in_json_text,
+    ActiveSettingsProfileName, ParameterizedJsonSchema, SettingsJsonSchemaParams, VsCodeSettings,
+    WorktreeId, parse_json_with_comments, update_value_in_json_text,
 };
 
 /// A value that can be defined as a user setting.
@@ -122,6 +122,8 @@ pub struct SettingsSources<'a, T> {
     pub user: Option<&'a T>,
     /// The user settings for the current release channel.
     pub release_channel: Option<&'a T>,
+    /// The settings associated with an enabled settings profile
+    pub profile: Option<&'a T>,
     /// The server's settings.
     pub server: Option<&'a T>,
     /// The project settings, ordered from least specific to most specific.
@@ -141,6 +143,7 @@ impl<'a, T: Serialize> SettingsSources<'a, T> {
             .chain(self.extensions)
             .chain(self.user)
             .chain(self.release_channel)
+            .chain(self.profile)
             .chain(self.server)
             .chain(self.project.iter().copied())
     }
@@ -282,6 +285,14 @@ impl SettingsStore {
         }
     }
 
+    pub fn observe_active_settings_profile_name(cx: &mut App) -> gpui::Subscription {
+        cx.observe_global::<ActiveSettingsProfileName>(|cx| {
+            Self::update_global(cx, |store, cx| {
+                store.recompute_values(None, cx).log_err();
+            });
+        })
+    }
+
     pub fn update<C, R>(cx: &mut C, f: impl FnOnce(&mut Self, &mut C) -> R) -> R
     where
         C: BorrowAppContext,
@@ -321,6 +332,17 @@ impl SettingsStore {
                     .log_err();
             }
 
+            let mut profile_value = None;
+            if let Some(active_profile) = cx.try_global::<ActiveSettingsProfileName>() {
+                if let Some(profiles) = self.raw_user_settings.get("profiles") {
+                    if let Some(profile_settings) = profiles.get(&active_profile.0) {
+                        profile_value = setting_value
+                            .deserialize_setting(profile_settings)
+                            .log_err();
+                    }
+                }
+            }
+
             let server_value = self
                 .raw_server_settings
                 .as_ref()
@@ -340,6 +362,7 @@ impl SettingsStore {
                         extensions: extension_value.as_ref(),
                         user: user_value.as_ref(),
                         release_channel: release_channel_value.as_ref(),
+                        profile: profile_value.as_ref(),
                         server: server_value.as_ref(),
                         project: &[],
                     },
@@ -402,6 +425,16 @@ impl SettingsStore {
         &self.raw_user_settings
     }
 
+    /// Get the configured settings profile names.
+    pub fn configured_settings_profiles(&self) -> impl Iterator<Item = &str> {
+        self.raw_user_settings
+            .get("profiles")
+            .and_then(|v| v.as_object())
+            .into_iter()
+            .flat_map(|obj| obj.keys())
+            .map(|s| s.as_str())
+    }
+
     /// Access the raw JSON value of the global settings.
     pub fn raw_global_settings(&self) -> Option<&Value> {
         self.raw_global_settings.as_ref()
@@ -1003,18 +1036,18 @@ impl SettingsStore {
         const ZED_SETTINGS: &str = "ZedSettings";
         let zed_settings_ref = add_new_subschema(&mut generator, ZED_SETTINGS, combined_schema);
 
-        // add `ZedReleaseStageSettings` which is the same as `ZedSettings` except that unknown
-        // fields are rejected.
-        let mut zed_release_stage_settings = zed_settings_ref.clone();
-        zed_release_stage_settings.insert("unevaluatedProperties".to_string(), false.into());
-        let zed_release_stage_settings_ref = add_new_subschema(
+        // add `ZedSettingsOverride` which is the same as `ZedSettings` except that unknown
+        // fields are rejected. This is used for release stage settings and profiles.
+        let mut zed_settings_override = zed_settings_ref.clone();
+        zed_settings_override.insert("unevaluatedProperties".to_string(), false.into());
+        let zed_settings_override_ref = add_new_subschema(
             &mut generator,
-            "ZedReleaseStageSettings",
-            zed_release_stage_settings.to_value(),
+            "ZedSettingsOverride",
+            zed_settings_override.to_value(),
         );
 
         // Remove `"additionalProperties": false` added by `DefaultDenyUnknownFields` so that
-        // unknown fields can be handled by the root schema and `ZedReleaseStageSettings`.
+        // unknown fields can be handled by the root schema and `ZedSettingsOverride`.
         let mut definitions = generator.take_definitions(true);
         definitions
             .get_mut(ZED_SETTINGS)
@@ -1034,15 +1067,20 @@ impl SettingsStore {
             "$schema": meta_schema,
             "title": "Zed Settings",
             "unevaluatedProperties": false,
-            // ZedSettings + settings overrides for each release stage
+            // ZedSettings + settings overrides for each release stage / profiles
             "allOf": [
                 zed_settings_ref,
                 {
                     "properties": {
-                        "dev": zed_release_stage_settings_ref,
-                        "nightly": zed_release_stage_settings_ref,
-                        "stable": zed_release_stage_settings_ref,
-                        "preview": zed_release_stage_settings_ref,
+                        "dev": zed_settings_override_ref,
+                        "nightly": zed_settings_override_ref,
+                        "stable": zed_settings_override_ref,
+                        "preview": zed_settings_override_ref,
+                        "profiles": {
+                            "type": "object",
+                            "description": "Configures any number of settings profiles that are temporarily applied when selected from `settings profile selector: toggle`.",
+                            "additionalProperties": zed_settings_override_ref
+                        }
                     }
                 }
             ],
@@ -1101,6 +1139,16 @@ impl SettingsStore {
                 }
             }
 
+            let mut profile_settings = None;
+            if let Some(active_profile) = cx.try_global::<ActiveSettingsProfileName>() {
+                if let Some(profiles) = self.raw_user_settings.get("profiles") {
+                    if let Some(profile_json) = profiles.get(&active_profile.0) {
+                        profile_settings =
+                            setting_value.deserialize_setting(profile_json).log_err();
+                    }
+                }
+            }
+
             // If the global settings file changed, reload the global value for the field.
             if changed_local_path.is_none() {
                 if let Some(value) = setting_value
@@ -1111,6 +1159,7 @@ impl SettingsStore {
                             extensions: extension_settings.as_ref(),
                             user: user_settings.as_ref(),
                             release_channel: release_channel_settings.as_ref(),
+                            profile: profile_settings.as_ref(),
                             server: server_settings.as_ref(),
                             project: &[],
                         },
@@ -1163,6 +1212,7 @@ impl SettingsStore {
                                     extensions: extension_settings.as_ref(),
                                     user: user_settings.as_ref(),
                                     release_channel: release_channel_settings.as_ref(),
+                                    profile: profile_settings.as_ref(),
                                     server: server_settings.as_ref(),
                                     project: &project_settings_stack.iter().collect::<Vec<_>>(),
                                 },
@@ -1288,6 +1338,9 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
                 release_channel: values
                     .release_channel
                     .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
+                profile: values
+                    .profile
+                    .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),
                 server: values
                     .server
                     .map(|value| value.0.downcast_ref::<T::FileContent>().unwrap()),

crates/settings_profile_selector/Cargo.toml 🔗

@@ -0,0 +1,33 @@
+[package]
+name = "settings_profile_selector"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/settings_profile_selector.rs"
+doctest = false
+
+[dependencies]
+fuzzy.workspace = true
+gpui.workspace = true
+picker.workspace = true
+settings.workspace = true
+ui.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true
+
+[dev-dependencies]
+editor = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+language = { workspace = true, features = ["test-support"] }
+menu.workspace = true
+project = { workspace = true, features = ["test-support"] }
+serde_json.workspace = true
+settings = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/settings_profile_selector/src/settings_profile_selector.rs 🔗

@@ -0,0 +1,548 @@
+use fuzzy::{StringMatch, StringMatchCandidate, match_strings};
+use gpui::{
+    App, Context, DismissEvent, Entity, EventEmitter, Focusable, Render, Task, WeakEntity, Window,
+};
+use picker::{Picker, PickerDelegate};
+use settings::{ActiveSettingsProfileName, SettingsStore};
+use ui::{HighlightedLabel, ListItem, ListItemSpacing, prelude::*};
+use workspace::{ModalView, Workspace};
+
+pub fn init(cx: &mut App) {
+    cx.on_action(|_: &zed_actions::settings_profile_selector::Toggle, cx| {
+        workspace::with_active_or_new_workspace(cx, |workspace, window, cx| {
+            toggle_settings_profile_selector(workspace, window, cx);
+        });
+    });
+}
+
+fn toggle_settings_profile_selector(
+    workspace: &mut Workspace,
+    window: &mut Window,
+    cx: &mut Context<Workspace>,
+) {
+    workspace.toggle_modal(window, cx, |window, cx| {
+        let delegate = SettingsProfileSelectorDelegate::new(cx.entity().downgrade(), window, cx);
+        SettingsProfileSelector::new(delegate, window, cx)
+    });
+}
+
+pub struct SettingsProfileSelector {
+    picker: Entity<Picker<SettingsProfileSelectorDelegate>>,
+}
+
+impl ModalView for SettingsProfileSelector {}
+
+impl EventEmitter<DismissEvent> for SettingsProfileSelector {}
+
+impl Focusable for SettingsProfileSelector {
+    fn focus_handle(&self, cx: &App) -> gpui::FocusHandle {
+        self.picker.focus_handle(cx)
+    }
+}
+
+impl Render for SettingsProfileSelector {
+    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
+        v_flex().w(rems(34.)).child(self.picker.clone())
+    }
+}
+
+impl SettingsProfileSelector {
+    pub fn new(
+        delegate: SettingsProfileSelectorDelegate,
+        window: &mut Window,
+        cx: &mut Context<Self>,
+    ) -> Self {
+        let picker = cx.new(|cx| Picker::uniform_list(delegate, window, cx));
+        Self { picker }
+    }
+}
+
+pub struct SettingsProfileSelectorDelegate {
+    matches: Vec<StringMatch>,
+    profile_names: Vec<Option<String>>,
+    original_profile_name: Option<String>,
+    selected_profile_name: Option<String>,
+    selected_index: usize,
+    selection_completed: bool,
+    selector: WeakEntity<SettingsProfileSelector>,
+}
+
+impl SettingsProfileSelectorDelegate {
+    fn new(
+        selector: WeakEntity<SettingsProfileSelector>,
+        _: &mut Window,
+        cx: &mut Context<SettingsProfileSelector>,
+    ) -> Self {
+        let settings_store = cx.global::<SettingsStore>();
+        let mut profile_names: Vec<String> = settings_store
+            .configured_settings_profiles()
+            .map(|s| s.to_string())
+            .collect();
+
+        profile_names.sort();
+        let mut profile_names: Vec<_> = profile_names.into_iter().map(Some).collect();
+        profile_names.insert(0, None);
+
+        let matches = profile_names
+            .iter()
+            .enumerate()
+            .map(|(ix, profile_name)| StringMatch {
+                candidate_id: ix,
+                score: 0.0,
+                positions: Default::default(),
+                string: display_name(profile_name),
+            })
+            .collect();
+
+        let profile_name = cx
+            .try_global::<ActiveSettingsProfileName>()
+            .map(|p| p.0.clone());
+
+        let mut this = Self {
+            matches,
+            profile_names,
+            original_profile_name: profile_name.clone(),
+            selected_profile_name: None,
+            selected_index: 0,
+            selection_completed: false,
+            selector,
+        };
+
+        if let Some(profile_name) = profile_name {
+            this.select_if_matching(&profile_name);
+        }
+
+        this
+    }
+
+    fn select_if_matching(&mut self, profile_name: &str) {
+        self.selected_index = self
+            .matches
+            .iter()
+            .position(|mat| mat.string == profile_name)
+            .unwrap_or(self.selected_index);
+    }
+
+    fn set_selected_profile(
+        &self,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) -> Option<String> {
+        let mat = self.matches.get(self.selected_index)?;
+        let profile_name = self.profile_names.get(mat.candidate_id)?;
+        return Self::update_active_profile_name_global(profile_name.clone(), cx);
+    }
+
+    fn update_active_profile_name_global(
+        profile_name: Option<String>,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) -> Option<String> {
+        if let Some(profile_name) = profile_name {
+            cx.set_global(ActiveSettingsProfileName(profile_name.clone()));
+            return Some(profile_name.clone());
+        }
+
+        if cx.has_global::<ActiveSettingsProfileName>() {
+            cx.remove_global::<ActiveSettingsProfileName>();
+        }
+
+        None
+    }
+}
+
+impl PickerDelegate for SettingsProfileSelectorDelegate {
+    type ListItem = ListItem;
+
+    fn placeholder_text(&self, _: &mut Window, _: &mut App) -> std::sync::Arc<str> {
+        "Select a settings profile...".into()
+    }
+
+    fn match_count(&self) -> usize {
+        self.matches.len()
+    }
+
+    fn selected_index(&self) -> usize {
+        self.selected_index
+    }
+
+    fn set_selected_index(
+        &mut self,
+        ix: usize,
+        _: &mut Window,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) {
+        self.selected_index = ix;
+        self.selected_profile_name = self.set_selected_profile(cx);
+    }
+
+    fn update_matches(
+        &mut self,
+        query: String,
+        window: &mut Window,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) -> Task<()> {
+        let background = cx.background_executor().clone();
+        let candidates = self
+            .profile_names
+            .iter()
+            .enumerate()
+            .map(|(id, profile_name)| StringMatchCandidate::new(id, &display_name(profile_name)))
+            .collect::<Vec<_>>();
+
+        cx.spawn_in(window, async move |this, cx| {
+            let matches = if query.is_empty() {
+                candidates
+                    .into_iter()
+                    .enumerate()
+                    .map(|(index, candidate)| StringMatch {
+                        candidate_id: index,
+                        string: candidate.string,
+                        positions: Vec::new(),
+                        score: 0.0,
+                    })
+                    .collect()
+            } else {
+                match_strings(
+                    &candidates,
+                    &query,
+                    false,
+                    true,
+                    100,
+                    &Default::default(),
+                    background,
+                )
+                .await
+            };
+
+            this.update_in(cx, |this, _, cx| {
+                this.delegate.matches = matches;
+                this.delegate.selected_index = this
+                    .delegate
+                    .selected_index
+                    .min(this.delegate.matches.len().saturating_sub(1));
+                this.delegate.selected_profile_name = this.delegate.set_selected_profile(cx);
+            })
+            .ok();
+        })
+    }
+
+    fn confirm(
+        &mut self,
+        _: bool,
+        _: &mut Window,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) {
+        self.selection_completed = true;
+        self.selector
+            .update(cx, |_, cx| {
+                cx.emit(DismissEvent);
+            })
+            .ok();
+    }
+
+    fn dismissed(
+        &mut self,
+        _: &mut Window,
+        cx: &mut Context<Picker<SettingsProfileSelectorDelegate>>,
+    ) {
+        if !self.selection_completed {
+            SettingsProfileSelectorDelegate::update_active_profile_name_global(
+                self.original_profile_name.clone(),
+                cx,
+            );
+        }
+        self.selector.update(cx, |_, cx| cx.emit(DismissEvent)).ok();
+    }
+
+    fn render_match(
+        &self,
+        ix: usize,
+        selected: bool,
+        _: &mut Window,
+        _: &mut Context<Picker<Self>>,
+    ) -> Option<Self::ListItem> {
+        let mat = &self.matches[ix];
+        let profile_name = &self.profile_names[mat.candidate_id];
+
+        Some(
+            ListItem::new(ix)
+                .inset(true)
+                .spacing(ListItemSpacing::Sparse)
+                .toggle_state(selected)
+                .child(HighlightedLabel::new(
+                    display_name(profile_name),
+                    mat.positions.clone(),
+                )),
+        )
+    }
+}
+
+fn display_name(profile_name: &Option<String>) -> String {
+    profile_name.clone().unwrap_or("Disabled".into())
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+    use editor;
+    use gpui::{TestAppContext, UpdateGlobal, VisualTestContext};
+    use language;
+    use menu::{Cancel, Confirm, SelectNext, SelectPrevious};
+    use project::{FakeFs, Project};
+    use serde_json::json;
+    use workspace::{self, AppState};
+    use zed_actions::settings_profile_selector;
+
+    async fn init_test(
+        profiles_json: serde_json::Value,
+        cx: &mut TestAppContext,
+    ) -> (Entity<Workspace>, &mut VisualTestContext) {
+        cx.update(|cx| {
+            let state = AppState::test(cx);
+            language::init(cx);
+            super::init(cx);
+            editor::init(cx);
+            workspace::init_settings(cx);
+            Project::init_settings(cx);
+            state
+        });
+
+        cx.update(|cx| {
+            SettingsStore::update_global(cx, |store, cx| {
+                let settings_json = json!({
+                    "profiles": profiles_json
+                });
+
+                store
+                    .set_user_settings(&settings_json.to_string(), cx)
+                    .unwrap();
+            });
+        });
+
+        let fs = FakeFs::new(cx.executor());
+        let project = Project::test(fs, ["/test".as_ref()], cx).await;
+        let (workspace, cx) =
+            cx.add_window_view(|window, cx| Workspace::test_new(project.clone(), window, cx));
+
+        cx.update(|_, cx| {
+            assert!(!cx.has_global::<ActiveSettingsProfileName>());
+        });
+
+        (workspace, cx)
+    }
+
+    #[track_caller]
+    fn active_settings_profile_picker(
+        workspace: &Entity<Workspace>,
+        cx: &mut VisualTestContext,
+    ) -> Entity<Picker<SettingsProfileSelectorDelegate>> {
+        workspace.update(cx, |workspace, cx| {
+            workspace
+                .active_modal::<SettingsProfileSelector>(cx)
+                .expect("settings profile selector is not open")
+                .read(cx)
+                .picker
+                .clone()
+        })
+    }
+
+    #[gpui::test]
+    async fn test_settings_profile_selector_state(cx: &mut TestAppContext) {
+        let profiles_json = json!({
+            "Demo Videos": {
+                "buffer_font_size": 14
+            },
+            "Classroom / Streaming": {
+                "buffer_font_size": 16,
+                "vim_mode": true
+            }
+        });
+        let (workspace, cx) = init_test(profiles_json.clone(), cx).await;
+
+        cx.dispatch_action(settings_profile_selector::Toggle);
+
+        let picker = active_settings_profile_picker(&workspace, cx);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.matches.len(), 3);
+            assert_eq!(picker.delegate.matches[0].string, "Disabled");
+            assert_eq!(picker.delegate.matches[1].string, "Classroom / Streaming");
+            assert_eq!(picker.delegate.matches[2].string, "Demo Videos");
+            assert_eq!(picker.delegate.matches.get(3), None);
+
+            assert_eq!(picker.delegate.selected_index, 0);
+            assert_eq!(picker.delegate.selected_profile_name, None);
+
+            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
+        });
+
+        cx.dispatch_action(Confirm);
+
+        cx.update(|_, cx| {
+            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
+        });
+
+        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_index, 1);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Classroom / Streaming".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Classroom / Streaming".to_string())
+            );
+        });
+
+        cx.dispatch_action(Cancel);
+
+        cx.update(|_, cx| {
+            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
+        });
+
+        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_index, 1);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Classroom / Streaming".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Classroom / Streaming".to_string())
+            );
+        });
+
+        cx.dispatch_action(SelectNext);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 2);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Demo Videos".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Demo Videos".to_string())
+            );
+        });
+
+        cx.dispatch_action(Confirm);
+
+        cx.update(|_, cx| {
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Demo Videos".to_string())
+            );
+        });
+
+        cx.dispatch_action(settings_profile_selector::Toggle);
+        let picker = active_settings_profile_picker(&workspace, cx);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 2);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Demo Videos".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Demo Videos".to_string())
+            );
+        });
+
+        cx.dispatch_action(SelectPrevious);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 1);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Classroom / Streaming".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Classroom / Streaming".to_string())
+            );
+        });
+
+        cx.dispatch_action(Cancel);
+
+        cx.update(|_, cx| {
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Demo Videos".to_string())
+            );
+        });
+
+        cx.dispatch_action(settings_profile_selector::Toggle);
+        let picker = active_settings_profile_picker(&workspace, cx);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 2);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Demo Videos".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Demo Videos".to_string())
+            );
+        });
+
+        cx.dispatch_action(SelectPrevious);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 1);
+            assert_eq!(
+                picker.delegate.selected_profile_name,
+                Some("Classroom / Streaming".to_string())
+            );
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                Some("Classroom / Streaming".to_string())
+            );
+        });
+
+        cx.dispatch_action(SelectPrevious);
+
+        picker.read_with(cx, |picker, cx| {
+            assert_eq!(picker.delegate.selected_index, 0);
+            assert_eq!(picker.delegate.selected_profile_name, None);
+
+            assert_eq!(
+                cx.try_global::<ActiveSettingsProfileName>()
+                    .map(|p| p.0.clone()),
+                None
+            );
+        });
+
+        cx.dispatch_action(Confirm);
+
+        cx.update(|_, cx| {
+            assert_eq!(cx.try_global::<ActiveSettingsProfileName>(), None);
+        });
+    }
+}

crates/theme/src/settings.rs 🔗

@@ -867,6 +867,7 @@ impl settings::Settings for ThemeSettings {
             .user
             .into_iter()
             .chain(sources.release_channel)
+            .chain(sources.profile)
             .chain(sources.server)
         {
             if let Some(value) = value.ui_density {

crates/zed/Cargo.toml 🔗

@@ -106,6 +106,7 @@ outline_panel.workspace = true
 parking_lot.workspace = true
 paths.workspace = true
 picker.workspace = true
+settings_profile_selector.workspace = true
 profiling.workspace = true
 project.workspace = true
 project_panel.workspace = true

crates/zed/src/main.rs 🔗

@@ -613,6 +613,7 @@ pub fn main() {
         language_selector::init(cx);
         toolchain_selector::init(cx);
         theme_selector::init(cx);
+        settings_profile_selector::init(cx);
         language_tools::init(cx);
         call::init(app_state.client.clone(), app_state.user_store.clone(), cx);
         notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx);

crates/zed/src/zed.rs 🔗

@@ -4366,6 +4366,7 @@ mod tests {
                 "repl",
                 "rules_library",
                 "search",
+                "settings_profile_selector",
                 "snippets",
                 "supermaven",
                 "svg",

crates/zed_actions/src/lib.rs 🔗

@@ -260,6 +260,16 @@ pub mod icon_theme_selector {
     }
 }
 
+pub mod settings_profile_selector {
+    use gpui::Action;
+    use schemars::JsonSchema;
+    use serde::Deserialize;
+
+    #[derive(PartialEq, Clone, Default, Debug, Deserialize, JsonSchema, Action)]
+    #[action(namespace = settings_profile_selector)]
+    pub struct Toggle;
+}
+
 pub mod agent {
     use gpui::actions;