Start up settings UI 2 (#38673)

Mikayla Maki , Anthony , Ben Kunkle , Anthony , and Ben Kunkle created

Release Notes:

- N/A

---------

Co-authored-by: Anthony <hello@anthonyeid.me>
Co-authored-by: Ben Kunkle <ben@zed.dev>
Co-authored-by: Anthony <anthony@zed.dev>
Co-authored-by: Ben Kunkle <ben.kunkle@gmail.com>

Change summary

Cargo.lock                            |  26 +
Cargo.toml                            |   1 
crates/settings/Cargo.toml            |   1 
crates/settings/src/keymap_file.rs    |   1 
crates/settings/src/settings_store.rs |   8 
crates/settings_ui/Cargo.toml         |  44 ++
crates/settings_ui/LICENSE-GPL        |   1 
crates/settings_ui/examples/ui.rs     |  48 +++
crates/settings_ui/src/settings_ui.rs | 446 +++++++++++++++++++++++++++++
crates/zed/Cargo.toml                 |  15 
crates/zed/src/main.rs                |   1 
11 files changed, 582 insertions(+), 10 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -14502,6 +14502,31 @@ dependencies = [
  "zed_actions",
 ]
 
+[[package]]
+name = "settings_ui"
+version = "0.1.0"
+dependencies = [
+ "anyhow",
+ "assets",
+ "command_palette_hooks",
+ "editor",
+ "feature_flags",
+ "fs",
+ "futures 0.3.31",
+ "gpui",
+ "language",
+ "menu",
+ "paths",
+ "project",
+ "serde",
+ "settings",
+ "theme",
+ "ui",
+ "workspace",
+ "workspace-hack",
+ "zlog",
+]
+
 [[package]]
 name = "sha1"
 version = "0.10.6"
@@ -20253,6 +20278,7 @@ dependencies = [
  "session",
  "settings",
  "settings_profile_selector",
+ "settings_ui",
  "shellexpand 2.1.2",
  "smol",
  "snippet_provider",

Cargo.toml 🔗

@@ -151,6 +151,7 @@ members = [
     "crates/settings",
     "crates/settings_macros",
     "crates/settings_profile_selector",
+    "crates/settings_ui",
     "crates/snippet",
     "crates/snippet_provider",
     "crates/snippets_ui",

crates/settings/Cargo.toml 🔗

@@ -45,6 +45,7 @@ zlog.workspace = true
 [dev-dependencies]
 fs = { workspace = true, features = ["test-support"] }
 gpui = { workspace = true, features = ["test-support"] }
+
 indoc.workspace = true
 pretty_assertions.workspace = true
 unindent.workspace = true

crates/settings/src/settings_store.rs 🔗

@@ -138,7 +138,6 @@ pub struct SettingsLocation<'a> {
     pub path: &'a Path,
 }
 
-/// A set of strongly-typed setting values defined via multiple config files.
 pub struct SettingsStore {
     setting_values: HashMap<TypeId, Box<dyn AnySettingValue>>,
     default_settings: Rc<SettingsContent>,
@@ -318,7 +317,7 @@ impl SettingsStore {
             .set_global_value(Box::new(value))
     }
 
-    /// Get the user's settings as a raw JSON value.
+    /// Get the user's settings content.
     ///
     /// For user-facing functionality use the typed setting interface.
     /// (e.g. ProjectSettings::get_global(cx))
@@ -326,6 +325,11 @@ impl SettingsStore {
         self.user_settings.as_ref()
     }
 
+    /// Get the default settings content as a raw JSON value.
+    pub fn raw_default_settings(&self) -> &SettingsContent {
+        &self.default_settings
+    }
+
     /// Get the configured settings profile names.
     pub fn configured_settings_profiles(&self) -> impl Iterator<Item = &str> {
         self.user_settings

crates/settings_ui/Cargo.toml 🔗

@@ -0,0 +1,44 @@
+[package]
+name = "settings_ui"
+version = "0.1.0"
+edition.workspace = true
+publish.workspace = true
+license = "GPL-3.0-or-later"
+
+[lints]
+workspace = true
+
+[lib]
+path = "src/settings_ui.rs"
+
+[features]
+default = []
+test-support = []
+
+[dependencies]
+project.workspace = true
+fs.workspace = true
+anyhow.workspace = true
+command_palette_hooks.workspace = true
+editor.workspace = true
+feature_flags.workspace = true
+gpui.workspace = true
+menu.workspace = true
+serde.workspace = true
+settings.workspace = true
+theme.workspace = true
+ui.workspace = true
+workspace-hack.workspace = true
+workspace.workspace = true
+
+[dev-dependencies]
+settings = { workspace = true, features = ["test-support"] }
+futures.workspace = true
+language.workspace = true
+assets.workspace = true
+paths.workspace = true
+zlog.workspace = true
+
+[[example]]
+name = "ui"
+path = "examples/ui.rs"

crates/settings_ui/examples/ui.rs 🔗

@@ -0,0 +1,48 @@
+use std::sync::Arc;
+
+use futures::StreamExt;
+use settings::{DEFAULT_KEYMAP_PATH, KeymapFile, SettingsStore, watch_config_file};
+use settings_ui::open_settings_editor;
+use ui::BorrowAppContext;
+
+fn main() {
+    let app = gpui::Application::new().with_assets(assets::Assets);
+
+    let fs = Arc::new(fs::RealFs::new(None, app.background_executor()));
+    let mut user_settings_file_rx = watch_config_file(
+        &app.background_executor(),
+        fs.clone(),
+        paths::settings_file().clone(),
+    );
+    zlog::init();
+    zlog::init_output_stderr();
+
+    app.run(move |cx| {
+        <dyn fs::Fs>::set_global(fs.clone(), cx);
+        settings::init(cx);
+        theme::init(theme::LoadThemes::JustBase, cx);
+        workspace::init_settings(cx);
+        project::Project::init_settings(cx);
+        language::init(cx);
+        editor::init(cx);
+        menu::init();
+
+        let keybindings =
+            KeymapFile::load_asset_allow_partial_failure(DEFAULT_KEYMAP_PATH, cx).unwrap();
+        cx.bind_keys(keybindings);
+        cx.spawn(async move |cx| {
+            while let Some(content) = user_settings_file_rx.next().await {
+                cx.update(|cx| {
+                    cx.update_global(|store: &mut SettingsStore, cx| {
+                        store.set_user_settings(&content, cx).unwrap()
+                    })
+                })
+                .ok();
+            }
+        })
+        .detach();
+
+        open_settings_editor(cx).unwrap();
+        cx.activate(true);
+    });
+}

crates/settings_ui/src/settings_ui.rs 🔗

@@ -0,0 +1,446 @@
+//! # settings_ui
+use std::{rc::Rc, sync::Arc};
+
+use editor::Editor;
+use feature_flags::{FeatureFlag, FeatureFlagAppExt as _};
+use gpui::{
+    App, AppContext as _, Context, Div, Entity, IntoElement, ReadGlobal as _, Render, Window,
+    WindowHandle, WindowOptions, actions, div, px, size,
+};
+use project::WorktreeId;
+use settings::{SettingsContent, SettingsStore};
+use std::path::Path;
+use ui::{
+    ActiveTheme as _, AnyElement, BorrowAppContext as _, Button, Clickable as _, Color,
+    FluentBuilder as _, Icon, IconName, InteractiveElement as _, Label, LabelCommon as _,
+    LabelSize, ParentElement, SharedString, StatefulInteractiveElement as _, Styled, Switch,
+    v_flex,
+};
+
+fn user_settings_data() -> Vec<SettingsPage> {
+    vec![
+        SettingsPage {
+            title: "General Page",
+            items: vec![
+                SettingsPageItem::SectionHeader("General Section"),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Confirm Quit",
+                    description: "Whether to confirm before quitting Zed",
+                    render: Rc::new(|_, cx| {
+                        render_toggle_button(
+                            "confirm_quit",
+                            SettingsFile::User,
+                            cx,
+                            |settings_content| &mut settings_content.workspace.confirm_quit,
+                        )
+                    }),
+                }),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Auto Update",
+                    description: "Automatically update Zed (may be ignored on Linux if installed through a package manager)",
+                    render: Rc::new(|_, cx| {
+                        render_toggle_button(
+                            "Auto Update",
+                            SettingsFile::User,
+                            cx,
+                            |settings_content| &mut settings_content.auto_update,
+                        )
+                    }),
+                }),
+            ],
+        },
+        SettingsPage {
+            title: "Project",
+            items: vec![
+                SettingsPageItem::SectionHeader("Worktree Settings Content"),
+                SettingsPageItem::SettingItem(SettingItem {
+                    title: "Project Name",
+                    description: "The displayed name of this project. If not set, the root directory name",
+                    render: Rc::new(|window, cx| {
+                        render_text_field(
+                            "project_name",
+                            SettingsFile::User,
+                            window,
+                            cx,
+                            |settings_content| &mut settings_content.project.worktree.project_name,
+                        )
+                    }),
+                }),
+            ],
+        },
+    ]
+}
+
+fn project_settings_data() -> Vec<SettingsPage> {
+    vec![SettingsPage {
+        title: "Project",
+        items: vec![
+            SettingsPageItem::SectionHeader("Worktree Settings Content"),
+            SettingsPageItem::SettingItem(SettingItem {
+                title: "Project Name",
+                description: " The displayed name of this project. If not set, the root directory name",
+                render: Rc::new(|window, cx| {
+                    render_text_field(
+                        "project_name",
+                        SettingsFile::Local((
+                            WorktreeId::from_usize(0),
+                            Arc::from(Path::new("TODO: actually pass through file")),
+                        )),
+                        window,
+                        cx,
+                        |settings_content| &mut settings_content.project.worktree.project_name,
+                    )
+                }),
+            }),
+        ],
+    }]
+}
+
+pub struct SettingsUiFeatureFlag;
+
+impl FeatureFlag for SettingsUiFeatureFlag {
+    const NAME: &'static str = "settings-ui";
+}
+
+actions!(
+    zed,
+    [
+        /// Opens Settings Editor.
+        OpenSettingsEditor
+    ]
+);
+
+pub fn init(cx: &mut App) {
+    cx.observe_new(|workspace: &mut workspace::Workspace, _, _| {
+        workspace.register_action_renderer(|div, _, _, cx| {
+            let settings_ui_actions = [std::any::TypeId::of::<OpenSettingsEditor>()];
+            let has_flag = cx.has_flag::<SettingsUiFeatureFlag>();
+            command_palette_hooks::CommandPaletteFilter::update_global(cx, |filter, _| {
+                if has_flag {
+                    filter.show_action_types(&settings_ui_actions);
+                } else {
+                    filter.hide_action_types(&settings_ui_actions);
+                }
+            });
+            if has_flag {
+                div.on_action(cx.listener(|_, _: &OpenSettingsEditor, _, cx| {
+                    open_settings_editor(cx).ok();
+                }))
+            } else {
+                div
+            }
+        });
+    })
+    .detach();
+}
+
+pub fn open_settings_editor(cx: &mut App) -> anyhow::Result<WindowHandle<SettingsWindow>> {
+    cx.open_window(
+        WindowOptions {
+            titlebar: None,
+            focus: true,
+            show: true,
+            kind: gpui::WindowKind::Normal,
+            window_min_size: Some(size(px(300.), px(500.))), // todo(settings_ui): Does this min_size make sense?
+            ..Default::default()
+        },
+        |window, cx| cx.new(|cx| SettingsWindow::new(window, cx)),
+    )
+}
+
+pub struct SettingsWindow {
+    files: Vec<SettingsFile>,
+    current_file: SettingsFile,
+    pages: Vec<SettingsPage>,
+    search: Entity<Editor>,
+    current_page: usize, // Index into pages - should probably be (usize, Option<usize>) for section + page
+}
+
+#[derive(Clone)]
+struct SettingsPage {
+    title: &'static str,
+    items: Vec<SettingsPageItem>,
+}
+
+#[derive(Clone)]
+enum SettingsPageItem {
+    SectionHeader(&'static str),
+    SettingItem(SettingItem),
+}
+
+impl SettingsPageItem {
+    fn render(&self, window: &mut Window, cx: &mut App) -> AnyElement {
+        match self {
+            SettingsPageItem::SectionHeader(header) => Label::new(SharedString::new_static(header))
+                .size(LabelSize::Large)
+                .into_any_element(),
+            SettingsPageItem::SettingItem(setting_item) => div()
+                .child(setting_item.title)
+                .child(setting_item.description)
+                .child((setting_item.render)(window, cx))
+                .into_any_element(),
+        }
+    }
+}
+
+impl SettingsPageItem {
+    fn _header(&self) -> Option<&'static str> {
+        match self {
+            SettingsPageItem::SectionHeader(header) => Some(header),
+            _ => None,
+        }
+    }
+}
+
+#[derive(Clone)]
+struct SettingItem {
+    title: &'static str,
+    description: &'static str,
+    render: std::rc::Rc<dyn Fn(&mut Window, &mut App) -> AnyElement>,
+}
+
+#[allow(unused)]
+#[derive(Clone)]
+enum SettingsFile {
+    User,                           // Uses all settings.
+    Local((WorktreeId, Arc<Path>)), // Has a special name, and special set of settings
+    Server(&'static str),           // Uses a special name, and the user settings
+}
+
+impl SettingsFile {
+    fn pages(&self) -> Vec<SettingsPage> {
+        match self {
+            SettingsFile::User => user_settings_data(),
+            SettingsFile::Local(_) => project_settings_data(),
+            SettingsFile::Server(_) => user_settings_data(),
+        }
+    }
+
+    fn name(&self) -> String {
+        match self {
+            SettingsFile::User => "User".to_string(),
+            SettingsFile::Local((_, path)) => format!("Local ({})", path.display()),
+            SettingsFile::Server(file) => format!("Server ({})", file),
+        }
+    }
+}
+
+impl SettingsWindow {
+    pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
+        let current_file = SettingsFile::User;
+        let search = cx.new(|cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_placeholder_text("Search Settings", window, cx);
+            editor
+        });
+        let mut this = Self {
+            files: vec![
+                SettingsFile::User,
+                SettingsFile::Local((
+                    WorktreeId::from_usize(0),
+                    Arc::from(Path::new("/my-project/")),
+                )),
+            ],
+            current_file: current_file,
+            pages: vec![],
+            current_page: 0,
+            search,
+        };
+        cx.observe_global_in::<SettingsStore>(window, move |_, _, cx| {
+            cx.notify();
+        })
+        .detach();
+
+        this.build_ui();
+        this
+    }
+
+    fn build_ui(&mut self) {
+        self.pages = self.current_file.pages();
+    }
+
+    fn change_file(&mut self, ix: usize) {
+        self.current_file = self.files[ix].clone();
+        self.build_ui();
+    }
+
+    fn render_files(&self, _window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
+        div()
+            .flex()
+            .flex_row()
+            .gap_1()
+            .children(self.files.iter().enumerate().map(|(ix, file)| {
+                Button::new(ix, file.name())
+                    .on_click(cx.listener(move |this, _, _window, _cx| this.change_file(ix)))
+            }))
+    }
+
+    fn render_search(&self, _window: &mut Window, _cx: &mut App) -> Div {
+        div()
+            .child(Icon::new(IconName::MagnifyingGlass))
+            .child(self.search.clone())
+    }
+
+    fn render_nav(&self, window: &mut Window, cx: &mut Context<SettingsWindow>) -> Div {
+        let mut nav = v_flex()
+            .p_4()
+            .gap_2()
+            .child(div().h_10()) // Files spacer;
+            .child(self.render_search(window, cx));
+
+        for (ix, page) in self.pages.iter().enumerate() {
+            nav = nav.child(
+                div()
+                    .id(page.title)
+                    .child(
+                        Label::new(page.title)
+                            .size(LabelSize::Large)
+                            .when(self.is_page_selected(ix), |this| {
+                                this.color(Color::Selected)
+                            }),
+                    )
+                    .on_click(cx.listener(move |this, _, _, cx| {
+                        this.current_page = ix;
+                        cx.notify();
+                    })),
+            );
+        }
+        nav
+    }
+
+    fn render_page(
+        &self,
+        page: &SettingsPage,
+        window: &mut Window,
+        cx: &mut Context<SettingsWindow>,
+    ) -> Div {
+        div()
+            .child(self.render_files(window, cx))
+            .child(Label::new(page.title))
+            .children(page.items.iter().map(|item| item.render(window, cx)))
+    }
+
+    fn current_page(&self) -> &SettingsPage {
+        &self.pages[self.current_page]
+    }
+
+    fn is_page_selected(&self, ix: usize) -> bool {
+        ix == self.current_page
+    }
+}
+
+impl Render for SettingsWindow {
+    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
+        div()
+            .size_full()
+            .bg(cx.theme().colors().background)
+            .flex()
+            .flex_row()
+            .text_color(cx.theme().colors().text)
+            .child(self.render_nav(window, cx).w(px(300.0)))
+            .child(self.render_page(self.current_page(), window, cx).w_full())
+    }
+}
+
+fn write_setting_value<T: Send + 'static>(
+    get_value: fn(&mut SettingsContent) -> &mut Option<T>,
+    value: Option<T>,
+    cx: &mut App,
+) {
+    cx.update_global(|store: &mut SettingsStore, cx| {
+        store.update_settings_file(<dyn fs::Fs>::global(cx), move |settings, _cx| {
+            *get_value(settings) = value;
+        });
+    });
+}
+
+fn render_text_field(
+    id: &'static str,
+    _file: SettingsFile,
+    window: &mut Window,
+    cx: &mut App,
+    get_value: fn(&mut SettingsContent) -> &mut Option<String>,
+) -> AnyElement {
+    // TODO: Updating file does not cause the editor text to reload, suspicious it may be a missing global update/notify in SettingsStore
+
+    // TODO: in settings window state
+    let store = SettingsStore::global(cx);
+
+    // TODO: This clone needs to go!!
+    let mut defaults = store.raw_default_settings().clone();
+    let mut user_settings = store
+        .raw_user_settings()
+        .cloned()
+        .unwrap_or_default()
+        .content;
+
+    // TODO: unwrap_or_default here because project name is null
+    let initial_text = get_value(user_settings.as_mut())
+        .clone()
+        .unwrap_or_else(|| get_value(&mut defaults).clone().unwrap_or_default());
+
+    let editor = window.use_keyed_state((id.into(), initial_text.clone()), cx, {
+        move |window, cx| {
+            let mut editor = Editor::single_line(window, cx);
+            editor.set_text(initial_text, window, cx);
+            editor
+        }
+    });
+
+    let weak_editor = editor.downgrade();
+    let theme_colors = cx.theme().colors();
+
+    div()
+        .child(editor)
+        .bg(theme_colors.editor_background)
+        .border_1()
+        .rounded_lg()
+        .border_color(theme_colors.border)
+        .on_action::<menu::Confirm>({
+            move |_, _, cx| {
+                let Some(editor) = weak_editor.upgrade() else {
+                    return;
+                };
+                let new_value = editor.read_with(cx, |editor, cx| editor.text(cx));
+                let new_value = (!new_value.is_empty()).then_some(new_value);
+                write_setting_value(get_value, new_value, cx);
+                editor.update(cx, |_, cx| {
+                    cx.notify();
+                });
+            }
+        })
+        .into_any_element()
+}
+
+fn render_toggle_button(
+    id: &'static str,
+    _: SettingsFile,
+    cx: &mut App,
+    get_value: fn(&mut SettingsContent) -> &mut Option<bool>,
+) -> AnyElement {
+    // TODO: in settings window state
+    let store = SettingsStore::global(cx);
+
+    // TODO: This clone needs to go!!
+    let mut defaults = store.raw_default_settings().clone();
+    let mut user_settings = store
+        .raw_user_settings()
+        .cloned()
+        .unwrap_or_default()
+        .content;
+
+    let toggle_state =
+        if get_value(&mut user_settings).unwrap_or_else(|| get_value(&mut defaults).unwrap()) {
+            ui::ToggleState::Selected
+        } else {
+            ui::ToggleState::Unselected
+        };
+
+    Switch::new(id, toggle_state)
+        .on_click({
+            move |state, _window, cx| {
+                write_setting_value(get_value, Some(*state == ui::ToggleState::Selected), cx);
+            }
+        })
+        .into_any_element()
+}

