settings ui: Start work on creating the initial structure (#36904)

Anthony Eid and Ben Kunkle created

## Goal 

This PR creates the initial settings ui structure with the primary goal
of making a settings UI that is
- Comprehensive: All settings are available through the UI
- Correct: Easy to understand the underlying JSON file from the UI
- Intuitive
- Easy to implement per setting so that UI is not a hindrance to future
settings changes

### Structure

The overall structure is settings layer -> data layer -> ui layer.

The settings layer is the pre-existing settings definitions, that
implement the `Settings` trait. The data layer is constructed from
settings primarily through the `SettingsUi` trait, and it's associated
derive macro. The data layer tracks the grouping of the settings, the
json path of the settings, and a data representation of how to render
the controls for the setting in the UI, that is either a marker value
for the component to use (avoiding a dependency on the `ui` crate) or a
custom render function.

Abstracting the data layer from the ui layer allows crates depending on
`settings` to implement their own UI without having to add additional UI
dependencies, thus avoiding circular dependencies. In cases where custom
UI is desired, and a creating a custom render function in the same crate
is infeasible due to circular dependencies, the current solution is to
implement a marker for the component in the `settings` crate, and then
handle the rendering of that component in `settings_ui`.

### Foundation 

This PR creates a macro and a trait both called `SettingsUi`. The
`SettingsUi` trait is added as a new trait bound on the `Settings`
trait, this allows the type system to guarantee that all settings
implement UI functionality. The macro is used to derived the trait for
most types, and can be modified through attributes for unique cases as
well.

A derive-macro is used to generate the settings UI trait impl, allowing
it the UI generation to be generated from the static information in our
code base (`default.json`, Struct/Enum names, field names, `serde`
attributes, etc). This allows the UI to be auto-generated for the most
part, and ensures consistency across the UI.


#### Immediate Follow ups

- Add a new `SettingsPath` trait that will be a trait bound on
`SettingsUi` and `Settings`
- This trait will replace the `Settings::key` value to enable
`SettingsUi` to infer the json path of it's derived type
- Figure out how to render `Option<T> where T: SettingsUi` correctly
- Handle `serde` attributes in the `SettingsUi` proc macro to correctly
get json path from a type's field and identity

Release Notes:

- N/A

---------

Co-authored-by: Ben Kunkle <ben@zed.dev>

Change summary

Cargo.lock                                                |  75 
Cargo.toml                                                |  10 
assets/settings/default.json                              |   3 
crates/agent_servers/src/settings.rs                      |   4 
crates/agent_settings/src/agent_settings.rs               |   4 
crates/agent_ui/src/slash_command_settings.rs             |   4 
crates/audio/src/audio_settings.rs                        |   4 
crates/auto_update/src/auto_update.rs                     |   3 
crates/call/src/call_settings.rs                          |   4 
crates/client/src/client.rs                               |   8 
crates/collab_ui/src/panel_settings.rs                    |  10 
crates/dap/src/debugger_settings.rs                       |   8 
crates/editor/src/editor_settings.rs                      |   4 
crates/extension_host/src/extension_settings.rs           |   4 
crates/file_finder/src/file_finder_settings.rs            |   4 
crates/git_hosting_providers/src/settings.rs              |   4 
crates/git_ui/src/git_panel_settings.rs                   |   4 
crates/go_to_line/src/cursor_position.rs                  |   4 
crates/gpui_macros/src/derive_action.rs                   |   7 
crates/image_viewer/src/image_viewer_settings.rs          |   4 
crates/journal/src/journal.rs                             |   4 
crates/keymap_editor/Cargo.toml                           |  53 
crates/keymap_editor/LICENSE-GPL                          |   1 
crates/keymap_editor/src/keymap_editor.rs                 |   6 
crates/keymap_editor/src/ui_components/keystroke_input.rs |   0 
crates/keymap_editor/src/ui_components/mod.rs             |   0 
crates/keymap_editor/src/ui_components/table.rs           |   0 
crates/language/src/language_settings.rs                  |   4 
crates/language_models/src/settings.rs                    |   4 
crates/outline_panel/src/outline_panel_settings.rs        |   4 
crates/project/src/project.rs                             |   2 
crates/project/src/project_settings.rs                    |   4 
crates/project_panel/src/project_panel_settings.rs        |   4 
crates/recent_projects/src/ssh_connections.rs             |   4 
crates/repl/src/jupyter_settings.rs                       |   4 
crates/settings/Cargo.toml                                |   1 
crates/settings/src/base_keymap_setting.rs                |   8 
crates/settings/src/settings.rs                           |   4 
crates/settings/src/settings_json.rs                      |  12 
crates/settings/src/settings_store.rs                     | 144 +-
crates/settings/src/settings_ui.rs                        | 118 ++
crates/settings/src/vscode_import.rs                      |   4 
crates/settings_ui/Cargo.toml                             |  41 
crates/settings_ui/src/settings_ui.rs                     | 500 ++++++++
crates/settings_ui_macros/Cargo.toml                      |  22 
crates/settings_ui_macros/LICENSE-GPL                     |   1 
crates/settings_ui_macros/src/settings_ui_macros.rs       | 201 +++
crates/terminal/src/terminal_settings.rs                  |   4 
crates/theme/src/settings.rs                              |   4 
crates/title_bar/Cargo.toml                               |   2 
crates/title_bar/src/title_bar.rs                         |   6 
crates/title_bar/src/title_bar_settings.rs                |   5 
crates/vim/src/vim.rs                                     |   4 
crates/vim_mode_setting/src/vim_mode_setting.rs           |   4 
crates/workspace/src/item.rs                              |   6 
crates/workspace/src/workspace_settings.rs                |   6 
crates/worktree/src/worktree_settings.rs                  |   4 
crates/zed/Cargo.toml                                     |   1 
crates/zed/src/main.rs                                    |   1 
crates/zed/src/zed.rs                                     |   2 
crates/zed/src/zed/app_menus.rs                           |   3 
crates/zlog_settings/src/zlog_settings.rs                 |   4 
62 files changed, 1,149 insertions(+), 229 deletions(-)

Detailed changes

Cargo.lock πŸ”—

@@ -8951,6 +8951,44 @@ dependencies = [
  "uuid",
 ]
 
+[[package]]
+name = "keymap_editor"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "collections",
+ "command_palette",
+ "component",
+ "db",
+ "editor",
+ "fs",
+ "fuzzy",
+ "gpui",
+ "itertools 0.14.0",
+ "language",
+ "log",
+ "menu",
+ "notifications",
+ "paths",
+ "project",
+ "search",
+ "serde",
+ "serde_json",
+ "settings",
+ "telemetry",
+ "tempfile",
+ "theme",
+ "tree-sitter-json",
+ "tree-sitter-rust",
+ "ui",
+ "ui_input",
+ "util",
+ "vim",
+ "workspace",
+ "workspace-hack",
+ "zed_actions",
+]
+
 [[package]]
 name = "khronos-egl"
 version = "6.0.0"
