//! The settings window: shared settings and this app's own. use ely_gpui_component::{ buttons::SegmentedControl, feedback::Banner, forms::{Input, InputEvent, Switch, TextInput}, primitives::{IconName, Severity}, settings::{self as kit, SettingsLayout, SettingsRow, SettingsSection, ThemeSelector}, }; use ely_platform::{ AppId, settings::{APPEARANCE, AUTHOR, Appearance, CHECK_UPDATES, LANGUAGE, Language, MOTION, Motion, OMARCHY_SYNC, Store, word}, }; use gpui::{ AppContext as _, Context, Entity, FocusHandle, Global, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, WindowHandle, div, }; use serde_json::Value; use std::sync::{ Arc, atomic::{AtomicU64, Ordering}, }; use super::{current, set}; use crate::{i18n::text, shell::Shell}; /// The open settings window, one per app. #[derive(Clone, Copy)] struct Opened(Option>); impl Global for Opened {} /// Opens the settings window, or brings it forward. pub fn open(app: AppId, cx: &mut gpui::App) { let shown = cx.try_global::().and_then(|opened| opened.0); if let Some(handle) = shown && handle.update(cx, |_, window, _| window.activate_window()).is_ok() { return; } let mut options = crate::run::window_options(app, gpui::size(gpui::px(720.0), gpui::px(520.0)), cx); if let Some(bar) = options.titlebar.as_mut() { bar.title = Some(text(cx, "settings.title", &[])); } match cx.open_window(options, |window, cx| cx.new(|cx| SettingsView::new(app, window, cx))) { Ok(handle) => cx.set_global(Opened(Some(handle))), Err(error) => log::error!("settings: the window did not open: {error:#}"), } } pub struct SettingsView { pub(super) app: AppId, focus: FocusHandle, section: SharedString, author: Entity, /// The name last read from or saved to the file. author_saved: String, /// The latest author commit; older writes are dropped. author_asked: Arc, git: Option>, git_error: Option, /// The latest Git commit; older checks are dropped. git_asked: Arc, pub(super) diagnostics: super::diagnostics::Diagnostics, pub(super) storage: super::storage::Storage, _fields: Vec, } impl SettingsView { pub(super) fn new(app: AppId, window: &mut Window, cx: &mut Context) -> Self { let author = cx.new(|cx| TextInput::new(window, cx).label(text(cx, "settings.author", &[]))); let account = ely_platform::user::account().unwrap_or_else(|error| { log::warn!("settings: no account name: {error}"); String::new() }); let named = current(cx).author.clone(); author.update(cx, |field, cx| { field.set_placeholder(account, cx); field.set_text(named.clone(), cx); }); let mut fields = vec![ cx.subscribe(&author, |this, field, event: &InputEvent, cx| { if matches!(event, InputEvent::Blur | InputEvent::Submit) { this.commit_author(&field, cx); } }), cx.observe_global::(|this, cx| this.follow_author(cx)), ]; let git = (app == AppId::Tree).then(|| cx.new(|cx| TextInput::new(window, cx).label(text(cx, "settings.git.path", &[])))); let mut git_error = None; if let Some(git) = &git { let stored = git_setting().unwrap_or_else(|error| { log::error!("settings: the Git setting is unreadable: {error}"); git_error = Some(text(cx, "settings.unreadable", &[("error", &error.to_string())])); String::new() }); git.update(cx, |field, cx| field.set_text(stored, cx)); fields.push(cx.subscribe(git, |this, field, event: &InputEvent, cx| { if matches!(event, InputEvent::Blur | InputEvent::Submit) { this.commit_git(&field, cx); } })); } let focus = cx.focus_handle(); window.focus(&focus, cx); Self { app, focus, section: "general".into(), author, author_saved: named, author_asked: Arc::default(), git, git_error, git_asked: Arc::default(), diagnostics: Default::default(), storage: Default::default(), _fields: fields, } } /// A name written elsewhere replaces an unedited field. fn follow_author(&mut self, cx: &mut Context) { let named = current(cx).author.clone(); let shown = self.author.read(cx).text().trim().to_string(); if named != self.author_saved && shown == self.author_saved { self.author.update(cx, |field, cx| field.set_text(named.clone(), cx)); self.author_saved = named; } } /// Writes an edit; a failed one stays for retry. fn commit_author(&mut self, field: &Entity, cx: &mut Context) { let typed = field.read(cx).text().trim().to_string(); // Every commit outdates the writes still waiting. let asked = self.author_asked.fetch_add(1, Ordering::SeqCst) + 1; if typed == self.author_saved { return; } let turn = (Arc::clone(&self.author_asked), asked); let write = super::saved(AUTHOR, Value::String(typed.clone()), Some(turn), cx); cx.spawn(async move |this, cx| { if write.await { this.update(cx, |this, _| this.author_saved = typed).ok(); } }) .detach(); } /// Checks a system Git, then keeps it; latest wins. fn commit_git(&mut self, field: &Entity, cx: &mut Context) { let typed = field.read(cx).text().trim().to_string(); // Every commit outdates the checks still running. let asked = self.git_asked.fetch_add(1, Ordering::SeqCst) + 1; if git_setting().is_ok_and(|stored| stored == typed) { self.git_error = None; return cx.notify(); } let latest = Arc::clone(&self.git_asked); let check = cx.background_executor().spawn({ let typed = typed.clone(); async move { if !typed.is_empty() { ely_platform::git::system_version(std::path::Path::new(&typed))?; } // Under the file lock: a later commit skips this. let still = || latest.load(Ordering::SeqCst) == asked; Store::app(AppId::Tree)?.set_if(ely_platform::git::PROGRAM, Value::String(typed), still) } }); cx.spawn(async move |this, cx| { let checked = check.await; this.update(cx, |this, cx| { if this.git_asked.load(Ordering::SeqCst) != asked { return log::info!("settings: Git check {asked} was overtaken"); } this.git_error = checked.err().map(|error| { log::warn!("settings: Git setting refused: {error}"); text(cx, "settings.git.bad", &[("path", &typed), ("error", &error.to_string())]) }); cx.notify(); }) }) .detach(); } pub(super) fn sections(&self, cx: &gpui::App) -> Vec<(&'static str, SharedString, IconName)> { let mut sections = vec![("general", text(cx, "settings.general", &[]), IconName::Settings)]; if matches!(self.app, AppId::Doc | AppId::Table | AppId::Show | AppId::Pdf) { sections.push(("documents", text(cx, "settings.documents", &[]), IconName::FileText)); } if self.git.is_some() { sections.push(("git", text(cx, "settings.git", &[]), IconName::GitBranch)); } sections.push(("storage", text(cx, "settings.storage", &[]), IconName::HardDrive)); sections.push(("diagnostics", text(cx, "settings.diagnostics", &[]), IconName::Bug)); sections } fn general(&self, cx: &gpui::App) -> SettingsSection { let shared = current(cx); let appearance = match shared.appearance { Appearance::System => kit::Appearance::System, Appearance::Light => kit::Appearance::Light, Appearance::Dark => kit::Appearance::Dark, }; let theme = ThemeSelector::new("settings-appearance", appearance, |chosen, _, cx| { let chosen = match chosen { kit::Appearance::System => Appearance::System, kit::Appearance::Light => Appearance::Light, kit::Appearance::Dark => Appearance::Dark, }; set(APPEARANCE, word(&Appearance::ALL, &chosen).into(), cx); }); let language = choices("settings-language", &Language::ALL, &shared.language, |choice, cx| match choice { Language::System => text(cx, "settings.language.system", &[]), Language::English => "English".into(), Language::Chinese => "简体中文".into(), }, LANGUAGE, cx); let motion = choices("settings-motion", &Motion::ALL, &shared.motion, |choice, cx| match choice { Motion::System => text(cx, "settings.motion.system", &[]), Motion::Reduced => text(cx, "settings.motion.reduce", &[]), Motion::Full => text(cx, "settings.motion.full", &[]), }, MOTION, cx); let section = SettingsSection::new(text(cx, "settings.general", &[])) .row(SettingsRow::new(text(cx, "settings.appearance", &[])).control(theme)) .row( SettingsRow::new(text(cx, "settings.language", &[])) .description(text(cx, "settings.language.next", &[])) .control(language), ) .row(SettingsRow::new(text(cx, "settings.motion", &[])).control(motion)); let updates = Switch::new("settings-updates", shared.check_updates) .name(text(cx, "settings.updates", &[])) .on_change(|on, _, cx| set(CHECK_UPDATES, Value::Bool(on), cx)); let mut section = section.row( SettingsRow::new(text(cx, "settings.updates", &[])) .description(text(cx, "settings.updates.hint", &[])) .control(updates), ); // Only where Omarchy is installed. if ely_platform::omarchy_current_dir().is_some() { let follow = Switch::new("settings-omarchy", shared.omarchy_sync) .name(text(cx, "settings.omarchy", &[])) .on_change(|on, _, cx| set(OMARCHY_SYNC, Value::Bool(on), cx)); section = section.row(SettingsRow::new(text(cx, "settings.omarchy", &[])).control(follow)); } section } fn page(&self, this: gpui::WeakEntity, cx: &gpui::App) -> gpui::AnyElement { match self.section.as_ref() { "documents" => { let autosave = Switch::new("settings-autosave", super::autosave::on(cx)) .name(text(cx, "settings.autosave", &[])) .on_change(|on, _, cx| super::autosave::set(on, cx)); SettingsSection::new(text(cx, "settings.documents", &[])) .row( SettingsRow::new(text(cx, "settings.author", &[])) .description(text(cx, "settings.author.hint", &[])) .control(div().w_full().max_w_64().child(Input::new(&self.author))), ) .row( SettingsRow::new(text(cx, "settings.autosave", &[])) .description(text(cx, "settings.autosave.hint", &[])) .control(autosave), ) .into_any_element() } "git" => { let field = self.git.clone().expect("the git section is ElyTree's"); let section = SettingsSection::new(text(cx, "settings.git", &[])).row( SettingsRow::new(text(cx, "settings.git.path", &[])) .description(text(cx, "settings.git.hint", &[])) .control(div().w_full().max_w_64().child(Input::new(&field).invalid(self.git_error.is_some()))), ); match self.git_error.clone() { Some(error) => section.row(Banner::new("settings-git-error", Severity::Danger, error)), None => section, } .into_any_element() } "storage" => self.storage_page(this, cx), "diagnostics" => self.diagnostics_page(this, cx), _ => self.general(cx).into_any_element(), } } } /// A segmented choice that writes `key` when picked. fn choices( id: &'static str, all: &'static [(T, &'static str)], chosen: &T, label: impl Fn(&T, &gpui::App) -> SharedString, key: &'static str, cx: &gpui::App, ) -> SegmentedControl { all.iter() .fold(SegmentedControl::new(id, word(all, chosen)), |control, (choice, word)| { control.segment(*word, label(choice, cx), None) }) .on_change(move |picked, _, cx| set(key, Value::String(picked.to_string()), cx)) } /// ElyTree's Git program; empty is its own. fn git_setting() -> std::io::Result { match Store::app(AppId::Tree)?.read()?.get(ely_platform::git::PROGRAM) { None => Ok(String::new()), Some(Value::String(path)) => Ok(path.clone()), Some(other) => Err(std::io::Error::other(format!("settings: git cannot be {other}"))), } } impl Render for SettingsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let sections = self.sections(cx); let this = cx.entity().downgrade(); let page = self.page(this.clone(), cx); let dialog = self.storage_dialog(this.clone(), cx); let layout = SettingsLayout::new("settings", sections, self.section.clone(), move |key, _, cx| { let key = key.clone(); this.update(cx, |this, cx| { if key == "diagnostics" { this.read_diagnostics(cx); } if key == "storage" { this.read_storage(cx); } this.section = key; cx.notify(); }) .ok(); }) .page(page); let current = cx.global::(); let unreadable = current.error.clone().map(|error| { Banner::new("settings-unreadable", Severity::Danger, text(cx, "settings.unreadable", &[("error", &error)])) }); let app_error: Vec<_> = current .app_error .iter() .map(|(kind, error)| { let id = SharedString::from(format!("settings-app-unreadable-{kind}")); Banner::new(id, Severity::Danger, text(cx, "settings.app_unreadable", &[("error", error)])) }) .collect(); let unsaved: Vec<_> = current .unsaved .iter() .map(|(kind, error)| { Banner::new(SharedString::from(format!("settings-unsaved-{kind}")), Severity::Danger, text(cx, "settings.unsaved", &[("error", error)])) }) .collect(); Shell::new(text(cx, "settings.title", &[]), &self.focus) .child(div().size_full().p_6().flex().flex_col().gap_4().children(unreadable).children(app_error).children(unsaved).child(layout)) .children(dialog) } }