crates/zed/Cargo.toml 🔗

@@ -19,11 +19,11 @@ name = "zed"
 path = "src/main.rs"
 
 [dependencies]
-activity_indicator.workspace = true
 acp_tools.workspace = true
+activity_indicator.workspace = true
 agent.workspace = true
-agent_ui.workspace = true
 agent_settings.workspace = true
+agent_ui.workspace = true
 anyhow.workspace = true
 askpass.workspace = true
 assets.workspace = true
@@ -60,13 +60,13 @@ extensions_ui.workspace = true
 feature_flags.workspace = true
 feedback.workspace = true
 file_finder.workspace = true
-system_specs.workspace = true
 fs.workspace = true
 futures.workspace = true
 git.workspace = true
 git_hosting_providers.workspace = true
 git_ui.workspace = true
 go_to_line.workspace = true
+system_specs.workspace = true
 gpui = { workspace = true, features = [
     "wayland",
     "x11",
@@ -75,12 +75,13 @@ gpui = { workspace = true, features = [
 ] }
 gpui_tokio.workspace = true
 
+edit_prediction_button.workspace = true
 http_client.workspace = true
 image_viewer.workspace = true
-edit_prediction_button.workspace = true
 inspector_ui.workspace = true
 install_cli.workspace = true
 journal.workspace = true
+keymap_editor.workspace = true
 language.workspace = true
 language_extension.workspace = true
 language_model.workspace = true
@@ -93,7 +94,6 @@ line_ending_selector.workspace = true
 log.workspace = true
 markdown.workspace = true
 markdown_preview.workspace = true
-svg_preview.workspace = true
 menu.workspace = true
 migrator.workspace = true
 mimalloc = { version = "0.1", optional = true }
@@ -107,7 +107,6 @@ 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
@@ -126,12 +125,14 @@ serde.workspace = true
 serde_json.workspace = true
 session.workspace = true
 settings.workspace = true
-keymap_editor.workspace = true
+settings_profile_selector.workspace = true
+settings_ui.workspace = true
 shellexpand.workspace = true
 smol.workspace = true
 snippet_provider.workspace = true
 snippets_ui.workspace = true
 supermaven.workspace = true
+svg_preview.workspace = true
 sysinfo.workspace = true
 tab_switcher.workspace = true
 task.workspace = true

crates/zed/src/main.rs 🔗

@@ -614,6 +614,7 @@ pub fn main() {
         markdown_preview::init(cx);
         svg_preview::init(cx);
         onboarding::init(cx);
+        settings_ui::init(cx);
         keymap_editor::init(cx);
         extensions_ui::init(cx);
         zeta::init(cx);