From 53885c00d3b3415ffd25814ab8557a2d50b8d8f7 Mon Sep 17 00:00:00 2001 From: Mikayla Maki Date: Wed, 24 Sep 2025 08:45:14 -0700 Subject: [PATCH] Start up settings UI 2 (#38673) Release Notes: - N/A --------- Co-authored-by: Anthony Co-authored-by: Ben Kunkle Co-authored-by: Anthony Co-authored-by: Ben Kunkle --- 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(-) create mode 100644 crates/settings_ui/Cargo.toml create mode 120000 crates/settings_ui/LICENSE-GPL create mode 100644 crates/settings_ui/examples/ui.rs create mode 100644 crates/settings_ui/src/settings_ui.rs diff --git a/Cargo.lock b/Cargo.lock index 46de7e804b2b13ff775ac87953bb7c9593c39120..c3c374fb12a7feb61770016d714115759d6c6ef5 100644 --- a/Cargo.lock +++ b/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", diff --git a/Cargo.toml b/Cargo.toml index a06ba76326e1b3c6482507119c824f143e26065a..4d51d0577051280305330a6481b0138027dd0b24 100644 --- a/Cargo.toml +++ b/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", diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 4790df4d95f676b7e7170e86b6d05976cbd3c69a..cfa3238ad65b22c1dcba2daaa9e70322819a493a 100644 --- a/crates/settings/Cargo.toml +++ b/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 diff --git a/crates/settings/src/keymap_file.rs b/crates/settings/src/keymap_file.rs index 6e9ab5c34d5749e3cdaf0f144930353f2323f17b..10421ef8f8984a7b5af58ec06d12e21889ea3bba 100644 --- a/crates/settings/src/keymap_file.rs +++ b/crates/settings/src/keymap_file.rs @@ -177,7 +177,6 @@ impl KeymapFile { } } - #[cfg(feature = "test-support")] pub fn load_asset_allow_partial_failure( asset_path: &str, cx: &App, diff --git a/crates/settings/src/settings_store.rs b/crates/settings/src/settings_store.rs index 040122bc1d1fde4387e4ef0c92e1d71b0420b5c6..c5eb80c48c4481646446bb58f92378d54801615c 100644 --- a/crates/settings/src/settings_store.rs +++ b/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>, default_settings: Rc, @@ -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 { self.user_settings diff --git a/crates/settings_ui/Cargo.toml b/crates/settings_ui/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..4dd1ecdb7f2a425b20f04a623853af75d3006ec2 --- /dev/null +++ b/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" diff --git a/crates/settings_ui/LICENSE-GPL b/crates/settings_ui/LICENSE-GPL new file mode 120000 index 0000000000000000000000000000000000000000..89e542f750cd3860a0598eff0dc34b56d7336dc4 --- /dev/null +++ b/crates/settings_ui/LICENSE-GPL @@ -0,0 +1 @@ +../../LICENSE-GPL \ No newline at end of file diff --git a/crates/settings_ui/examples/ui.rs b/crates/settings_ui/examples/ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..46b5fa02a45d94d0094a12db90746b7889ce6ba5 --- /dev/null +++ b/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| { + ::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); + }); +} diff --git a/crates/settings_ui/src/settings_ui.rs b/crates/settings_ui/src/settings_ui.rs new file mode 100644 index 0000000000000000000000000000000000000000..86e8b61ec6a439033f43a9e085282383ad619c26 --- /dev/null +++ b/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 { + 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 { + 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::()]; + let has_flag = cx.has_flag::(); + 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> { + 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, + current_file: SettingsFile, + pages: Vec, + search: Entity, + current_page: usize, // Index into pages - should probably be (usize, Option) for section + page +} + +#[derive(Clone)] +struct SettingsPage { + title: &'static str, + items: Vec, +} + +#[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 AnyElement>, +} + +#[allow(unused)] +#[derive(Clone)] +enum SettingsFile { + User, // Uses all settings. + Local((WorktreeId, Arc)), // 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 { + 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 { + 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::(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) -> 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) -> 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, + ) -> 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) -> 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( + get_value: fn(&mut SettingsContent) -> &mut Option, + value: Option, + cx: &mut App, +) { + cx.update_global(|store: &mut SettingsStore, cx| { + store.update_settings_file(::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, +) -> 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::({ + 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, +) -> 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() +} diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 18e2226ece905ceea0b262879dc59225f356155d..c550669479e204e15e6647bb211743e17acfc89d 100644 --- a/crates/zed/Cargo.toml +++ b/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 diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 98140cc61497ac5c1cfd3cf9f0ff1b6d19e4888c..43ee2251bbaf130e413a6f534f1cca38e76164eb 100644 --- a/crates/zed/src/main.rs +++ b/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);