//! # 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() }