@@ -14856,6 +14894,7 @@ dependencies = [
  "serde_derive",
  "serde_json",
  "serde_json_lenient",
+ "settings_ui_macros",
  "smallvec",
  "tree-sitter",
  "tree-sitter-json",
@@ -14891,39 +14930,28 @@ name = "settings_ui"
 version = "0.1.0"
 dependencies = [
  "anyhow",
- "collections",
- "command_palette",
  "command_palette_hooks",
- "component",
- "db",
  "editor",
  "feature_flags",
- "fs",
- "fuzzy",
  "gpui",
- "itertools 0.14.0",
- "language",
- "log",
- "menu",
- "notifications",
- "paths",
- "project",
- "search",
  "serde",
  "serde_json",
  "settings",
- "telemetry",
- "tempfile",
+ "smallvec",
  "theme",
- "tree-sitter-json",
- "tree-sitter-rust",
  "ui",
- "ui_input",
- "util",
- "vim",
  "workspace",
  "workspace-hack",
- "zed_actions",
+]
+
+[[package]]
+name = "settings_ui_macros"
+version = "0.1.0"
+dependencies = [
+ "proc-macro2",
+ "quote",
+ "syn 2.0.101",
+ "workspace-hack",
 ]
 
 [[package]]
@@ -16739,6 +16767,7 @@ dependencies = [
  "db",
  "gpui",
  "http_client",
+ "keymap_editor",
  "notifications",
  "pretty_assertions",
  "project",
@@ -16747,7 +16776,6 @@ dependencies = [
  "schemars",
  "serde",
  "settings",
- "settings_ui",
  "smallvec",
  "story",
  "telemetry",
@@ -20458,6 +20486,7 @@ dependencies = [
  "itertools 0.14.0",
  "jj_ui",
  "journal",
+ "keymap_editor",
  "language",
  "language_extension",
  "language_model",

Cargo.toml πŸ”—

@@ -54,6 +54,8 @@ members = [
     "crates/deepseek",
     "crates/diagnostics",
     "crates/docs_preprocessor",
+    "crates/edit_prediction",
+    "crates/edit_prediction_button",
     "crates/editor",
     "crates/eval",
     "crates/explorer_command_injector",
@@ -82,13 +84,12 @@ members = [
     "crates/http_client_tls",
     "crates/icons",
     "crates/image_viewer",
-    "crates/edit_prediction",
-    "crates/edit_prediction_button",
     "crates/inspector_ui",
     "crates/install_cli",
     "crates/jj",
     "crates/jj_ui",
     "crates/journal",
+    "crates/keymap_editor",
     "crates/language",
     "crates/language_extension",
     "crates/language_model",
@@ -146,6 +147,7 @@ members = [
     "crates/settings",
     "crates/settings_profile_selector",
     "crates/settings_ui",
+    "crates/settings_ui_macros",
     "crates/snippet",
     "crates/snippet_provider",
     "crates/snippets_ui",
@@ -156,9 +158,9 @@ members = [
     "crates/streaming_diff",
     "crates/sum_tree",
     "crates/supermaven",
-    "crates/system_specs",
     "crates/supermaven_api",
     "crates/svg_preview",
+    "crates/system_specs",
     "crates/tab_switcher",
     "crates/task",
     "crates/tasks_ui",
@@ -314,6 +316,7 @@ install_cli = { path = "crates/install_cli" }
 jj = { path = "crates/jj" }
 jj_ui = { path = "crates/jj_ui" }
 journal = { path = "crates/journal" }
+keymap_editor = { path = "crates/keymap_editor" }
 language = { path = "crates/language" }
 language_extension = { path = "crates/language_extension" }
 language_model = { path = "crates/language_model" }
@@ -373,6 +376,7 @@ semantic_version = { path = "crates/semantic_version" }
 session = { path = "crates/session" }
 settings = { path = "crates/settings" }
 settings_ui = { path = "crates/settings_ui" }
+settings_ui_macros = { path = "crates/settings_ui_macros" }
 snippet = { path = "crates/snippet" }
 snippet_provider = { path = "crates/snippet_provider" }
 snippets_ui = { path = "crates/snippets_ui" }

assets/settings/default.json πŸ”—

@@ -1922,7 +1922,10 @@
   "debugger": {
     "stepping_granularity": "line",
     "save_breakpoints": true,
+    "timeout": 2000,
     "dock": "bottom",
+    "log_dap_communications": true,
+    "format_dap_log_messages": true,
     "button": true
   },
   // Configures any number of settings profiles that are temporarily applied on

crates/agent_servers/src/settings.rs πŸ”—

@@ -6,13 +6,13 @@ use collections::HashMap;
 use gpui::{App, SharedString};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 pub fn init(cx: &mut App) {
     AllAgentServersSettings::register(cx);
 }
 
-#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug)]
+#[derive(Default, Deserialize, Serialize, Clone, JsonSchema, Debug, SettingsUi)]
 pub struct AllAgentServersSettings {
     pub gemini: Option<BuiltinAgentServerSettings>,
     pub claude: Option<CustomAgentServerSettings>,

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

@@ -8,7 +8,7 @@ use gpui::{App, Pixels, SharedString};
 use language_model::LanguageModel;
 use schemars::{JsonSchema, json_schema};
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use std::borrow::Cow;
 
 pub use crate::agent_profile::*;
@@ -48,7 +48,7 @@ pub enum NotifyWhenAgentWaiting {
     Never,
 }
 
-#[derive(Default, Clone, Debug)]
+#[derive(Default, Clone, Debug, SettingsUi)]
 pub struct AgentSettings {
     pub enabled: bool,
     pub button: bool,

crates/agent_ui/src/slash_command_settings.rs πŸ”—

@@ -2,10 +2,10 @@ use anyhow::Result;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 /// Settings for slash commands.
-#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
+#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema, SettingsUi)]
 pub struct SlashCommandSettings {
     /// Settings for the `/cargo-workspace` slash command.
     #[serde(default)]

crates/audio/src/audio_settings.rs πŸ”—

@@ -2,9 +2,9 @@ use anyhow::Result;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Deserialize, Debug)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
 pub struct AudioSettings {
     /// Opt into the new audio system.
     #[serde(rename = "experimental.rodio_audio", default)]

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

@@ -10,7 +10,7 @@ use paths::remote_servers_dir;
 use release_channel::{AppCommitSha, ReleaseChannel};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources, SettingsStore};
+use settings::{Settings, SettingsSources, SettingsStore, SettingsUi};
 use smol::{fs, io::AsyncReadExt};
 use smol::{fs::File, process::Command};
 use std::{
@@ -113,6 +113,7 @@ impl Drop for MacOsUnmounter {
     }
 }
 
+#[derive(SettingsUi)]
 struct AutoUpdateSetting(bool);
 
 /// Whether or not to automatically check for updates.

crates/call/src/call_settings.rs πŸ”—

@@ -2,9 +2,9 @@ use anyhow::Result;
 use gpui::App;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, SettingsUi)]
 pub struct CallSettings {
     pub mute_on_join: bool,
     pub share_on_join: bool,

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

@@ -31,7 +31,7 @@ use release_channel::{AppVersion, ReleaseChannel};
 use rpc::proto::{AnyTypedEnvelope, EnvelopedMessage, PeerId, RequestMessage};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use std::{
     any::TypeId,
     convert::TryFrom,
@@ -101,7 +101,7 @@ pub struct ClientSettingsContent {
     server_url: Option<String>,
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct ClientSettings {
     pub server_url: String,
 }
@@ -127,7 +127,7 @@ pub struct ProxySettingsContent {
     proxy: Option<String>,
 }
 
-#[derive(Deserialize, Default)]
+#[derive(Deserialize, Default, SettingsUi)]
 pub struct ProxySettings {
     pub proxy: Option<String>,
 }
@@ -520,7 +520,7 @@ impl<T: 'static> Drop for PendingEntitySubscription<T> {
     }
 }
 
-#[derive(Copy, Clone, Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, SettingsUi)]
 pub struct TelemetrySettings {
     pub diagnostics: bool,
     pub metrics: bool,

crates/collab_ui/src/panel_settings.rs πŸ”—

@@ -1,10 +1,10 @@
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use workspace::dock::DockPosition;
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, SettingsUi)]
 pub struct CollaborationPanelSettings {
     pub button: bool,
     pub dock: DockPosition,
@@ -20,7 +20,7 @@ pub enum ChatPanelButton {
     WhenInCall,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, SettingsUi)]
 pub struct ChatPanelSettings {
     pub button: ChatPanelButton,
     pub dock: DockPosition,
@@ -43,7 +43,7 @@ pub struct ChatPanelSettingsContent {
     pub default_width: Option<f32>,
 }
 
-#[derive(Deserialize, Debug)]
+#[derive(Deserialize, Debug, SettingsUi)]
 pub struct NotificationPanelSettings {
     pub button: bool,
     pub dock: DockPosition,
@@ -66,7 +66,7 @@ pub struct PanelSettingsContent {
     pub default_width: Option<f32>,
 }
 
-#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug, SettingsUi)]
 pub struct MessageEditorSettings {
     /// Whether to automatically replace emoji shortcodes with emoji characters.
     /// For example: typing `:wave:` gets replaced with `πŸ‘‹`.

crates/dap/src/debugger_settings.rs πŸ”—

@@ -2,9 +2,9 @@ use dap_types::SteppingGranularity;
 use gpui::{App, Global};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
+#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, SettingsUi)]
 #[serde(rename_all = "snake_case")]
 pub enum DebugPanelDockPosition {
     Left,
@@ -12,12 +12,14 @@ pub enum DebugPanelDockPosition {
     Right,
 }
 
-#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy)]
+#[derive(Serialize, Deserialize, JsonSchema, Clone, Copy, SettingsUi)]
 #[serde(default)]
+#[settings_ui(group = "Debugger", path = "debugger")]
 pub struct DebuggerSettings {
     /// Determines the stepping granularity.
     ///
     /// Default: line
+    #[settings_ui(skip)]
     pub stepping_granularity: SteppingGranularity,
     /// Whether the breakpoints should be reused across Zed sessions.
     ///

crates/editor/src/editor_settings.rs πŸ”—

@@ -6,12 +6,12 @@ use language::CursorShape;
 use project::project_settings::DiagnosticSeverity;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources, VsCodeSettings};
+use settings::{Settings, SettingsSources, SettingsUi, VsCodeSettings};
 use util::serde::default_true;
 
 /// Imports from the VSCode settings at
 /// https://code.visualstudio.com/docs/reference/default-settings
-#[derive(Deserialize, Clone)]
+#[derive(Deserialize, Clone, SettingsUi)]
 pub struct EditorSettings {
     pub cursor_blink: bool,
     pub cursor_shape: Option<CursorShape>,

crates/extension_host/src/extension_settings.rs πŸ”—

@@ -3,10 +3,10 @@ use collections::HashMap;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use std::sync::Arc;
 
-#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema)]
+#[derive(Deserialize, Serialize, Debug, Default, Clone, JsonSchema, SettingsUi)]
 pub struct ExtensionSettings {
     /// The extensions that should be automatically installed by Zed.
     ///

crates/file_finder/src/file_finder_settings.rs πŸ”—

@@ -1,9 +1,9 @@
 use anyhow::Result;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, SettingsUi)]
 pub struct FileFinderSettings {
     pub file_icons: bool,
     pub modal_max_width: Option<FileFinderWidth>,

crates/git_hosting_providers/src/settings.rs πŸ”—

@@ -5,7 +5,7 @@ use git::GitHostingProviderRegistry;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsStore};
+use settings::{Settings, SettingsStore, SettingsUi};
 use url::Url;
 use util::ResultExt as _;
 
@@ -78,7 +78,7 @@ pub struct GitHostingProviderConfig {
     pub name: String,
 }
 
-#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema)]
+#[derive(Default, Debug, Clone, Serialize, Deserialize, JsonSchema, SettingsUi)]
 pub struct GitHostingProviderSettings {
     /// The list of custom Git hosting providers.
     #[serde(default)]

crates/git_ui/src/git_panel_settings.rs πŸ”—

@@ -2,7 +2,7 @@ use editor::ShowScrollbar;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use workspace::dock::DockPosition;
 
 #[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
@@ -77,7 +77,7 @@ pub struct GitPanelSettingsContent {
     pub collapse_untracked_diff: Option<bool>,
 }
 
-#[derive(Deserialize, Debug, Clone, PartialEq)]
+#[derive(Deserialize, Debug, Clone, PartialEq, SettingsUi)]
 pub struct GitPanelSettings {
     pub button: bool,
     pub dock: DockPosition,

crates/go_to_line/src/cursor_position.rs πŸ”—

@@ -2,7 +2,7 @@ use editor::{Editor, EditorSettings, MultiBufferSnapshot};
 use gpui::{App, Entity, FocusHandle, Focusable, Subscription, Task, WeakEntity};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use std::{fmt::Write, num::NonZeroU32, time::Duration};
 use text::{Point, Selection};
 use ui::{
@@ -293,7 +293,7 @@ impl StatusItemView for CursorPosition {
     }
 }
 
-#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize)]
+#[derive(Clone, Copy, Default, PartialEq, JsonSchema, Deserialize, Serialize, SettingsUi)]
 #[serde(rename_all = "snake_case")]
 pub(crate) enum LineIndicatorFormat {
     Short,

crates/gpui_macros/src/derive_action.rs πŸ”—

@@ -16,6 +16,13 @@ pub(crate) fn derive_action(input: TokenStream) -> TokenStream {
     let mut deprecated = None;
     let mut doc_str: Option<String> = None;
 
+    /*
+    *
+    * #[action()]
+    * Struct Foo {
+    *  bar: bool // is bar considered an attribute
+    }
+    */
     for attr in &input.attrs {
         if attr.path().is_ident("action") {
             attr.parse_nested_meta(|meta| {

crates/image_viewer/src/image_viewer_settings.rs πŸ”—

@@ -1,10 +1,10 @@
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 /// The settings for the image viewer.
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Default, SettingsUi)]
 pub struct ImageViewerSettings {
     /// The unit to use for displaying image file sizes.
     ///

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

@@ -5,7 +5,7 @@ use editor::{Editor, SelectionEffects};
 use gpui::{App, AppContext as _, Context, Window, actions};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use std::{
     fs::OpenOptions,
     path::{Path, PathBuf},
@@ -22,7 +22,7 @@ actions!(
 );
 
 /// Settings specific to journaling
-#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
+#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, SettingsUi)]
 pub struct JournalSettings {
     /// The path of the directory where journal entries are stored.
     ///

crates/keymap_editor/Cargo.toml πŸ”—

@@ -0,0 +1,53 @@
+[package]
+name = "keymap_editor"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/keymap_editor.rs"
+
+[dependencies]
+anyhow.workspace = true
+collections.workspace = true
+command_palette.workspace = true
+component.workspace = true
+db.workspace = true
+editor.workspace = true
+fs.workspace = true
+fuzzy.workspace = true
+gpui.workspace = true
+itertools.workspace = true
+language.workspace = true
+log.workspace = true
+menu.workspace = true
+notifications.workspace = true
+paths.workspace = true
+project.workspace = true
+search.workspace = true
+serde.workspace = true
+serde_json.workspace = true
+settings.workspace = true
+telemetry.workspace = true
+tempfile.workspace = true
+theme.workspace = true
+tree-sitter-json.workspace = true
+tree-sitter-rust.workspace = true
+ui.workspace = true
+ui_input.workspace = true
+util.workspace = true
+vim.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+zed_actions.workspace = true
+
+[dev-dependencies]
+db = {"workspace"= true, "features" = ["test-support"]}
+fs = { workspace = true, features = ["test-support"] }
+gpui = { workspace = true, features = ["test-support"] }
+project = { workspace = true, features = ["test-support"] }
+workspace = { workspace = true, features = ["test-support"] }

crates/settings_ui/src/keybindings.rs β†’ crates/keymap_editor/src/keymap_editor.rs πŸ”—

@@ -5,6 +5,8 @@ use std::{
     time::Duration,
 };
 
+mod ui_components;
+
 use anyhow::{Context as _, anyhow};
 use collections::{HashMap, HashSet};
 use editor::{CompletionProvider, Editor, EditorEvent};
@@ -34,8 +36,10 @@ use workspace::{
     register_serializable_item,
 };
 
+pub use ui_components::*;
+
 use crate::{
-    keybindings::persistence::KEYBINDING_EDITORS,
+    persistence::KEYBINDING_EDITORS,
     ui_components::{
         keystroke_input::{ClearKeystrokes, KeystrokeInput, StartRecording, StopRecording},
         table::{ColumnWidths, ResizeBehavior, Table, TableInteractionState},

crates/language/src/language_settings.rs πŸ”—

@@ -17,7 +17,7 @@ use serde::{
 };
 
 use settings::{
-    ParameterizedJsonSchema, Settings, SettingsLocation, SettingsSources, SettingsStore,
+    ParameterizedJsonSchema, Settings, SettingsLocation, SettingsSources, SettingsStore, SettingsUi,
 };
 use shellexpand;
 use std::{borrow::Cow, num::NonZeroU32, path::Path, slice, sync::Arc};
@@ -55,7 +55,7 @@ pub fn all_language_settings<'a>(
 }
 
 /// The settings for all languages.
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, SettingsUi)]
 pub struct AllLanguageSettings {
     /// The edit prediction settings.
     pub edit_predictions: EditPredictionSettings,

crates/language_models/src/settings.rs πŸ”—

@@ -5,7 +5,7 @@ use collections::HashMap;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 use crate::provider::{
     self,
@@ -29,7 +29,7 @@ pub fn init_settings(cx: &mut App) {
     AllLanguageModelSettings::register(cx);
 }
 
-#[derive(Default)]
+#[derive(Default, SettingsUi)]
 pub struct AllLanguageModelSettings {
     pub anthropic: AnthropicSettings,
     pub bedrock: AmazonBedrockSettings,

crates/outline_panel/src/outline_panel_settings.rs πŸ”—

@@ -2,7 +2,7 @@ use editor::ShowScrollbar;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
 #[serde(rename_all = "snake_case")]
@@ -18,7 +18,7 @@ pub enum ShowIndentGuides {
     Never,
 }
 
-#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, SettingsUi)]
 pub struct OutlinePanelSettings {
     pub button: bool,
     pub default_width: Pixels,

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

@@ -952,7 +952,7 @@ pub enum PulledDiagnostics {
 /// Whether to disable all AI features in Zed.
 ///
 /// Default: false
-#[derive(Copy, Clone, Debug)]
+#[derive(Copy, Clone, Debug, settings::SettingsUi)]
 pub struct DisableAiSettings {
     pub disable_ai: bool,
 }

crates/project/src/project_settings.rs πŸ”—

@@ -19,7 +19,7 @@ use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
 use settings::{
     InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, SettingsSources,
-    SettingsStore, parse_json_with_comments, watch_config_file,
+    SettingsStore, SettingsUi, parse_json_with_comments, watch_config_file,
 };
 use std::{
     collections::BTreeMap,
@@ -36,7 +36,7 @@ use crate::{
     worktree_store::{WorktreeStore, WorktreeStoreEvent},
 };
 
-#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)]
+#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
 pub struct ProjectSettings {
     /// Configuration for language servers.
     ///

crates/project_panel/src/project_panel_settings.rs πŸ”—

@@ -2,7 +2,7 @@ use editor::ShowScrollbar;
 use gpui::Pixels;
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, Copy, PartialEq)]
 #[serde(rename_all = "snake_case")]
@@ -28,7 +28,7 @@ pub enum EntrySpacing {
     Standard,
 }
 
-#[derive(Deserialize, Debug, Clone, Copy, PartialEq)]
+#[derive(Deserialize, Debug, Clone, Copy, PartialEq, SettingsUi)]
 pub struct ProjectPanelSettings {
     pub button: bool,
     pub hide_gitignore: bool,

crates/recent_projects/src/ssh_connections.rs πŸ”—

@@ -20,7 +20,7 @@ use remote::{
 };
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use theme::ThemeSettings;
 use ui::{
     ActiveTheme, Color, Context, Icon, IconName, IconSize, InteractiveElement, IntoElement, Label,
@@ -29,7 +29,7 @@ use ui::{
 use util::serde::default_true;
 use workspace::{AppState, ModalView, Workspace};
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct SshSettings {
     pub ssh_connections: Option<Vec<SshConnection>>,
     /// Whether to read ~/.ssh/config for ssh connection sources.

crates/repl/src/jupyter_settings.rs πŸ”—

@@ -4,9 +4,9 @@ use editor::EditorSettings;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Debug, Default)]
+#[derive(Debug, Default, SettingsUi)]
 pub struct JupyterSettings {
     pub kernel_selections: HashMap<String, String>,
 }

crates/settings/Cargo.toml πŸ”—

@@ -31,6 +31,7 @@ schemars.workspace = true
 serde.workspace = true
 serde_derive.workspace = true
 serde_json.workspace = true
+settings_ui_macros.workspace = true
 serde_json_lenient.workspace = true
 smallvec.workspace = true
 tree-sitter-json.workspace = true

crates/settings/src/base_keymap_setting.rs πŸ”—

@@ -1,13 +1,17 @@
 use std::fmt::{Display, Formatter};
 
-use crate::{Settings, SettingsSources, VsCodeSettings};
+use crate as settings;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
+use settings::{Settings, SettingsSources, VsCodeSettings};
+use settings_ui_macros::SettingsUi;
 
 /// Base key bindings scheme. Base keymaps can be overridden with user keymaps.
 ///
 /// Default: VSCode
-#[derive(Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default)]
+#[derive(
+    Copy, Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Default, SettingsUi,
+)]
 pub enum BaseKeymap {
     #[default]
     VSCode,

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

@@ -4,6 +4,7 @@ mod keymap_file;
 mod settings_file;
 mod settings_json;
 mod settings_store;
+mod settings_ui;
 mod vscode_import;
 
 use gpui::{App, Global};
@@ -23,6 +24,9 @@ pub use settings_store::{
     InvalidSettingsError, LocalSettingsKind, Settings, SettingsLocation, SettingsSources,
     SettingsStore,
 };
+pub use settings_ui::*;
+// Re-export the derive macro
+pub use settings_ui_macros::SettingsUi;
 pub use vscode_import::{VsCodeSettings, VsCodeSettingsSource};
 
 #[derive(Clone, Debug, PartialEq)]

crates/settings/src/settings_json.rs πŸ”—

@@ -87,9 +87,9 @@ pub fn update_value_in_json_text<'a>(
 }
 
 /// * `replace_key` - When an exact key match according to `key_path` is found, replace the key with `replace_key` if `Some`.
-fn replace_value_in_json_text(
+pub fn replace_value_in_json_text<T: AsRef<str>>(
     text: &str,
-    key_path: &[&str],
+    key_path: &[T],
     tab_size: usize,
     new_value: Option<&Value>,
     replace_key: Option<&str>,
@@ -141,7 +141,7 @@ fn replace_value_in_json_text(
         let found_key = text
             .get(key_range.clone())
             .map(|key_text| {
-                depth < key_path.len() && key_text == format!("\"{}\"", key_path[depth])
+                depth < key_path.len() && key_text == format!("\"{}\"", key_path[depth].as_ref())
             })
             .unwrap_or(false);
 
@@ -226,13 +226,13 @@ fn replace_value_in_json_text(
         }
     } else {
         // We have key paths, construct the sub objects
-        let new_key = key_path[depth];
+        let new_key = key_path[depth].as_ref();
 
         // We don't have the key, construct the nested objects
         let mut new_value =
             serde_json::to_value(new_value.unwrap_or(&serde_json::Value::Null)).unwrap();
         for key in key_path[(depth + 1)..].iter().rev() {
-            new_value = serde_json::json!({ key.to_string(): new_value });
+            new_value = serde_json::json!({ key.as_ref().to_string(): new_value });
         }
 
         if let Some(first_key_start) = first_key_start {
@@ -465,7 +465,7 @@ pub fn append_top_level_array_value_in_json_text(
     }
 
     let (mut replace_range, mut replace_value) =
-        replace_value_in_json_text("", &[], tab_size, Some(new_value), None);
+        replace_value_in_json_text::<&str>("", &[], tab_size, Some(new_value), None);
 
     replace_range.start = close_bracket_start;
     replace_range.end = close_bracket_start;

crates/settings/src/settings_store.rs πŸ”—

@@ -7,7 +7,7 @@ use futures::{
     channel::{mpsc, oneshot},
     future::LocalBoxFuture,
 };
-use gpui::{App, AsyncApp, BorrowAppContext, Global, Task, UpdateGlobal};
+use gpui::{App, AsyncApp, BorrowAppContext, Global, SharedString, Task, UpdateGlobal};
 
 use paths::{EDITORCONFIG_NAME, local_settings_file_relative_path, task_file_name};
 use schemars::JsonSchema;
@@ -31,14 +31,15 @@ use util::{
 pub type EditorconfigProperties = ec4rs::Properties;
 
 use crate::{
-    ActiveSettingsProfileName, ParameterizedJsonSchema, SettingsJsonSchemaParams, VsCodeSettings,
-    WorktreeId, parse_json_with_comments, update_value_in_json_text,
+    ActiveSettingsProfileName, ParameterizedJsonSchema, SettingsJsonSchemaParams, SettingsUiEntry,
+    VsCodeSettings, WorktreeId, parse_json_with_comments, replace_value_in_json_text,
+    settings_ui::SettingsUi, update_value_in_json_text,
 };
 
 /// A value that can be defined as a user setting.
 ///
 /// Settings can be loaded from a combination of multiple JSON files.
-pub trait Settings: 'static + Send + Sync {
+pub trait Settings: SettingsUi + 'static + Send + Sync {
     /// The name of a key within the JSON file from which this setting should
     /// be deserialized. If this is `None`, then the setting will be deserialized
     /// from the root object.
@@ -284,6 +285,7 @@ trait AnySettingValue: 'static + Send + Sync {
         text: &mut String,
         edits: &mut Vec<(Range<usize>, String)>,
     );
+    fn settings_ui_item(&self) -> SettingsUiEntry;
 }
 
 struct DeserializedSetting(Box<dyn Any>);
@@ -480,6 +482,11 @@ impl SettingsStore {
         self.raw_global_settings.as_ref()
     }
 
+    /// Access the raw JSON value of the default settings.
+    pub fn raw_default_settings(&self) -> &Value {
+        &self.raw_default_settings
+    }
+
     #[cfg(any(test, feature = "test-support"))]
     pub fn test(cx: &mut App) -> Self {
         let mut this = Self::new(cx);
@@ -532,49 +539,10 @@ impl SettingsStore {
         }
     }
 
-    pub fn update_settings_file<T: Settings>(
+    fn update_settings_file_inner(
         &self,
         fs: Arc<dyn Fs>,
-        update: impl 'static + Send + FnOnce(&mut T::FileContent, &App),
-    ) {
-        self.setting_file_updates_tx
-            .unbounded_send(Box::new(move |cx: AsyncApp| {
-                async move {
-                    let old_text = Self::load_settings(&fs).await?;
-                    let new_text = cx.read_global(|store: &SettingsStore, cx| {
-                        store.new_text_for_update::<T>(old_text, |content| update(content, cx))
-                    })?;
-                    let settings_path = paths::settings_file().as_path();
-                    if fs.is_file(settings_path).await {
-                        let resolved_path =
-                            fs.canonicalize(settings_path).await.with_context(|| {
-                                format!("Failed to canonicalize settings path {:?}", settings_path)
-                            })?;
-
-                        fs.atomic_write(resolved_path.clone(), new_text)
-                            .await
-                            .with_context(|| {
-                                format!("Failed to write settings to file {:?}", resolved_path)
-                            })?;
-                    } else {
-                        fs.atomic_write(settings_path.to_path_buf(), new_text)
-                            .await
-                            .with_context(|| {
-                                format!("Failed to write settings to file {:?}", settings_path)
-                            })?;
-                    }
-
-                    anyhow::Ok(())
-                }
-                .boxed_local()
-            }))
-            .ok();
-    }
-
-    pub fn import_vscode_settings(
-        &self,
-        fs: Arc<dyn Fs>,
-        vscode_settings: VsCodeSettings,
+        update: impl 'static + Send + FnOnce(String, AsyncApp) -> Result<String>,
     ) -> oneshot::Receiver<Result<()>> {
         let (tx, rx) = oneshot::channel::<Result<()>>();
         self.setting_file_updates_tx
@@ -582,9 +550,7 @@ impl SettingsStore {
                 async move {
                     let res = async move {
                         let old_text = Self::load_settings(&fs).await?;
-                        let new_text = cx.read_global(|store: &SettingsStore, _cx| {
-                            store.get_vscode_edits(old_text, &vscode_settings)
-                        })?;
+                        let new_text = update(old_text, cx)?;
                         let settings_path = paths::settings_file().as_path();
                         if fs.is_file(settings_path).await {
                             let resolved_path =
@@ -607,7 +573,6 @@ impl SettingsStore {
                                     format!("Failed to write settings to file {:?}", settings_path)
                                 })?;
                         }
-
                         anyhow::Ok(())
                     }
                     .await;
@@ -622,9 +587,67 @@ impl SettingsStore {
                 }
                 .boxed_local()
             }))
-            .ok();
+            .map_err(|err| anyhow::format_err!("Failed to update settings file: {}", err))
+            .log_with_level(log::Level::Warn);
+        return rx;
+    }
+
+    pub fn update_settings_file_at_path(
+        &self,
+        fs: Arc<dyn Fs>,
+        path: &[&str],
+        new_value: serde_json::Value,
+    ) -> oneshot::Receiver<Result<()>> {
+        let key_path = path
+            .into_iter()
+            .cloned()
+            .map(SharedString::new)
+            .collect::<Vec<_>>();
+        let update = move |mut old_text: String, cx: AsyncApp| {
+            cx.read_global(|store: &SettingsStore, _cx| {
+                // todo(settings_ui) use `update_value_in_json_text` for merging new and old objects with comment preservation, needs old value though...
+                let (range, replacement) = replace_value_in_json_text(
+                    &old_text,
+                    key_path.as_slice(),
+                    store.json_tab_size(),
+                    Some(&new_value),
+                    None,
+                );
+                old_text.replace_range(range, &replacement);
+                old_text
+            })
+        };
+        self.update_settings_file_inner(fs, update)
+    }
 
-        rx
+    pub fn update_settings_file<T: Settings>(
+        &self,
+        fs: Arc<dyn Fs>,
+        update: impl 'static + Send + FnOnce(&mut T::FileContent, &App),
+    ) {
+        _ = self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| {
+            cx.read_global(|store: &SettingsStore, cx| {
+                store.new_text_for_update::<T>(old_text, |content| update(content, cx))
+            })
+        });
+    }
+
+    pub fn import_vscode_settings(
+        &self,
+        fs: Arc<dyn Fs>,
+        vscode_settings: VsCodeSettings,
+    ) -> oneshot::Receiver<Result<()>> {
+        self.update_settings_file_inner(fs, move |old_text: String, cx: AsyncApp| {
+            cx.read_global(|store: &SettingsStore, _cx| {
+                store.get_vscode_edits(old_text, &vscode_settings)
+            })
+        })
+    }
+
+    pub fn settings_ui_items(&self) -> impl IntoIterator<Item = SettingsUiEntry> {
+        self.setting_values
+            .values()
+            .map(|item| item.settings_ui_item())
     }
 }
 
@@ -1520,6 +1543,10 @@ impl<T: Settings> AnySettingValue for SettingValue<T> {
             edits,
         );
     }
+
+    fn settings_ui_item(&self) -> SettingsUiEntry {
+        <T as SettingsUi>::settings_ui_entry()
+    }
 }
 
 #[cfg(test)]
@@ -1527,7 +1554,10 @@ mod tests {
     use crate::VsCodeSettingsSource;
 
     use super::*;
+    // This is so the SettingsUi macro can still work properly
+    use crate as settings;
     use serde_derive::Deserialize;
+    use settings_ui_macros::SettingsUi;
     use unindent::Unindent;
 
     #[gpui::test]
@@ -2070,14 +2100,14 @@ mod tests {
         pretty_assertions::assert_eq!(new, expected);
     }
 
-    #[derive(Debug, PartialEq, Deserialize)]
+    #[derive(Debug, PartialEq, Deserialize, SettingsUi)]
     struct UserSettings {
         name: String,
         age: u32,
         staff: bool,
     }
 
-    #[derive(Default, Clone, Serialize, Deserialize, JsonSchema)]
+    #[derive(Default, Clone, Serialize, Deserialize, JsonSchema, SettingsUi)]
     struct UserSettingsContent {
         name: Option<String>,
         age: Option<u32>,
@@ -2097,7 +2127,7 @@ mod tests {
         }
     }
 
-    #[derive(Debug, Deserialize, PartialEq)]
+    #[derive(Debug, Deserialize, PartialEq, SettingsUi)]
     struct TurboSetting(bool);
 
     impl Settings for TurboSetting {
@@ -2111,7 +2141,7 @@ mod tests {
         fn import_from_vscode(_vscode: &VsCodeSettings, _current: &mut Self::FileContent) {}
     }
 
-    #[derive(Clone, Debug, PartialEq, Deserialize)]
+    #[derive(Clone, Debug, PartialEq, Deserialize, SettingsUi)]
     struct MultiKeySettings {
         #[serde(default)]
         key1: String,
@@ -2144,7 +2174,7 @@ mod tests {
         }
     }
 
-    #[derive(Debug, Deserialize)]
+    #[derive(Debug, Deserialize, SettingsUi)]
     struct JournalSettings {
         pub path: String,
         pub hour_format: HourFormat,
@@ -2245,7 +2275,7 @@ mod tests {
         );
     }
 
-    #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema)]
+    #[derive(Clone, Debug, Default, Serialize, Deserialize, JsonSchema, SettingsUi)]
     struct LanguageSettings {
         #[serde(default)]
         languages: HashMap<String, LanguageSettingEntry>,

crates/settings/src/settings_ui.rs πŸ”—

@@ -0,0 +1,118 @@
+use anyhow::Context as _;
+use fs::Fs;
+use gpui::{AnyElement, App, AppContext as _, ReadGlobal as _, Window};
+use smallvec::SmallVec;
+
+use crate::SettingsStore;
+
+pub trait SettingsUi {
+    fn settings_ui_item() -> SettingsUiItem {
+        SettingsUiItem::None
+    }
+    fn settings_ui_entry() -> SettingsUiEntry;
+}
+
+pub struct SettingsUiEntry {
+    // todo(settings_ui): move this back here once there isn't a None variant
+    // pub path: &'static str,
+    // pub title: &'static str,
+    pub item: SettingsUiEntryVariant,
+}
+
+pub enum SettingsUiEntryVariant {
+    Group {
+        path: &'static str,
+        title: &'static str,
+        items: Vec<SettingsUiEntry>,
+    },
+    Item {
+        path: &'static str,
+        item: SettingsUiItemSingle,
+    },
+    // todo(settings_ui): remove
+    None,
+}
+
+pub enum SettingsUiItemSingle {
+    SwitchField,
+    NumericStepper,
+    ToggleGroup(&'static [&'static str]),
+    /// This should be used when toggle group size > 6
+    DropDown(&'static [&'static str]),
+    Custom(Box<dyn Fn(SettingsValue<serde_json::Value>, &mut Window, &mut App) -> AnyElement>),
+}
+
+pub struct SettingsValue<T> {
+    pub title: &'static str,
+    pub path: SmallVec<[&'static str; 1]>,
+    pub value: Option<T>,
+    pub default_value: T,
+}
+
+impl<T> SettingsValue<T> {
+    pub fn read(&self) -> &T {
+        match &self.value {
+            Some(value) => value,
+            None => &self.default_value,
+        }
+    }
+}
+
+impl SettingsValue<serde_json::Value> {
+    pub fn write_value(path: &SmallVec<[&'static str; 1]>, value: serde_json::Value, cx: &mut App) {
+        let settings_store = SettingsStore::global(cx);
+        let fs = <dyn Fs>::global(cx);
+
+        let rx = settings_store.update_settings_file_at_path(fs.clone(), path.as_slice(), value);
+        let path = path.clone();
+        cx.background_spawn(async move {
+            rx.await?
+                .with_context(|| format!("Failed to update setting at path `{:?}`", path.join(".")))
+        })
+        .detach_and_log_err(cx);
+    }
+}
+
+impl<T: serde::Serialize> SettingsValue<T> {
+    pub fn write(
+        path: &SmallVec<[&'static str; 1]>,
+        value: T,
+        cx: &mut App,
+    ) -> Result<(), serde_json::Error> {
+        SettingsValue::write_value(path, serde_json::to_value(value)?, cx);
+        Ok(())
+    }
+}
+
+pub enum SettingsUiItem {
+    Group {
+        title: &'static str,
+        items: Vec<SettingsUiEntry>,
+    },
+    Single(SettingsUiItemSingle),
+    None,
+}
+
+impl SettingsUi for bool {
+    fn settings_ui_item() -> SettingsUiItem {
+        SettingsUiItem::Single(SettingsUiItemSingle::SwitchField)
+    }
+
+    fn settings_ui_entry() -> SettingsUiEntry {
+        SettingsUiEntry {
+            item: SettingsUiEntryVariant::None,
+        }
+    }
+}
+
+impl SettingsUi for u64 {
+    fn settings_ui_item() -> SettingsUiItem {
+        SettingsUiItem::Single(SettingsUiItemSingle::NumericStepper)
+    }
+
+    fn settings_ui_entry() -> SettingsUiEntry {
+        SettingsUiEntry {
+            item: SettingsUiEntryVariant::None,
+        }
+    }
+}

crates/settings/src/vscode_import.rs πŸ”—

@@ -2,7 +2,7 @@ use anyhow::{Context as _, Result, anyhow};
 use fs::Fs;
 use paths::{cursor_settings_file_paths, vscode_settings_file_paths};
 use serde_json::{Map, Value};
-use std::{path::Path, rc::Rc, sync::Arc};
+use std::{path::Path, sync::Arc};
 
 #[derive(Clone, Copy, PartialEq, Eq, Debug)]
 pub enum VsCodeSettingsSource {
@@ -21,7 +21,7 @@ impl std::fmt::Display for VsCodeSettingsSource {
 
 pub struct VsCodeSettings {
     pub source: VsCodeSettingsSource,
-    pub path: Rc<Path>,
+    pub path: Arc<Path>,
     content: Map<String, Value>,
 }
 

crates/settings_ui/Cargo.toml πŸ”—

@@ -11,45 +11,26 @@ workspace = true
 [lib]
 path = "src/settings_ui.rs"
 
+[features]
+default = []
+
 [dependencies]
 anyhow.workspace = true
-collections.workspace = true
-command_palette.workspace = true
 command_palette_hooks.workspace = true
-component.workspace = true
-db.workspace = true
 editor.workspace = true
 feature_flags.workspace = true
-fs.workspace = true
-fuzzy.workspace = true
 gpui.workspace = true
-itertools.workspace = true
-language.workspace = true
-log.workspace = true
-menu.workspace = true
-notifications.workspace = true
-paths.workspace = true
-project.workspace = true
-search.workspace = true
-serde.workspace = true
 serde_json.workspace = true
+serde.workspace = true
 settings.workspace = true
-telemetry.workspace = true
-tempfile.workspace = true
+smallvec.workspace = true
 theme.workspace = true
-tree-sitter-json.workspace = true
-tree-sitter-rust.workspace = true
 ui.workspace = true
-ui_input.workspace = true
-util.workspace = true
-vim.workspace = true
-workspace-hack.workspace = true
 workspace.workspace = true
-zed_actions.workspace = true
+workspace-hack.workspace = true
 
-[dev-dependencies]
-db = {"workspace"= true, "features" = ["test-support"]}
-fs = { workspace = true, features = ["test-support"] }
-gpui = { workspace = true, features = ["test-support"] }
-project = { workspace = true, features = ["test-support"] }
-workspace = { workspace = true, features = ["test-support"] }
+# Uncomment other workspace dependencies as needed
+# assistant.workspace = true
+# client.workspace = true
+# project.workspace = true
+# settings.workspace = true

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

@@ -1,20 +1,24 @@
 mod appearance_settings_controls;
 
 use std::any::TypeId;
+use std::ops::{Not, Range};
 
+use anyhow::Context as _;
 use command_palette_hooks::CommandPaletteFilter;
 use editor::EditorSettingsControls;
 use feature_flags::{FeatureFlag, FeatureFlagViewExt};
-use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, actions};
-use ui::prelude::*;
-use workspace::item::{Item, ItemEvent};
-use workspace::{Workspace, with_active_or_new_workspace};
+use gpui::{App, Entity, EventEmitter, FocusHandle, Focusable, ReadGlobal, actions};
+use settings::{SettingsStore, SettingsUiEntryVariant, SettingsUiItemSingle, SettingsValue};
+use smallvec::SmallVec;
+use ui::{NumericStepper, SwitchField, ToggleButtonGroup, ToggleButtonSimple, prelude::*};
+use workspace::{
+    Workspace,
+    item::{Item, ItemEvent},
+    with_active_or_new_workspace,
+};
 
 use crate::appearance_settings_controls::AppearanceSettingsControls;
 
-pub mod keybindings;
-pub mod ui_components;
-
 pub struct SettingsUiFeatureFlag;
 
 impl FeatureFlag for SettingsUiFeatureFlag {
@@ -75,18 +79,18 @@ pub fn init(cx: &mut App) {
         .detach();
     })
     .detach();
-
-    keybindings::init(cx);
 }
 
 pub struct SettingsPage {
     focus_handle: FocusHandle,
+    settings_tree: SettingsUiTree,
 }
 
 impl SettingsPage {
     pub fn new(_workspace: &Workspace, cx: &mut Context<Workspace>) -> Entity<Self> {
         cx.new(|cx| Self {
             focus_handle: cx.focus_handle(),
+            settings_tree: SettingsUiTree::new(cx),
         })
     }
 }
@@ -119,26 +123,472 @@ impl Item for SettingsPage {
     }
 }
 
+// We want to iterate over the side bar with root groups
+// - this is a loop over top level groups, and if any are expanded, recursively displaying their items
+// - Should be able to get all items from a group (flatten a group)
+// - Should be able to toggle/untoggle groups in UI (at least in sidebar)
+// - Search should be available
+//  - there should be an index of text -> item mappings, for using fuzzy::match
+//   - Do we want to show the parent groups when a item is matched?
+
+struct UIEntry {
+    title: &'static str,
+    path: &'static str,
+    _depth: usize,
+    // a
+    //  b     < a descendant range < a total descendant range
+    //    f   |                    |
+    //    g   |                    |
+    //  c     <                    |
+    //    d                        |
+    //    e                        <
+    descendant_range: Range<usize>,
+    total_descendant_range: Range<usize>,
+    next_sibling: Option<usize>,
+    // expanded: bool,
+    render: Option<SettingsUiItemSingle>,
+}
+
+struct SettingsUiTree {
+    root_entry_indices: Vec<usize>,
+    entries: Vec<UIEntry>,
+    active_entry_index: usize,
+}
+
+fn build_tree_item(
+    tree: &mut Vec<UIEntry>,
+    group: SettingsUiEntryVariant,
+    depth: usize,
+    prev_index: Option<usize>,
+) {
+    let index = tree.len();
+    tree.push(UIEntry {
+        title: "",
+        path: "",
+        _depth: depth,
+        descendant_range: index + 1..index + 1,
+        total_descendant_range: index + 1..index + 1,
+        render: None,
+        next_sibling: None,
+    });
+    if let Some(prev_index) = prev_index {
+        tree[prev_index].next_sibling = Some(index);
+    }
+    match group {
+        SettingsUiEntryVariant::Group {
+            path,
+            title,
+            items: group_items,
+        } => {
+            tree[index].path = path;
+            tree[index].title = title;
+            for group_item in group_items {
+                let prev_index = tree[index]
+                    .descendant_range
+                    .is_empty()
+                    .not()
+                    .then_some(tree[index].descendant_range.end - 1);
+                tree[index].descendant_range.end = tree.len() + 1;
+                build_tree_item(tree, group_item.item, depth + 1, prev_index);
+                tree[index].total_descendant_range.end = tree.len();
+            }
+        }
+        SettingsUiEntryVariant::Item { path, item } => {
+            tree[index].path = path;
+            // todo(settings_ui) create title from path in macro, and use here
+            tree[index].title = path;
+            tree[index].render = Some(item);
+        }
+        SettingsUiEntryVariant::None => {
+            return;
+        }
+    }
+}
+
+impl SettingsUiTree {
+    fn new(cx: &App) -> Self {
+        let settings_store = SettingsStore::global(cx);
+        let mut tree = vec![];
+        let mut root_entry_indices = vec![];
+        for item in settings_store.settings_ui_items() {
+            if matches!(item.item, SettingsUiEntryVariant::None) {
+                continue;
+            }
+
+            assert!(
+                matches!(item.item, SettingsUiEntryVariant::Group { .. }),
+                "top level items must be groups: {:?}",
+                match item.item {
+                    SettingsUiEntryVariant::Item { path, .. } => path,
+                    _ => unreachable!(),
+                }
+            );
+            let prev_root_entry_index = root_entry_indices.last().copied();
+            root_entry_indices.push(tree.len());
+            build_tree_item(&mut tree, item.item, 0, prev_root_entry_index);
+        }
+
+        root_entry_indices.sort_by_key(|i| tree[*i].title);
+
+        let active_entry_index = root_entry_indices[0];
+        Self {
+            entries: tree,
+            root_entry_indices,
+            active_entry_index,
+        }
+    }
+}
+
+fn render_nav(tree: &SettingsUiTree, _window: &mut Window, cx: &mut Context<SettingsPage>) -> Div {
+    let mut nav = v_flex().p_4().gap_2();
+    for &index in &tree.root_entry_indices {
+        nav = nav.child(
+            div()
+                .id(index)
+                .on_click(cx.listener(move |settings, _, _, _| {
+                    settings.settings_tree.active_entry_index = index;
+                }))
+                .child(
+                    Label::new(SharedString::new_static(tree.entries[index].title))
+                        .size(LabelSize::Large)
+                        .when(tree.active_entry_index == index, |this| {
+                            this.color(Color::Selected)
+                        }),
+                ),
+        );
+    }
+    nav
+}
+
+fn render_content(
+    tree: &SettingsUiTree,
+    window: &mut Window,
+    cx: &mut Context<SettingsPage>,
+) -> impl IntoElement {
+    let Some(entry) = tree.entries.get(tree.active_entry_index) else {
+        return div()
+            .size_full()
+            .child(Label::new(SharedString::new_static("No settings found")).color(Color::Error));
+    };
+    let mut content = v_flex().size_full().gap_4();
+
+    let mut child_index = entry
+        .descendant_range
+        .is_empty()
+        .not()
+        .then_some(entry.descendant_range.start);
+    let mut path = smallvec::smallvec![entry.path];
+
+    while let Some(index) = child_index {
+        let child = &tree.entries[index];
+        child_index = child.next_sibling;
+        if child.render.is_none() {
+            // todo(settings_ui): subgroups?
+            continue;
+        }
+        path.push(child.path);
+        let settings_value = settings_value_from_settings_and_path(
+            path.clone(),
+            // PERF: how to structure this better? There feels like there's a way to avoid the clone
+            // and every value lookup
+            SettingsStore::global(cx).raw_user_settings(),
+            SettingsStore::global(cx).raw_default_settings(),
+        );
+        content = content.child(
+            div()
+                .child(
+                    Label::new(SharedString::new_static(tree.entries[index].title))
+                        .size(LabelSize::Large)
+                        .when(tree.active_entry_index == index, |this| {
+                            this.color(Color::Selected)
+                        }),
+                )
+                .child(render_item_single(
+                    settings_value,
+                    child.render.as_ref().unwrap(),
+                    window,
+                    cx,
+                )),
+        );
+
+        path.pop();
+    }
+
+    return content;
+}
+
 impl Render for SettingsPage {
-    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
-        v_flex()
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .grid()
+            .grid_cols(16)
             .p_4()
+            .bg(cx.theme().colors().editor_background)
             .size_full()
-            .gap_4()
-            .child(Label::new("Settings").size(LabelSize::Large))
-            .child(
-                v_flex().gap_1().child(Label::new("Appearance")).child(
-                    v_flex()
-                        .elevation_2(cx)
-                        .child(AppearanceSettingsControls::new()),
-                ),
-            )
             .child(
-                v_flex().gap_1().child(Label::new("Editor")).child(
-                    v_flex()
-                        .elevation_2(cx)
-                        .child(EditorSettingsControls::new()),
-                ),
+                div()
+                    .col_span(2)
+                    .h_full()
+                    .child(render_nav(&self.settings_tree, window, cx)),
             )
+            .child(div().col_span(4).h_full().child(render_content(
+                &self.settings_tree,
+                window,
+                cx,
+            )))
     }
 }
+
+// todo(settings_ui): remove, only here as inspiration
+#[allow(dead_code)]
+fn render_old_appearance_settings(cx: &mut App) -> impl IntoElement {
+    v_flex()
+        .p_4()
+        .size_full()
+        .gap_4()
+        .child(Label::new("Settings").size(LabelSize::Large))
+        .child(
+            v_flex().gap_1().child(Label::new("Appearance")).child(
+                v_flex()
+                    .elevation_2(cx)
+                    .child(AppearanceSettingsControls::new()),
+            ),
+        )
+        .child(
+            v_flex().gap_1().child(Label::new("Editor")).child(
+                v_flex()
+                    .elevation_2(cx)
+                    .child(EditorSettingsControls::new()),
+            ),
+        )
+}
+
+fn element_id_from_path(path: &[&'static str]) -> ElementId {
+    if path.len() == 0 {
+        panic!("Path length must not be zero");
+    } else if path.len() == 1 {
+        ElementId::Name(SharedString::new_static(path[0]))
+    } else {
+        ElementId::from((
+            ElementId::from(SharedString::new_static(path[path.len() - 2])),
+            SharedString::new_static(path[path.len() - 1]),
+        ))
+    }
+}
+
+fn render_item_single(
+    settings_value: SettingsValue<serde_json::Value>,
+    item: &SettingsUiItemSingle,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    match item {
+        SettingsUiItemSingle::Custom(_) => div()
+            .child(format!("Item: {}", settings_value.path.join(".")))
+            .into_any_element(),
+        SettingsUiItemSingle::SwitchField => {
+            render_any_item(settings_value, render_switch_field, window, cx)
+        }
+        SettingsUiItemSingle::NumericStepper => {
+            render_any_item(settings_value, render_numeric_stepper, window, cx)
+        }
+        SettingsUiItemSingle::ToggleGroup(variants) => {
+            render_toggle_button_group(settings_value, variants, window, cx)
+        }
+        SettingsUiItemSingle::DropDown(_) => {
+            unimplemented!("This")
+        }
+    }
+}
+
+fn read_settings_value_from_path<'a>(
+    settings_contents: &'a serde_json::Value,
+    path: &[&'static str],
+) -> Option<&'a serde_json::Value> {
+    let Some((key, remaining)) = path.split_first() else {
+        return Some(settings_contents);
+    };
+    let Some(value) = settings_contents.get(key) else {
+        return None;
+    };
+
+    read_settings_value_from_path(value, remaining)
+}
+
+fn downcast_any_item<T: serde::de::DeserializeOwned>(
+    settings_value: SettingsValue<serde_json::Value>,
+) -> SettingsValue<T> {
+    let value = settings_value
+        .value
+        .map(|value| serde_json::from_value::<T>(value).expect("value is not a T"));
+    // todo(settings_ui) Create test that constructs UI tree, and asserts that all elements have default values
+    let default_value = serde_json::from_value::<T>(settings_value.default_value)
+        .expect("default value is not an Option<T>");
+    let deserialized_setting_value = SettingsValue {
+        title: settings_value.title,
+        path: settings_value.path,
+        value,
+        default_value,
+    };
+    deserialized_setting_value
+}
+
+fn render_any_item<T: serde::de::DeserializeOwned>(
+    settings_value: SettingsValue<serde_json::Value>,
+    render_fn: impl Fn(SettingsValue<T>, &mut Window, &mut App) -> AnyElement + 'static,
+    window: &mut Window,
+    cx: &mut App,
+) -> AnyElement {
+    let deserialized_setting_value = downcast_any_item(settings_value);
+    render_fn(deserialized_setting_value, window, cx)
+}
+
+fn render_numeric_stepper(
+    value: SettingsValue<u64>,
+    _window: &mut Window,
+    _cx: &mut App,
+) -> AnyElement {
+    let id = element_id_from_path(&value.path);
+    let path = value.path.clone();
+    let num = value.value.unwrap_or_else(|| value.default_value);
+
+    NumericStepper::new(
+        id,
+        num.to_string(),
+        {
+            let path = value.path.clone();
+            move |_, _, cx| {
+                let Some(number) = serde_json::Number::from_u128(num.saturating_sub(1) as u128)
+                else {
+                    return;
+                };
+                let new_value = serde_json::Value::Number(number);
+                SettingsValue::write_value(&path, new_value, cx);
+            }
+        },
+        move |_, _, cx| {
+            let Some(number) = serde_json::Number::from_u128(num.saturating_add(1) as u128) else {
+                return;
+            };
+
+            let new_value = serde_json::Value::Number(number);
+
+            SettingsValue::write_value(&path, new_value, cx);
+        },
+    )
+    .style(ui::NumericStepperStyle::Outlined)
+    .into_any_element()
+}
+
+fn render_switch_field(
+    value: SettingsValue<bool>,
+    _window: &mut Window,
+    _cx: &mut App,
+) -> AnyElement {
+    let id = element_id_from_path(&value.path);
+    let path = value.path.clone();
+    SwitchField::new(
+        id,
+        SharedString::new_static(value.title),
+        None,
+        match value.read() {
+            true => ToggleState::Selected,
+            false => ToggleState::Unselected,
+        },
+        move |toggle_state, _, cx| {
+            let new_value = serde_json::Value::Bool(match toggle_state {
+                ToggleState::Indeterminate => {
+                    return;
+                }
+                ToggleState::Selected => true,
+                ToggleState::Unselected => false,
+            });
+
+            SettingsValue::write_value(&path, new_value, cx);
+        },
+    )
+    .into_any_element()
+}
+
+fn render_toggle_button_group(
+    value: SettingsValue<serde_json::Value>,
+    variants: &'static [&'static str],
+    _: &mut Window,
+    _: &mut App,
+) -> AnyElement {
+    let value = downcast_any_item::<String>(value);
+
+    fn make_toggle_group<const LEN: usize>(
+        group_name: &'static str,
+        value: SettingsValue<String>,
+        variants: &'static [&'static str],
+    ) -> AnyElement {
+        let mut variants_array: [&'static str; LEN] = ["default"; LEN];
+        variants_array.copy_from_slice(variants);
+        let active_value = value.read();
+
+        let selected_idx = variants_array
+            .iter()
+            .enumerate()
+            .find_map(|(idx, variant)| {
+                if variant == &active_value {
+                    Some(idx)
+                } else {
+                    None
+                }
+            });
+
+        ToggleButtonGroup::single_row(
+            group_name,
+            variants_array.map(|variant| {
+                let path = value.path.clone();
+                ToggleButtonSimple::new(variant, move |_, _, cx| {
+                    SettingsValue::write_value(
+                        &path,
+                        serde_json::Value::String(variant.to_string()),
+                        cx,
+                    );
+                })
+            }),
+        )
+        .when_some(selected_idx, |this, ix| this.selected_index(ix))
+        .style(ui::ToggleButtonGroupStyle::Filled)
+        .into_any_element()
+    }
+
+    macro_rules! templ_toggl_with_const_param {
+        ($len:expr) => {
+            if variants.len() == $len {
+                return make_toggle_group::<$len>(value.title, value, variants);
+            }
+        };
+    }
+    templ_toggl_with_const_param!(1);
+    templ_toggl_with_const_param!(2);
+    templ_toggl_with_const_param!(3);
+    templ_toggl_with_const_param!(4);
+    templ_toggl_with_const_param!(5);
+    templ_toggl_with_const_param!(6);
+    unreachable!("Too many variants");
+}
+
+fn settings_value_from_settings_and_path(
+    path: SmallVec<[&'static str; 1]>,
+    user_settings: &serde_json::Value,
+    default_settings: &serde_json::Value,
+) -> SettingsValue<serde_json::Value> {
+    let default_value = read_settings_value_from_path(default_settings, &path)
+        .with_context(|| format!("No default value for item at path {:?}", path.join(".")))
+        .expect("Default value set for item")
+        .clone();
+
+    let value = read_settings_value_from_path(user_settings, &path).cloned();
+    let settings_value = SettingsValue {
+        default_value,
+        value,
+        path: path.clone(),
+        // todo(settings_ui) title for items
+        title: path.last().expect("path non empty"),
+    };
+    return settings_value;
+}

crates/settings_ui_macros/Cargo.toml πŸ”—

@@ -0,0 +1,22 @@
+[package]
+name = "settings_ui_macros"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lib]
+path = "src/settings_ui_macros.rs"
+proc-macro = true
+
+[lints]
+workspace = true
+
+[features]
+default = []
+
+[dependencies]
+proc-macro2.workspace = true
+quote.workspace = true
+syn.workspace = true
+workspace-hack.workspace = true

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

@@ -0,0 +1,201 @@
+use proc_macro2::TokenStream;
+use quote::{ToTokens, quote};
+use syn::{Data, DeriveInput, LitStr, Token, parse_macro_input};
+
+/// Derive macro for the `SettingsUi` marker trait.
+///
+/// This macro automatically implements the `SettingsUi` trait for the annotated type.
+/// The `SettingsUi` trait is a marker trait used to indicate that a type can be
+/// displayed in the settings UI.
+///
+/// # Example
+///
+/// ```
+/// use settings::SettingsUi;
+/// use settings_ui_macros::SettingsUi;
+///
+/// #[derive(SettingsUi)]
+/// #[settings_ui(group = "Standard")]
+/// struct MySettings {
+///     enabled: bool,
+///     count: usize,
+/// }
+/// ```
+#[proc_macro_derive(SettingsUi, attributes(settings_ui))]
+pub fn derive_settings_ui(input: proc_macro::TokenStream) -> proc_macro::TokenStream {
+    let input = parse_macro_input!(input as DeriveInput);
+    let name = &input.ident;
+
+    // Handle generic parameters if present
+    let (impl_generics, ty_generics, where_clause) = input.generics.split_for_impl();
+
+    let mut group_name = Option::<String>::None;
+    let mut path_name = Option::<String>::None;
+
+    for attr in &input.attrs {
+        if attr.path().is_ident("settings_ui") {
+            attr.parse_nested_meta(|meta| {
+                if meta.path.is_ident("group") {
+                    if group_name.is_some() {
+                        return Err(meta.error("Only one 'group' path can be specified"));
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    let lit: LitStr = meta.input.parse()?;
+                    group_name = Some(lit.value());
+                } else if meta.path.is_ident("path") {
+                    // todo(settings_ui) try get KEY from Settings if possible, and once we do,
+                    // if can get key from settings, throw error if path also passed
+                    if path_name.is_some() {
+                        return Err(meta.error("Only one 'path' can be specified"));
+                    }
+                    meta.input.parse::<Token![=]>()?;
+                    let lit: LitStr = meta.input.parse()?;
+                    path_name = Some(lit.value());
+                }
+                Ok(())
+            })
+            .unwrap_or_else(|e| panic!("in #[settings_ui] attribute: {}", e));
+        }
+    }
+
+    if path_name.is_none() && group_name.is_some() {
+        // todo(settings_ui) derive path from settings
+        panic!("path is required when group is specified");
+    }
+
+    let ui_render_fn_body = generate_ui_item_body(group_name.as_ref(), path_name.as_ref(), &input);
+
+    let settings_ui_item_fn_body = path_name
+        .as_ref()
+        .map(|path_name| map_ui_item_to_render(path_name, quote! { Self }))
+        .unwrap_or(quote! {
+            settings::SettingsUiEntry {
+                item: settings::SettingsUiEntryVariant::None
+            }
+        });
+
+    let expanded = quote! {
+        impl #impl_generics settings::SettingsUi for #name #ty_generics #where_clause {
+            fn settings_ui_item() -> settings::SettingsUiItem {
+                #ui_render_fn_body
+            }
+
+            fn settings_ui_entry() -> settings::SettingsUiEntry {
+                #settings_ui_item_fn_body
+            }
+        }
+    };
+
+    proc_macro::TokenStream::from(expanded)
+}
+
+fn map_ui_item_to_render(path: &str, ty: TokenStream) -> TokenStream {
+    quote! {
+        settings::SettingsUiEntry {
+            item: match #ty::settings_ui_item() {
+                settings::SettingsUiItem::Group{title, items} => settings::SettingsUiEntryVariant::Group {
+                    title,
+                    path: #path,
+                    items,
+                },
+                settings::SettingsUiItem::Single(item) => settings::SettingsUiEntryVariant::Item {
+                    path: #path,
+                    item,
+                },
+                settings::SettingsUiItem::None => settings::SettingsUiEntryVariant::None,
+            }
+        }
+    }
+}
+
+fn generate_ui_item_body(
+    group_name: Option<&String>,
+    path_name: Option<&String>,
+    input: &syn::DeriveInput,
+) -> TokenStream {
+    match (group_name, path_name, &input.data) {
+        (_, _, Data::Union(_)) => unimplemented!("Derive SettingsUi for Unions"),
+        (None, None, Data::Struct(_)) => quote! {
+            settings::SettingsUiItem::None
+        },
+        (Some(_), None, Data::Struct(_)) => quote! {
+            settings::SettingsUiItem::None
+        },
+        (None, Some(_), Data::Struct(_)) => quote! {
+            settings::SettingsUiItem::None
+        },
+        (Some(group_name), _, Data::Struct(data_struct)) => {
+            let fields = data_struct
+                .fields
+                .iter()
+                .filter(|field| {
+                    !field.attrs.iter().any(|attr| {
+                        let mut has_skip = false;
+                        if attr.path().is_ident("settings_ui") {
+                            let _ = attr.parse_nested_meta(|meta| {
+                                if meta.path.is_ident("skip") {
+                                    has_skip = true;
+                                }
+                                Ok(())
+                            });
+                        }
+
+                        has_skip
+                    })
+                })
+                .map(|field| {
+                    (
+                        field.ident.clone().expect("tuple fields").to_string(),
+                        field.ty.to_token_stream(),
+                    )
+                })
+                .map(|(name, ty)| map_ui_item_to_render(&name, ty));
+
+            quote! {
+                settings::SettingsUiItem::Group{ title: #group_name, items: vec![#(#fields),*] }
+            }
+        }
+        (None, _, Data::Enum(data_enum)) => {
+            let mut lowercase = false;
+            for attr in &input.attrs {
+                if attr.path().is_ident("serde") {
+                    attr.parse_nested_meta(|meta| {
+                        if meta.path.is_ident("rename_all") {
+                            meta.input.parse::<Token![=]>()?;
+                            let lit = meta.input.parse::<LitStr>()?.value();
+                            // todo(settings_ui) snake case
+                            lowercase = lit == "lowercase" || lit == "snake_case";
+                        }
+                        Ok(())
+                    })
+                    .ok();
+                }
+            }
+            let length = data_enum.variants.len();
+
+            let variants = data_enum.variants.iter().map(|variant| {
+                let string = variant.ident.clone().to_string();
+
+                if lowercase {
+                    string.to_lowercase()
+                } else {
+                    string
+                }
+            });
+
+            if length > 6 {
+                quote! {
+                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::DropDown(&[#(#variants),*]))
+                }
+            } else {
+                quote! {
+                    settings::SettingsUiItem::Single(settings::SettingsUiItemSingle::ToggleGroup(&[#(#variants),*]))
+                }
+            }
+        }
+        // todo(settings_ui) discriminated unions
+        (_, _, Data::Enum(_)) => quote! {
+            settings::SettingsUiItem::None
+        },
+    }
+}

crates/terminal/src/terminal_settings.rs πŸ”—

@@ -6,7 +6,7 @@ use gpui::{AbsoluteLength, App, FontFallbacks, FontFeatures, FontWeight, Pixels,
 use schemars::JsonSchema;
 use serde_derive::{Deserialize, Serialize};
 
-use settings::SettingsSources;
+use settings::{SettingsSources, SettingsUi};
 use std::path::PathBuf;
 use task::Shell;
 use theme::FontFamilyName;
@@ -24,7 +24,7 @@ pub struct Toolbar {
     pub breadcrumbs: bool,
 }
 
-#[derive(Clone, Debug, Deserialize)]
+#[derive(Clone, Debug, Deserialize, SettingsUi)]
 pub struct TerminalSettings {
     pub shell: Shell,
     pub working_directory: WorkingDirectory,

crates/theme/src/settings.rs πŸ”—

@@ -13,7 +13,7 @@ use gpui::{
 use refineable::Refineable;
 use schemars::{JsonSchema, json_schema};
 use serde::{Deserialize, Serialize};
-use settings::{ParameterizedJsonSchema, Settings, SettingsSources};
+use settings::{ParameterizedJsonSchema, Settings, SettingsSources, SettingsUi};
 use std::sync::Arc;
 use util::ResultExt as _;
 use util::schemars::replace_subschema;
@@ -87,7 +87,7 @@ impl From<UiDensity> for String {
 }
 
 /// Customizable settings for the UI and theme system.
-#[derive(Clone, PartialEq)]
+#[derive(Clone, PartialEq, SettingsUi)]
 pub struct ThemeSettings {
     /// The UI font size. Determines the size of text in the UI,
     /// as well as the size of a [gpui::Rems] unit.

crates/title_bar/Cargo.toml πŸ”—

@@ -42,7 +42,7 @@ rpc.workspace = true
 schemars.workspace = true
 serde.workspace = true
 settings.workspace = true
-settings_ui.workspace = true
+keymap_editor.workspace = true
 smallvec.workspace = true
 story = { workspace = true, optional = true }
 telemetry.workspace = true

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

@@ -29,10 +29,10 @@ use gpui::{
     IntoElement, MouseButton, ParentElement, Render, StatefulInteractiveElement, Styled,
     Subscription, WeakEntity, Window, actions, div,
 };
+use keymap_editor;
 use onboarding_banner::OnboardingBanner;
 use project::Project;
 use settings::Settings as _;
-use settings_ui::keybindings;
 use std::sync::Arc;
 use theme::ActiveTheme;
 use title_bar_settings::TitleBarSettings;
@@ -684,7 +684,7 @@ impl TitleBar {
                             "Settings Profiles",
                             zed_actions::settings_profile_selector::Toggle.boxed_clone(),
                         )
-                        .action("Key Bindings", Box::new(keybindings::OpenKeymapEditor))
+                        .action("Key Bindings", Box::new(keymap_editor::OpenKeymapEditor))
                         .action(
                             "Themes…",
                             zed_actions::theme_selector::Toggle::default().boxed_clone(),
@@ -732,7 +732,7 @@ impl TitleBar {
                                 "Settings Profiles",
                                 zed_actions::settings_profile_selector::Toggle.boxed_clone(),
                             )
-                            .action("Key Bindings", Box::new(keybindings::OpenKeymapEditor))
+                            .action("Key Bindings", Box::new(keymap_editor::OpenKeymapEditor))
                             .action(
                                 "Themes…",
                                 zed_actions::theme_selector::Toggle::default().boxed_clone(),

crates/title_bar/src/title_bar_settings.rs πŸ”—

@@ -1,9 +1,10 @@
 use db::anyhow;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Copy, Clone, Deserialize, Debug)]
+#[derive(Copy, Clone, Deserialize, Debug, SettingsUi)]
+#[settings_ui(group = "Title Bar", path = "title_bar")]
 pub struct TitleBarSettings {
     pub show_branch_icon: bool,
     pub show_onboarding_banner: bool,

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

@@ -39,7 +39,7 @@ use object::Object;
 use schemars::JsonSchema;
 use serde::Deserialize;
 use serde_derive::Serialize;
-use settings::{Settings, SettingsSources, SettingsStore, update_settings_file};
+use settings::{Settings, SettingsSources, SettingsStore, SettingsUi, update_settings_file};
 use state::{Mode, Operator, RecordedSelection, SearchState, VimGlobals};
 use std::{mem, ops::Range, sync::Arc};
 use surrounds::SurroundsType;
@@ -1774,7 +1774,7 @@ struct CursorShapeSettings {
     pub insert: Option<CursorShape>,
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 struct VimSettings {
     pub default_mode: Mode,
     pub toggle_relative_line_numbers: bool,

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

@@ -6,7 +6,7 @@
 
 use anyhow::Result;
 use gpui::App;
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
 /// Initializes the `vim_mode_setting` crate.
 pub fn init(cx: &mut App) {
@@ -17,6 +17,7 @@ pub fn init(cx: &mut App) {
 /// Whether or not to enable Vim mode.
 ///
 /// Default: false
+#[derive(SettingsUi)]
 pub struct VimModeSetting(pub bool);
 
 impl Settings for VimModeSetting {
@@ -43,6 +44,7 @@ impl Settings for VimModeSetting {
 /// Whether or not to enable Helix mode.
 ///
 /// Default: false
+#[derive(SettingsUi)]
 pub struct HelixModeSetting(pub bool);
 
 impl Settings for HelixModeSetting {

crates/workspace/src/item.rs πŸ”—

@@ -17,7 +17,7 @@ use gpui::{
 use project::{Project, ProjectEntryId, ProjectPath};
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsLocation, SettingsSources};
+use settings::{Settings, SettingsLocation, SettingsSources, SettingsUi};
 use smallvec::SmallVec;
 use std::{
     any::{Any, TypeId},
@@ -49,7 +49,7 @@ impl Default for SaveOptions {
     }
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct ItemSettings {
     pub git_status: bool,
     pub close_position: ClosePosition,
@@ -59,7 +59,7 @@ pub struct ItemSettings {
     pub show_close_button: ShowCloseButton,
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct PreviewTabsSettings {
     pub enabled: bool,
     pub enable_preview_from_file_finder: bool,

crates/workspace/src/workspace_settings.rs πŸ”—

@@ -6,9 +6,9 @@ use collections::HashMap;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct WorkspaceSettings {
     pub active_pane_modifiers: ActivePanelModifiers,
     pub bottom_dock_layout: BottomDockLayout,
@@ -216,7 +216,7 @@ pub struct WorkspaceSettingsContent {
     pub zoomed_padding: Option<bool>,
 }
 
-#[derive(Deserialize)]
+#[derive(Deserialize, SettingsUi)]
 pub struct TabBarSettings {
     pub show: bool,
     pub show_nav_history_buttons: bool,

crates/worktree/src/worktree_settings.rs πŸ”—

@@ -4,10 +4,10 @@ use anyhow::Context as _;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsSources};
+use settings::{Settings, SettingsSources, SettingsUi};
 use util::paths::PathMatcher;
 
-#[derive(Clone, PartialEq, Eq)]
+#[derive(Clone, PartialEq, Eq, SettingsUi)]
 pub struct WorktreeSettings {
     pub file_scan_inclusions: PathMatcher,
     pub file_scan_exclusions: PathMatcher,

crates/zed/Cargo.toml πŸ”—

@@ -131,6 +131,7 @@ serde_json.workspace = true
 session.workspace = true
 settings.workspace = true
 settings_ui.workspace = true
+keymap_editor.workspace = true
 shellexpand.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true

crates/zed/src/main.rs πŸ”—

@@ -632,6 +632,7 @@ pub fn main() {
         svg_preview::init(cx);
         onboarding::init(cx);
         settings_ui::init(cx);
+        keymap_editor::init(cx);
         extensions_ui::init(cx);
         zeta::init(cx);
         inspector_ui::init(app_state.clone(), cx);

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

@@ -1491,7 +1491,7 @@ fn reload_keymaps(cx: &mut App, mut user_key_bindings: Vec<KeyBinding>) {
         workspace::NewWindow,
     )]);
     // todo: nicer api here?
-    settings_ui::keybindings::KeymapEventChannel::trigger_keymap_changed(cx);
+    keymap_editor::KeymapEventChannel::trigger_keymap_changed(cx);
 }
 
 pub fn load_default_keymap(cx: &mut App) {

crates/zed/src/zed/app_menus.rs πŸ”—

@@ -1,6 +1,5 @@
 use collab_ui::collab_panel;
 use gpui::{Menu, MenuItem, OsAction};
-use settings_ui::keybindings;
 use terminal_view::terminal_panel;
 
 pub fn app_menus() -> Vec<Menu> {
@@ -17,7 +16,7 @@ pub fn app_menus() -> Vec<Menu> {
                     name: "Settings".into(),
                     items: vec![
                         MenuItem::action("Open Settings", super::OpenSettings),
-                        MenuItem::action("Open Key Bindings", keybindings::OpenKeymapEditor),
+                        MenuItem::action("Open Key Bindings", keymap_editor::OpenKeymapEditor),
                         MenuItem::action("Open Default Settings", super::OpenDefaultSettings),
                         MenuItem::action(
                             "Open Default Key Bindings",

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

@@ -3,7 +3,7 @@ use anyhow::Result;
 use gpui::App;
 use schemars::JsonSchema;
 use serde::{Deserialize, Serialize};
-use settings::{Settings, SettingsStore};
+use settings::{Settings, SettingsStore, SettingsUi};
 
 pub fn init(cx: &mut App) {
     ZlogSettings::register(cx);
@@ -15,7 +15,7 @@ pub fn init(cx: &mut App) {
     .detach();
 }
 
-#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
+#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema, SettingsUi)]
 pub struct ZlogSettings {
     #[serde(default, flatten)]
     pub scopes: std::collections::HashMap<String, String>,