From a0773d38aa971abbfd8ecd3eb739ee1520725334 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 1 Sep 2026 14:23:52 +0700 Subject: [PATCH 1/3] add settings crate --- Cargo.lock | 15 +- Cargo.toml | 1 + crates/settings/Cargo.toml | 18 ++ crates/settings/src/lib.rs | 11 + crates/settings/src/settings.rs | 215 ++++++++++++++++++ crates/settings/src/store.rs | 177 ++++++++++++++ crates/signed_nostr/src/backend.rs | 4 +- crates/signed_state/Cargo.toml | 1 - crates/signed_state/src/lib.rs | 14 +- crates/signed_state/src/local_repos.rs | 21 +- crates/workspace/Cargo.toml | 1 + .../src/views/repo_detail/init_dialog.rs | 8 +- .../src/views/sidebar/create_repo_dialog.rs | 30 ++- .../src/views/sidebar/grasp_servers.rs | 23 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 49 +++- 16 files changed, 531 insertions(+), 58 deletions(-) create mode 100644 crates/settings/Cargo.toml create mode 100644 crates/settings/src/lib.rs create mode 100644 crates/settings/src/settings.rs create mode 100644 crates/settings/src/store.rs diff --git a/Cargo.lock b/Cargo.lock index 928662a..8b25f79 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7764,6 +7764,18 @@ dependencies = [ "serde", ] +[[package]] +name = "settings" +version = "1.0.0" +dependencies = [ + "anyhow", + "gpui", + "log", + "paths", + "serde", + "serde_json", +] + [[package]] name = "sha1" version = "0.10.7" @@ -7874,6 +7886,7 @@ dependencies = [ "log", "paths", "reqwest_client", + "settings", "signed_state", "tracing", "tracing-subscriber", @@ -7927,7 +7940,6 @@ dependencies = [ "nostr", "nostr-connect", "nostr-sdk", - "paths", "rustls", "signed_core", "signed_git", @@ -10774,6 +10786,7 @@ dependencies = [ "log", "nostr", "paths", + "settings", "signed_core", "signed_git", "signed_state", diff --git a/Cargo.toml b/Cargo.toml index 2d17f53..efb0416 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,6 +24,7 @@ gpui-component = { git = "https://github.com/longbridge/gpui-component", feature gpui-base = { git = "https://github.com/longbridge/gpui-component" } dock = { path = "crates/dock" } +settings = { path = "crates/settings" } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml new file mode 100644 index 0000000..1ed7ef0 --- /dev/null +++ b/crates/settings/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "settings" +description = "Persisted application settings for Signed." +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +paths = { path = "../paths" } + +gpui.workspace = true +serde.workspace = true +serde_json.workspace = true +anyhow.workspace = true +log.workspace = true + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs new file mode 100644 index 0000000..228e05d --- /dev/null +++ b/crates/settings/src/lib.rs @@ -0,0 +1,11 @@ +//! Persisted application settings for Signed. +//! +//! The [`Settings`] model holds the user-configurable values that survive +//! restarts, and [`SettingsStore`] loads them from and saves them to a JSON +//! file on disk (see [`paths::settings_file`]). + +mod settings; +mod store; + +pub use settings::*; +pub use store::*; diff --git a/crates/settings/src/settings.rs b/crates/settings/src/settings.rs new file mode 100644 index 0000000..08c0ab1 --- /dev/null +++ b/crates/settings/src/settings.rs @@ -0,0 +1,215 @@ +use std::path::PathBuf; + +use serde::{Deserialize, Serialize}; + +/// The default grasp servers offered when the user hasn't published a +/// grasp list (kind `10317`) yet. +pub const DEFAULT_GRASP_SERVERS: [&str; 3] = [ + "wss://relay.ngit.dev", + "wss://gitnostr.com", + "wss://git.shakespeare.diy", +]; + +/// How the application picks its appearance. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum AppearanceMode { + /// Follow the system appearance (light/dark) at runtime. + #[default] + System, + /// Always use the light theme. + Light, + /// Always use the dark theme. + Dark, +} + +/// Theme configuration. +/// +/// The fields mirror the gpui-component `Theme` surface the application +/// customizes at startup, so applying the settings is a plain field-for-field +/// copy. The theme names identify entries in the gpui-component theme +/// registry. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct ThemeSettings { + /// Name of the light theme in the theme registry. + pub light_theme: String, + /// Name of the dark theme in the theme registry. + pub dark_theme: String, + /// The base font size in pixels. + pub font_size: f32, + /// The monospace font size in pixels. + pub mono_font_size: f32, + /// Corner radius for general elements in pixels. + pub radius: f32, + /// Corner radius for large elements (dialogs, notifications) in pixels. + pub radius_lg: f32, + /// Whether focused controls draw a ring outside their border. + pub focus_ring: bool, + /// Whether to render shadows. + pub shadow: bool, +} + +impl Default for ThemeSettings { + fn default() -> Self { + Self { + light_theme: "Signed Light".into(), + dark_theme: "Signed Dark".into(), + font_size: 16.0, + mono_font_size: 13.0, + radius: 2.0, + radius_lg: 6.0, + focus_ring: false, + shadow: false, + } + } +} + +/// Default grasp server settings. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct GraspServersSettings { + /// The servers offered when the user hasn't published a grasp list + /// (kind `10317`) yet. + pub default_servers: Vec, +} + +impl Default for GraspServersSettings { + fn default() -> Self { + Self { + default_servers: DEFAULT_GRASP_SERVERS + .iter() + .map(|server| (*server).to_owned()) + .collect(), + } + } +} + +/// Local repository scanning. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct LocalReposSettings { + /// The directories scanned for local git repositories. + /// + /// Defaults to the user's Desktop and Documents folders. + pub scan_paths: Vec, +} + +fn default_scan_paths() -> Vec { + vec![paths::desktop_dir(), paths::documents_dir()] +} + +impl Default for LocalReposSettings { + fn default() -> Self { + Self { + scan_paths: default_scan_paths(), + } + } +} + +/// The create-repository dialog. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CreateRepositorySettings { + /// The folder the create-repository dialog defaults to; the user's + /// Desktop when unset. + pub default_folder: Option, +} + +/// The complete set of persisted application settings. +#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct Settings { + /// How the application picks its appearance. + pub appearance: AppearanceMode, + /// Theme configuration. + pub theme: ThemeSettings, + /// Default grasp servers. + pub grasp_servers: GraspServersSettings, + /// Local repository scanning. + pub local_repos: LocalReposSettings, + /// The create-repository dialog. + pub create_repository: CreateRepositorySettings, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn defaults_match_the_app_conventions() { + let settings = Settings::default(); + assert_eq!(settings.appearance, AppearanceMode::System); + assert_eq!(settings.theme.light_theme, "Signed Light"); + assert_eq!(settings.theme.dark_theme, "Signed Dark"); + assert_eq!(settings.theme.font_size, 16.0); + assert_eq!(settings.theme.mono_font_size, 13.0); + assert_eq!(settings.theme.radius, 2.0); + assert_eq!(settings.theme.radius_lg, 6.0); + assert!(!settings.theme.focus_ring); + assert!(!settings.theme.shadow); + assert_eq!( + settings.grasp_servers.default_servers, + DEFAULT_GRASP_SERVERS.map(String::from).to_vec() + ); + assert_eq!(settings.local_repos.scan_paths.len(), 2); + assert_eq!( + settings.local_repos.scan_paths, + vec![paths::desktop_dir(), paths::documents_dir()] + ); + assert_eq!(settings.create_repository.default_folder, None); + } + + #[test] + fn json_roundtrip_preserves_everything() { + let settings = Settings { + appearance: AppearanceMode::Dark, + theme: ThemeSettings { + radius: 8.0, + ..Default::default() + }, + create_repository: CreateRepositorySettings { + default_folder: Some(PathBuf::from("/tmp/repos")), + }, + ..Default::default() + }; + + let json = serde_json::to_string(&settings).unwrap(); + let parsed: Settings = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed, settings); + } + + #[test] + fn missing_keys_fall_back_to_defaults() { + let settings: Settings = serde_json::from_str("{}").unwrap(); + assert_eq!(settings, Settings::default()); + } + + #[test] + fn partial_json_merges_with_defaults() { + let settings: Settings = + serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap(); + assert_eq!(settings.appearance, AppearanceMode::Dark); + assert_eq!(settings.theme.radius, 4.0); + // The rest of the theme and the other groups keep their defaults. + assert_eq!(settings.theme.light_theme, "Signed Light"); + assert_eq!(settings.grasp_servers, GraspServersSettings::default()); + assert_eq!(settings.create_repository.default_folder, None); + } + + #[test] + fn appearance_serializes_to_snake_case_names() { + assert_eq!( + serde_json::to_string(&AppearanceMode::System).unwrap(), + "\"system\"" + ); + assert_eq!( + serde_json::to_string(&AppearanceMode::Light).unwrap(), + "\"light\"" + ); + assert_eq!( + serde_json::to_string(&AppearanceMode::Dark).unwrap(), + "\"dark\"" + ); + } +} diff --git a/crates/settings/src/store.rs b/crates/settings/src/store.rs new file mode 100644 index 0000000..6245bfe --- /dev/null +++ b/crates/settings/src/store.rs @@ -0,0 +1,177 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use gpui::{App, Context, Entity, Global}; + +use crate::Settings; + +struct GlobalSettingsStore(Entity); + +impl Global for GlobalSettingsStore {} + +/// The application settings, loaded from disk at startup and persisted whenever they change. +/// Installed as a global by the app so any part of the UI can read and edit them. +pub struct SettingsStore { + path: PathBuf, + settings: Settings, +} + +impl SettingsStore { + /// Retrieve the global settings store (created at startup by the app). + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Install the store as a global. + pub fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalSettingsStore(entity)); + } + + /// Load the settings from `path`, falling back to the defaults when the + /// file is missing or unreadable. Missing keys merge with the defaults, + /// so older settings files keep working as new settings are added. + pub fn new(path: impl AsRef, _cx: &mut Context) -> Self { + Self { + path: path.as_ref().to_path_buf(), + settings: Self::load(path.as_ref()), + } + } + + /// A snapshot of the current settings. + pub fn settings(&self) -> &Settings { + &self.settings + } + + /// Mutate the settings, persist them to disk, and notify observers. + pub fn edit(&mut self, f: impl FnOnce(&mut Settings), cx: &mut Context) { + f(&mut self.settings); + if let Err(err) = self.save() { + log::error!( + "failed to save settings to {}: {err:#}", + self.path.display() + ); + } + cx.notify(); + } + + /// Read the settings file, merging any missing fields with the defaults. + fn load(path: &Path) -> Settings { + match std::fs::read_to_string(path) { + Ok(contents) => match serde_json::from_str::(&contents) { + Ok(settings) => settings, + Err(err) => { + log::error!( + "failed to parse settings file {}: {err}; using defaults", + path.display() + ); + Settings::default() + } + }, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Settings::default(), + Err(err) => { + log::error!( + "failed to read settings file {}: {err}; using defaults", + path.display() + ); + Settings::default() + } + } + } + + /// Write the settings to disk, replacing the file atomically + /// so a crash mid-write cannot corrupt the settings. + fn save(&self) -> Result<()> { + if let Some(parent) = self.path.parent() { + std::fs::create_dir_all(parent)?; + } + let json = serde_json::to_string_pretty(&self.settings)?; + let tmp = self.path.with_extension("json.tmp"); + std::fs::write(&tmp, json)?; + // `rename` cannot replace an existing file on Windows. + if cfg!(target_os = "windows") && self.path.exists() { + std::fs::remove_file(&self.path)?; + } + std::fs::rename(&tmp, &self.path)?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use std::sync::atomic::{AtomicUsize, Ordering}; + + use gpui::{AppContext, TestAppContext}; + + use super::*; + + static TEST_FILE_COUNTER: AtomicUsize = AtomicUsize::new(0); + + /// A unique, temporary settings path for one test. + fn temp_settings_path() -> PathBuf { + let n = TEST_FILE_COUNTER.fetch_add(1, Ordering::SeqCst); + std::env::temp_dir().join(format!( + "signed-settings-test-{}-{n}.json", + std::process::id() + )) + } + + fn cleanup(path: &Path) { + let _ = std::fs::remove_file(path); + let _ = std::fs::remove_file(path.with_extension("json.tmp")); + } + + #[test] + fn missing_file_loads_defaults() { + let path = temp_settings_path(); + cleanup(&path); + + let settings = SettingsStore::load(&path); + assert_eq!(settings, Settings::default()); + cleanup(&path); + } + + #[test] + fn corrupt_file_loads_defaults() { + let path = temp_settings_path(); + std::fs::write(&path, "{ not json").unwrap(); + + let settings = SettingsStore::load(&path); + assert_eq!(settings, Settings::default()); + cleanup(&path); + } + + #[test] + fn save_and_load_roundtrip() { + let path = temp_settings_path(); + cleanup(&path); + + let mut expected = Settings::default(); + expected.create_repository.default_folder = Some(PathBuf::from("/tmp/repos")); + + let store = SettingsStore { + path: path.clone(), + settings: expected.clone(), + }; + store.save().unwrap(); + + assert_eq!(SettingsStore::load(&path), expected); + cleanup(&path); + } + + #[gpui::test] + fn edit_mutates_and_persists(cx: &mut TestAppContext) { + let path = temp_settings_path(); + cleanup(&path); + + let store = cx.update(|cx| cx.new(|cx| SettingsStore::new(path.clone(), cx))); + cx.read(|cx| assert_eq!(store.read(cx).settings(), &Settings::default())); + + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.radius = 12.0, cx); + }); + + cx.read(|cx| assert_eq!(store.read(cx).settings().theme.radius, 12.0)); + assert_eq!(SettingsStore::load(&path).theme.radius, 12.0); + cleanup(&path); + } +} diff --git a/crates/signed_nostr/src/backend.rs b/crates/signed_nostr/src/backend.rs index ff86625..2770f29 100644 --- a/crates/signed_nostr/src/backend.rs +++ b/crates/signed_nostr/src/backend.rs @@ -15,8 +15,8 @@ use crate::signer::UniversalSigner; /// Open (or create) the LMDB database at `db_path` and build a client /// configured for Signed, together with a fresh signer. /// -/// The SDK manages its own internal tokio runtime; the returned client can be -/// driven by GPUI's executors. +/// The SDK manages its own internal tokio runtime. +/// the returned client can be driven by GPUI's executors. #[cfg(not(target_arch = "wasm32"))] pub async fn new_backend(db_path: impl AsRef) -> Result<(Client, UniversalSigner)> { let signer = UniversalSigner::new(Keys::generate()); diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 77328f7..fedbe76 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -24,4 +24,3 @@ log.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] rustls = "0.23" -paths = { path = "../paths" } diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 9e22e89..25a0968 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -18,17 +18,10 @@ pub use repo_list::{RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; pub use utils::shorten_pubkey; -/// The default directories scanned for local git repositories -/// on every platform: the user's Desktop and Documents folders. -#[cfg(not(target_arch = "wasm32"))] -fn default_scan_paths() -> Vec { - vec![paths::desktop_dir(), paths::documents_dir()] -} - /// Initialize the backend and stores, and install them as globals. /// Call once at startup, before opening any window that uses the stores. #[cfg(not(target_arch = "wasm32"))] -pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { +pub fn init(db_path: impl AsRef, scan_paths: Vec, cx: &mut App) -> Entity { // rustls uses the `aws_lc_rs` provider by default; ignore if already installed. rustls::crypto::aws_lc_rs::default_provider() .install_default() @@ -53,10 +46,7 @@ pub fn init(db_path: impl AsRef, cx: &mut App) -> Entity { // `GitStore::global` still works. GitStore::set_global(PathBuf::new(), cx); - LocalReposStore::set_global( - cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)), - cx, - ); + LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); entity } diff --git a/crates/signed_state/src/local_repos.rs b/crates/signed_state/src/local_repos.rs index 934420a..a1335ae 100644 --- a/crates/signed_state/src/local_repos.rs +++ b/crates/signed_state/src/local_repos.rs @@ -10,11 +10,6 @@ struct GlobalLocalReposStore(Entity); impl Global for GlobalLocalReposStore {} /// Store of the git repositories discovered under a set of scan paths. -/// -/// Created at startup by [`crate::init`] with the default scan paths -/// (the Desktop and Documents folders; empty on wasm, where no scan runs), -/// then installed as a global so the sidebar can list local repositories. -/// The scan runs on a background thread; only the results cross back into the entity. pub struct LocalReposStore { /// The directories being scanned. pub roots: Arc>, @@ -28,8 +23,7 @@ pub struct LocalReposStore { } impl LocalReposStore { - /// Retrieve the global local-repositories store - /// (created at startup by [`crate::init`]). + /// Retrieve the global local-repositories store. pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -38,8 +32,7 @@ impl LocalReposStore { cx.set_global(GlobalLocalReposStore(entity)); } - /// Create a store scanning `roots` right away - /// (a no-op when the list is empty, e.g. on wasm). + /// Create a store scanning `roots` right away. pub fn new(roots: Vec, cx: &mut Context) -> Self { let mut store = Self { roots: Arc::new(roots), @@ -52,10 +45,9 @@ impl LocalReposStore { store } - /// Forget a repository that has just been published to NIP-34, so it - /// leaves the local list immediately. A later rescan re-discovers it - /// from disk; the sidebar additionally hides published repositories by - /// identifier. + /// Forget a repository that has just been published to NIP-34, + /// so it leaves the local list immediately. A later rescan re-discovers it from disk, + /// the sidebar additionally hides published repositories by identifier. pub fn remove(&mut self, path: &Path, cx: &mut Context) { self.repos = Arc::new( self.repos @@ -67,8 +59,7 @@ impl LocalReposStore { cx.notify(); } - /// Re-run the scan. Requests that arrive while a scan is running are - /// folded into one follow-up scan; the results replace the list atomically. + /// Re-run the scan. pub fn rescan(&mut self, cx: &mut Context) { if self.scanning { self.scan_dirty = true; diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 2406672..7866cbb 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -8,6 +8,7 @@ publish.workspace = true assets = { path = "../assets" } dock = { workspace = true } paths = { path = "../paths" } +settings = { path = "../settings" } signed_core = { path = "../signed_core" } signed_git = { path = "../signed_git" } signed_state = { path = "../signed_state" } diff --git a/crates/workspace/src/views/repo_detail/init_dialog.rs b/crates/workspace/src/views/repo_detail/init_dialog.rs index 09c9f09..99c8c3c 100644 --- a/crates/workspace/src/views/repo_detail/init_dialog.rs +++ b/crates/workspace/src/views/repo_detail/init_dialog.rs @@ -10,6 +10,7 @@ use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, Dial use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::{ActiveTheme, Disableable, WindowExt}; +use settings::SettingsStore; use signed_state::Backend; use super::RepoDetailView; @@ -49,7 +50,12 @@ pub fn open( InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") }); let state = cx.new(|_| InitRepoState::default()); - let grasp_state = cx.new(|_| GraspServersState::new_default()); + let grasp_settings = SettingsStore::global(cx) + .read(cx) + .settings() + .grasp_servers + .clone(); + let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); load_user_grasp_servers(grasp_state.clone(), window, cx); diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 7b0c7c0..6931264 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -7,6 +7,7 @@ use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, Dial use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex}; +use settings::SettingsStore; use signed_core::Announcement; use signed_state::Backend; @@ -26,6 +27,15 @@ pub struct CreateRepoState { /// list) and falls back to the shared defaults when none are set. On /// success the dialog closes and the new repository opens in the dock. pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) { + let settings = SettingsStore::global(cx); + let default_folder = settings + .read(cx) + .settings() + .create_repository + .default_folder + .clone() + .unwrap_or_else(paths::desktop_dir); + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name")); let desc_input = cx.new(|cx| { TextareaState::new(window, cx) @@ -33,20 +43,20 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) .placeholder("Short description") }); let folder_input = cx.new(|cx| { - InputState::new(window, cx) - .default_value(paths::desktop_dir().to_string_lossy().to_string()) + InputState::new(window, cx).default_value(default_folder.to_string_lossy().to_string()) }); let relay_input = cx.new(|cx| { InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") }); let state = cx.new(|_| CreateRepoState::default()); - let grasp_state = cx.new(|_| GraspServersState::new_default()); + let grasp_settings = settings.read(cx).settings().grasp_servers.clone(); + let grasp_state = cx.new(|_| GraspServersState::new_default(&grasp_settings)); load_user_grasp_servers(grasp_state.clone(), window, cx); window.open_dialog(cx, move |dialog, _window, _cx| { const DESC: &str = "Publish a new repository to your grasp servers."; - const FOLDER_NOTE: &str = "Where the repository is stored, defaults to your Desktop"; + const FOLDER_NOTE: &str = "Where the repository is stored; defaults to your Desktop"; let name_input = name_input.clone(); let desc_input = desc_input.clone(); @@ -148,10 +158,12 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) /// Prompt the user to pick the folder the repository will be stored in, using /// the platform's native folder picker, and show the result in the disabled -/// folder input. +/// folder input. The picked folder is remembered in the settings so it +/// becomes the default next time. fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mut App) { let handle = window.window_handle(); let folder_input = folder_input.clone(); + let store = SettingsStore::global(cx); let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, @@ -166,6 +178,14 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu { let path = path.to_string_lossy().to_string(); cx.update_window(handle, |_, window, cx| { + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.create_repository.default_folder = Some(path.clone().into()) + }, + cx, + ); + }); folder_input.update(cx, |input, cx| input.set_value(path, window, cx)); }) .ok(); diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs index 25a9d0a..2c58b1a 100644 --- a/crates/workspace/src/views/sidebar/grasp_servers.rs +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -5,16 +5,10 @@ use gpui_component::form::{Field, field}; use gpui_component::input::{Input, InputState}; use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex}; use nostr::prelude::*; +use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings}; use signed_core::filters; use signed_state::Backend; -/// Grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. -const DEFAULT_GRASP_SERVERS: [&str; 3] = [ - "wss://relay.ngit.dev", - "wss://gitnostr.com", - "wss://git.shakespeare.diy", -]; - /// State of the grasp-server section of a publish dialog, so async /// results can be rendered. #[derive(Default)] @@ -30,11 +24,22 @@ pub struct GraspServersState { impl GraspServersState { /// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. - pub fn new_default() -> Self { + /// + /// The servers come from the persisted settings, falling back to the + /// built-in defaults when the configured list is empty. + pub fn new_default(settings: &GraspServersSettings) -> Self { + let urls: Vec = if settings.default_servers.is_empty() { + DEFAULT_GRASP_SERVERS + .iter() + .map(|url| (*url).to_owned()) + .collect() + } else { + settings.default_servers.clone() + }; Self { loading_servers: true, servers_enabled: false, - grasp_servers: DEFAULT_GRASP_SERVERS + grasp_servers: urls .iter() .filter_map(|url| RelayUrl::parse(url).ok()) .collect(), diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 709bebc..d8e2d7e 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -11,6 +11,7 @@ path = "src/main.rs" [dependencies] assets = { path = "../crates/assets" } paths = { path = "../crates/paths" } +settings = { path = "../crates/settings" } signed_state = { path = "../crates/signed_state" } workspace = { path = "../crates/workspace" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index fa2bce4..9f6aa79 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -3,8 +3,9 @@ use std::sync::Arc; use assets::Assets; use dock::TAB_BAR_HEIGHT; use gpui::*; -use gpui_component::{Theme, ThemeRegistry, theme}; +use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme}; use gpui_platform::application; +use settings::{AppearanceMode, SettingsStore}; fn main() { tracing_subscriber::fmt::init(); @@ -16,40 +17,64 @@ fn main() { gpui_component::init(cx); theme::init(cx); + // Load the persisted settings before applying the theme, + // so the stored appearance and theme configuration take effect at startup. + let store = cx.new(|cx| SettingsStore::new(paths::settings_file(), cx)); + SettingsStore::set_global(store.clone(), cx); + let settings = store.read(cx).settings().clone(); + // Register the built-in "Signed" theme (light + dark variants) - // and make it the active theme, following the system appearance. + // and make it the active theme, following the stored appearance. let registry = ThemeRegistry::global_mut(cx); for (name, content) in Assets.themes() { if let Err(err) = registry.load_themes_from_str(&content) { tracing::error!("Failed to load theme {name}: {err}"); } } - let light_theme = registry.themes().get("Signed Light").cloned(); - let dark_theme = registry.themes().get("Signed Dark").cloned(); + let light_theme = registry + .themes() + .get(settings.theme.light_theme.as_str()) + .cloned(); + let dark_theme = registry + .themes() + .get(settings.theme.dark_theme.as_str()) + .cloned(); let theme = Theme::global_mut(cx); - theme.radius = px(2.); - theme.radius_lg = px(6.); - theme.focus_ring = false; - theme.shadow = false; + theme.radius = px(settings.theme.radius); + theme.radius_lg = px(settings.theme.radius_lg); + theme.focus_ring = settings.theme.focus_ring; + theme.shadow = settings.theme.shadow; + theme.font_size = px(settings.theme.font_size); + theme.mono_font_size = px(settings.theme.mono_font_size); if let Some(light) = light_theme { theme.light_theme = light; } else { - tracing::warn!("Signed Light theme is missing from the registry"); + tracing::warn!( + "{} theme is missing from the registry", + settings.theme.light_theme + ); } if let Some(dark) = dark_theme { theme.dark_theme = dark; } else { - tracing::warn!("Signed Dark theme is missing from the registry"); + tracing::warn!( + "{} theme is missing from the registry", + settings.theme.dark_theme + ); } - Theme::sync_system_appearance(None, cx); + match settings.appearance { + AppearanceMode::System => Theme::sync_system_appearance(None, cx), + AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), + AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + } // Connects relays and restores the session. std::fs::create_dir_all(paths::nostr_dir()).ok(); - signed_state::init(paths::nostr_dir(), cx); + signed_state::init(paths::nostr_dir(), settings.local_repos.scan_paths, cx); // Local git clone cache for browsing repository contents. std::fs::create_dir_all(paths::repos_dir()).ok(); -- 2.54.0 From b3e34b00756e46676b7671615c6167fc3ff8bf97 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 1 Sep 2026 17:23:32 +0700 Subject: [PATCH 2/3] update design --- crates/signed_ui/src/lib.rs | 6 + crates/signed_ui/src/setting.rs | 113 +++ .../src/views/sidebar/create_repo_dialog.rs | 2 +- .../src/views/sidebar/grasp_servers.rs | 3 +- crates/workspace/src/views/sidebar/mod.rs | 7 +- .../src/views/sidebar/settings_dialog.rs | 808 ++++++++++++++++++ 6 files changed, 933 insertions(+), 6 deletions(-) create mode 100644 crates/signed_ui/src/setting.rs create mode 100644 crates/workspace/src/views/sidebar/settings_dialog.rs diff --git a/crates/signed_ui/src/lib.rs b/crates/signed_ui/src/lib.rs index d574ad8..3af2bd6 100644 --- a/crates/signed_ui/src/lib.rs +++ b/crates/signed_ui/src/lib.rs @@ -19,6 +19,10 @@ //! - [`placeholder`] — centered muted placeholder message //! - [`copy_row`] / [`menu_copy_row`] — rows with a copy-to-clipboard button //! - [`tree_row`] — one row of a file tree +//! - [`setting_row`] / [`setting_block`] — label + description rows for a +//! settings dialog, with the control on the right (row) or below (block) +//! - [`SelectOption`] — dropdown option with a display label and a stored +//! value //! - [`title_bar_drag_handlers`] — make an element behave like a window //! title bar (drag moves the window, double-click zooms) //! - [`image_cache`] — per-view LRU image cache provider @@ -29,6 +33,7 @@ mod nav_item; mod pixel_avatar; mod placeholder; mod segment_button; +mod setting; mod status_badge; mod title_bar; mod tree_row; @@ -45,6 +50,7 @@ pub use nav_item::NavItem; pub use pixel_avatar::PixelAvatar; pub use placeholder::placeholder; pub use segment_button::{CountBadge, SegmentButton}; +pub use setting::{SelectOption, setting_block, setting_row}; pub use status_badge::status_badge; pub use title_bar::title_bar_drag_handlers; pub use tree_row::tree_row; diff --git a/crates/signed_ui/src/setting.rs b/crates/signed_ui/src/setting.rs new file mode 100644 index 0000000..e24c727 --- /dev/null +++ b/crates/signed_ui/src/setting.rs @@ -0,0 +1,113 @@ +//! Reusable settings UI: rows and blocks for building a settings dialog, plus +//! a labeled dropdown option. + +use gpui::prelude::*; +use gpui::{App, SharedString, div}; +use gpui_component::searchable_list::SearchableListItem; +use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; + +/// A dropdown option with a display label and a stored value. +/// +/// Renders the `label` in the trigger and the menu, while `value` is what a +/// [`gpui_component::select::SelectState`] reports as the selection. +#[derive(Clone)] +pub struct SelectOption { + value: SharedString, + label: SharedString, +} + +impl SelectOption { + /// Create an option with the given stored `value` and display `label`. + pub fn new(value: impl Into, label: impl Into) -> Self { + Self { + value: value.into(), + label: label.into(), + } + } + + /// The stored value of this option. + pub fn value(&self) -> &SharedString { + &self.value + } + + /// The display label of this option. + pub fn label(&self) -> &SharedString { + &self.label + } +} + +impl SearchableListItem for SelectOption { + type Value = SharedString; + + fn title(&self) -> SharedString { + self.label.clone() + } + + fn value(&self) -> &Self::Value { + &self.value + } +} + +/// A settings row: label + description on the left, control on the right. +pub fn setting_row( + cx: &App, + title: impl Into, + description: impl Into, + control: impl IntoElement, +) -> impl IntoElement { + let title = title.into(); + let description = description.into(); + + h_flex() + .w_full() + .justify_between() + .items_center() + .gap_4() + .child( + v_flex() + .flex_1() + .min_w_0() + .child(div().text_sm().font_semibold().child(title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(description), + ), + ) + .child( + h_flex() + .w_40() + .flex_shrink_0() + .justify_end() + .items_center() + .child(control), + ) +} + +/// A full-width settings block: title + subtitle in one header, `gap_3` +/// between the header and the control below. +pub fn setting_block( + cx: &App, + title: impl Into, + description: impl Into, + control: impl IntoElement, +) -> impl IntoElement { + let title = title.into(); + let description = description.into(); + v_flex() + .w_full() + .gap_3() + .child( + v_flex() + .w_full() + .child(div().text_sm().font_semibold().child(title)) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .child(description), + ), + ) + .child(control) +} diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 6931264..21eb909 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -56,7 +56,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) window.open_dialog(cx, move |dialog, _window, _cx| { const DESC: &str = "Publish a new repository to your grasp servers."; - const FOLDER_NOTE: &str = "Where the repository is stored; defaults to your Desktop"; + const FOLDER_NOTE: &str = "Where the repository is stored."; let name_input = name_input.clone(); let desc_input = desc_input.clone(); diff --git a/crates/workspace/src/views/sidebar/grasp_servers.rs b/crates/workspace/src/views/sidebar/grasp_servers.rs index 2c58b1a..fdfe212 100644 --- a/crates/workspace/src/views/sidebar/grasp_servers.rs +++ b/crates/workspace/src/views/sidebar/grasp_servers.rs @@ -57,8 +57,7 @@ pub fn grasp_servers_field( relay_input: &Entity, cx: &App, ) -> Field { - const SERVER_NOTE: &str = - "Where the repository is hosted, the initial push goes to each server"; + const SERVER_NOTE: &str = "Where the repository is hosted"; let state = state.clone(); let relay_input = relay_input.clone(); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 5c962c6..dbe6311 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -26,6 +26,7 @@ pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; pub(crate) mod passphrase_dialog; +mod settings_dialog; use self::onboarding_dialog::OnboardingState; @@ -591,9 +592,9 @@ impl Render for SidebarPanel { "Settings", Icon::new(IconName::Settings).small(), ) - .on_click( - cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), - ), + .on_click(cx.listener(|_, _ev, window, cx| { + settings_dialog::open(window, cx); + })), ), ) } diff --git a/crates/workspace/src/views/sidebar/settings_dialog.rs b/crates/workspace/src/views/sidebar/settings_dialog.rs new file mode 100644 index 0000000..3a24019 --- /dev/null +++ b/crates/workspace/src/views/sidebar/settings_dialog.rs @@ -0,0 +1,808 @@ +//! The Settings dialog, opened from the sidebar's Settings entry. +//! +//! A custom settings layout that divides related settings into sections +//! separated by simple horizontal lines — no `GroupBox` boxes and no settings +//! navigation sidebar. Every control edits the persisted [`SettingsStore`] +//! and applies the change to the live theme immediately. + +use std::cell::Cell; +use std::path::PathBuf; +use std::rc::Rc; + +use gpui::prelude::*; +use gpui::{ + AnyWindowHandle, App, Entity, PathPromptOptions, SharedString, Subscription, Window, div, px, +}; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::input::{ + Input, InputEvent, InputState, NumberInput, NumberInputEvent, StepAction, +}; +use gpui_component::select::{Select, SelectEvent, SelectState}; +use gpui_component::separator::Separator; +use gpui_component::setting::NumberFieldOptions; +use gpui_component::switch::Switch; +use gpui_component::{ + ActiveTheme, IconName, IndexPath, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex, v_flex, +}; +use nostr::prelude::RelayUrl; +use settings::{AppearanceMode, Settings, SettingsStore}; +use signed_ui::{SelectOption, setting_block, setting_row}; + +/// The index of `value` in `options`, for seeding a [`SelectState`]. +fn selected_index(options: &[SelectOption], value: &str) -> Option { + options + .iter() + .position(|option| option.value().as_ref() == value) + .map(|row| IndexPath::default().row(row)) +} + +/// The light and dark themes registered in the theme registry. +fn theme_options(cx: &App) -> (Vec, Vec) { + let registry = ThemeRegistry::global(cx); + let mut light = Vec::new(); + let mut dark = Vec::new(); + for config in registry.themes().values() { + let name = config.name.clone(); + if config.mode.is_dark() { + dark.push(SelectOption::new(name.clone(), name)); + } else { + light.push(SelectOption::new(name.clone(), name)); + } + } + light.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref())); + dark.sort_by(|a, b| a.label().as_ref().cmp(b.label().as_ref())); + (light, dark) +} + +/// Stateful controls of the settings dialog, created once when it opens so +/// their values survive re-renders of the dialog content. +struct SettingsControls { + appearance: Entity>>, + light_theme: Entity>>, + dark_theme: Entity>>, + font_size: Entity, + mono_font_size: Entity, + radius: Entity, + radius_lg: Entity, + grasp_server_input: Entity, + /// The effective default create-repository folder, shown in the disabled + /// folder selector. + default_folder: Entity, + /// Keeps the control subscriptions alive for the dialog's lifetime. + _subscriptions: Vec, +} + +impl SettingsControls { + fn new(window: &mut Window, cx: &mut App) -> Self { + let store = SettingsStore::global(cx); + let settings = store.read(cx).settings().clone(); + + let appearance_options = vec![ + SelectOption::new("system", "Follow system"), + SelectOption::new("light", "Light"), + SelectOption::new("dark", "Dark"), + ]; + let appearance_value = match settings.appearance { + AppearanceMode::System => "system", + AppearanceMode::Light => "light", + AppearanceMode::Dark => "dark", + }; + let appearance = cx.new(|cx| { + SelectState::new( + appearance_options.clone(), + selected_index(&appearance_options, appearance_value), + window, + cx, + ) + }); + + let (light_options, dark_options) = theme_options(cx); + let light_theme = cx.new(|cx| { + SelectState::new( + light_options.clone(), + selected_index(&light_options, &settings.theme.light_theme), + window, + cx, + ) + }); + let dark_theme = cx.new(|cx| { + SelectState::new( + dark_options.clone(), + selected_index(&dark_options, &settings.theme.dark_theme), + window, + cx, + ) + }); + + let font_size = cx.new(|cx| { + InputState::new(window, cx).default_value(settings.theme.font_size.to_string()) + }); + let mono_font_size = cx.new(|cx| { + InputState::new(window, cx).default_value(settings.theme.mono_font_size.to_string()) + }); + let radius = cx + .new(|cx| InputState::new(window, cx).default_value(settings.theme.radius.to_string())); + let radius_lg = cx.new(|cx| { + InputState::new(window, cx).default_value(settings.theme.radius_lg.to_string()) + }); + + let grasp_server_input = + cx.new(|cx| InputState::new(window, cx).placeholder("wss://relay.example.com")); + let default_folder = cx.new(|cx| { + let folder = settings + .create_repository + .default_folder + .clone() + .unwrap_or_else(paths::desktop_dir); + InputState::new(window, cx).default_value(folder.to_string_lossy().to_string()) + }); + + let mut subscriptions = Vec::new(); + + subscriptions.push(cx.subscribe(&appearance, |_, event, cx| { + if let SelectEvent::Confirm(Some(value)) = event { + let appearance = match value.as_ref() { + "light" => AppearanceMode::Light, + "dark" => AppearanceMode::Dark, + _ => AppearanceMode::System, + }; + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.appearance = appearance, cx); + }); + apply_appearance(appearance, cx); + } + })); + + subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| { + if let SelectEvent::Confirm(Some(value)) = event { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| settings.theme.light_theme = value.to_string(), + cx, + ); + }); + apply_theme(cx); + } + })); + + subscriptions.push(cx.subscribe(&dark_theme, |_, event, cx| { + if let SelectEvent::Confirm(Some(value)) = event { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.dark_theme = value.to_string(), cx); + }); + apply_theme(cx); + } + })); + + wire_number_input( + &font_size, + &mut subscriptions, + window.window_handle(), + cx, + NumberFieldOptions { + min: 10., + max: 32., + ..Default::default() + }, + settings.theme.font_size as f64, + |value, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.font_size = value as f32, cx); + }); + apply_theme(cx); + }, + ); + + wire_number_input( + &mono_font_size, + &mut subscriptions, + window.window_handle(), + cx, + NumberFieldOptions { + min: 8., + max: 32., + ..Default::default() + }, + settings.theme.mono_font_size as f64, + |value, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.mono_font_size = value as f32, cx); + }); + apply_theme(cx); + }, + ); + + wire_number_input( + &radius, + &mut subscriptions, + window.window_handle(), + cx, + NumberFieldOptions { + min: 0., + max: 24., + ..Default::default() + }, + settings.theme.radius as f64, + |value, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.radius = value as f32, cx); + }); + apply_theme(cx); + }, + ); + + wire_number_input( + &radius_lg, + &mut subscriptions, + window.window_handle(), + cx, + NumberFieldOptions { + min: 0., + max: 24., + ..Default::default() + }, + settings.theme.radius_lg as f64, + |value, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.radius_lg = value as f32, cx); + }); + apply_theme(cx); + }, + ); + + Self { + appearance, + light_theme, + dark_theme, + font_size, + mono_font_size, + radius, + radius_lg, + grasp_server_input, + default_folder, + _subscriptions: subscriptions, + } + } +} + +/// Open the Settings dialog. +pub fn open(window: &mut Window, cx: &mut App) { + let controls = Rc::new(SettingsControls::new(window, cx)); + + let store = SettingsStore::global(cx); + let window_handle = window.window_handle(); + let store_subscription = cx.observe(&store, move |_, cx| { + window_handle + .update(cx, |_, window, _| window.refresh()) + .ok(); + }); + + let dialog_state = Rc::new((controls, store_subscription)); + + window.open_dialog(cx, move |dialog, _window, cx| { + let dialog_state = dialog_state.clone(); + dialog + .title("Settings") + .width(px(640.)) + .h(px(560.)) + .child(settings_view(&dialog_state.0, cx)) + }); +} + +/// The settings content: one section per related setting, divided by +/// horizontal separator lines. +fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement { + let store = SettingsStore::global(cx); + let settings = store.read(cx).settings().clone(); + + v_flex() + .w_full() + .gap_4() + .child(appearance_section(controls, cx)) + .child(Separator::horizontal()) + .child(theme_section(&settings, controls, cx)) + .child(Separator::horizontal()) + .child(grasp_servers_section(&settings, controls, cx)) + .child(Separator::horizontal()) + .child(repositories_section(&settings, controls, cx)) +} + +/// How the app picks its appearance. +fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement { + v_flex().w_full().gap_3().child(setting_row( + cx, + "Appearance", + "How the app picks its appearance: follow the system, or always light or dark.", + Select::new(&controls.appearance).w_full(), + )) +} + +/// Theme configuration: the theme names in the registry plus +/// the visual tweaks the application customizes at startup. +fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { + v_flex() + .w_full() + .gap_3() + .child(setting_row( + cx, + "Light theme", + "The light theme used when the appearance is light.", + Select::new(&controls.light_theme).w_full(), + )) + .child(setting_row( + cx, + "Dark theme", + "The dark theme used when the appearance is dark.", + Select::new(&controls.dark_theme).w_full(), + )) + .child(setting_row( + cx, + "Base font size", + "The base font size in pixels.", + NumberInput::new(&controls.font_size).w_full(), + )) + .child(setting_row( + cx, + "Monospace font size", + "The monospace font size in pixels.", + NumberInput::new(&controls.mono_font_size).w_full(), + )) + .child(setting_row( + cx, + "Corner radius", + "The corner radius of general elements in pixels.", + NumberInput::new(&controls.radius).w_full(), + )) + .child(setting_row( + cx, + "Large corner radius", + "The corner radius of large elements (dialogs, notifications) in pixels.", + NumberInput::new(&controls.radius_lg).w_full(), + )) + .child(setting_row( + cx, + "Focus ring", + "Draw a ring around focused controls.", + Switch::new("focus-ring") + .checked(settings.theme.focus_ring) + .on_click(move |checked: &bool, _window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.focus_ring = *checked, cx); + }); + apply_theme(cx); + }), + )) + .child(setting_row( + cx, + "Shadows", + "Render shadows.", + Switch::new("shadows") + .checked(settings.theme.shadow) + .on_click(move |checked: &bool, _window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit(|settings| settings.theme.shadow = *checked, cx); + }); + apply_theme(cx); + }), + )) +} + +/// The default grasp servers offered when the user hasn't published a grasp list (kind `10317`) yet. +fn grasp_servers_section( + settings: &Settings, + controls: &SettingsControls, + cx: &App, +) -> impl IntoElement { + let servers = settings.grasp_servers.default_servers.clone(); + + v_flex().w_full().gap_3().child(setting_block( + cx, + "Default servers", + "Servers offered when you haven't published a grasp list (kind 10317).", + grasp_server_editor(&servers, controls, cx), + )) +} + +/// The editable list of default grasp servers plus an add-relay input, +/// styled like the grasp-server section of the publish dialogs. +fn grasp_server_editor( + servers: &[String], + controls: &SettingsControls, + cx: &App, +) -> impl IntoElement { + let server_input = controls.grasp_server_input.clone(); + + v_flex() + .w_full() + .gap_2() + .children(servers.iter().enumerate().map(|(ix, server)| { + h_flex() + .w_full() + .gap_1() + .items_center() + .child( + h_flex() + .h_8() + .w_full() + .px_2() + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .text_sm() + .rounded(cx.theme().radius) + .child(display_server(server)), + ) + .child( + Button::new(format!("settings-remove-server:{ix}")) + .icon(IconName::Close) + .ghost() + .flex_shrink_0() + .tooltip("Remove") + .on_click(move |_event, _window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.grasp_servers.default_servers.remove(ix); + }, + cx, + ); + }); + }), + ) + })) + .child( + h_flex() + .gap_1() + .items_center() + .child(div().flex_1().child(Input::new(&server_input))) + .child( + Button::new("settings-add-server") + .icon(IconName::Plus) + .ghost() + .tooltip("Add grasp server") + .on_click({ + let server_input = server_input.clone(); + move |_event, window, cx| add_server(&server_input, window, cx) + }), + ), + ) +} + +/// The bare host of a grasp server (defaults are entered without a scheme), +/// matching how the publish dialogs display servers. +fn display_server(server: &str) -> SharedString { + RelayUrl::parse(server) + .ok() + .and_then(|relay| relay.domain().map(|domain| domain.to_owned())) + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(server.to_owned())) +} + +/// Local repository scanning and the create-repository dialog default folder. +fn repositories_section( + settings: &Settings, + controls: &SettingsControls, + cx: &App, +) -> impl IntoElement { + let scan_paths = settings.local_repos.scan_paths.clone(); + + v_flex() + .w_full() + .gap_3() + .child(setting_block( + cx, + "Scan directories", + "Directories scanned for local git repositories.", + scan_paths_editor(&scan_paths, cx), + )) + .child(setting_block( + cx, + "Default folder", + "The folder the Create Repository dialog defaults to.", + folder_selector(controls), + )) +} + +/// The editable list of scan directories plus an add-directory button, +/// styled like the grasp-server list. +fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { + v_flex() + .w_full() + .gap_2() + .children(scan_paths.iter().enumerate().map(|(ix, path)| { + h_flex() + .w_full() + .gap_1() + .items_center() + .child( + h_flex() + .h_8() + .w_full() + .px_2() + .bg(cx.theme().muted) + .text_color(cx.theme().muted_foreground) + .text_sm() + .rounded(cx.theme().radius) + .child(path.display().to_string()), + ) + .child( + Button::new(format!("settings-remove-path:{ix}")) + .icon(IconName::Close) + .ghost() + .flex_shrink_0() + .tooltip("Remove") + .on_click(move |_event, _window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.local_repos.scan_paths.remove(ix); + }, + cx, + ); + }); + }), + ) + })) + .child( + h_flex().w_full().justify_end().child( + Button::new("settings-add-path") + .icon(IconName::FolderOpen) + .ghost() + .tooltip("Add directory") + .on_click(move |_event, _window, cx| add_scan_path(cx)), + ), + ) +} + +/// The default-folder selector: a disabled input showing the effective +/// folder plus a picker button, matching the create-repository dialog. +fn folder_selector(controls: &SettingsControls) -> impl IntoElement { + let default_folder = controls.default_folder.clone(); + h_flex() + .w_full() + .gap_1() + .items_center() + .child( + div() + .flex_1() + .child(Input::new(&default_folder).disabled(true)), + ) + .child( + Button::new("settings-choose-folder") + .icon(IconName::FolderOpen) + .ghost() + .tooltip("Choose folder") + .on_click(move |_event, window, cx| { + choose_default_folder(&default_folder, window, cx); + }), + ) +} + +/// Parse the server input (accepting a bare host) and append it to the +/// default grasp servers. +fn add_server(input: &Entity, window: &mut Window, cx: &mut App) { + let value = input.read(cx).value().trim().to_owned(); + if value.is_empty() { + return; + } + let normalized = if value.contains("://") { + value + } else { + format!("wss://{value}") + }; + if RelayUrl::parse(&normalized).is_err() { + return; + } + + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + if !settings.grasp_servers.default_servers.contains(&normalized) { + settings + .grasp_servers + .default_servers + .push(normalized.clone()); + } + }, + cx, + ); + }); + input.update(cx, |input, cx| input.set_value("", window, cx)); +} + +/// Prompt for directories to add to the local-repository scan. +fn add_scan_path(cx: &mut App) { + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: true, + prompt: Some("Choose directories to scan".into()), + }); + + cx.spawn(async move |cx| { + if let Ok(Ok(Some(paths))) = prompt.await { + cx.update(|cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + for path in paths { + let path = path.to_string_lossy().to_string(); + if !settings + .local_repos + .scan_paths + .iter() + .any(|existing| existing.to_string_lossy() == path) + { + settings.local_repos.scan_paths.push(path.into()); + } + } + }, + cx, + ); + }); + }); + } + }) + .detach(); +} + +/// Prompt for the folder the Create Repository dialog should default to, +/// remembering it in the settings and showing it in the disabled input. +fn choose_default_folder(default_folder: &Entity, window: &mut Window, cx: &mut App) { + let handle = window.window_handle(); + let default_folder = default_folder.clone(); + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose default folder".into()), + }); + + cx.spawn(async move |cx| { + if let Ok(Ok(Some(mut paths))) = prompt.await + && let Some(path) = paths.pop() + { + let path = path.to_string_lossy().to_string(); + cx.update_window(handle, |_, window, cx| { + let store = SettingsStore::global(cx); + store.update(cx, |store, cx| { + store.edit( + |settings| { + settings.create_repository.default_folder = Some(path.clone().into()) + }, + cx, + ); + }); + default_folder.update(cx, |input, cx| input.set_value(path, window, cx)); + }) + .ok(); + } + }) + .detach(); +} + +/// Wire a number input to the settings: steps clamp and persist, typed +/// changes parse, clamp and persist. +fn wire_number_input( + state: &Entity, + subscriptions: &mut Vec, + window_handle: AnyWindowHandle, + cx: &mut App, + options: NumberFieldOptions, + initial: f64, + on_change: impl Fn(f64, &mut App) + 'static, +) { + let on_change = Rc::new(on_change); + let initial_value = Rc::new(Cell::new(initial)); + let (min, max, step) = (options.min, options.max, options.step); + + subscriptions.push(cx.subscribe(&state.clone(), { + let state = state.clone(); + let initial_value = initial_value.clone(); + let on_change = on_change.clone(); + move |_, event: &NumberInputEvent, cx| { + let NumberInputEvent::Step(action) = event; + let value = state.read(cx).value(); + if let Ok(value) = value.parse::() { + let new_value = match action { + StepAction::Increment => value + step, + StepAction::Decrement => value - step, + }; + let clamped = new_value.clamp(min, max); + window_handle + .update(cx, |_, window, cx| { + state.update(cx, |input, cx| { + input.set_value(SharedString::from(clamped.to_string()), window, cx); + }); + }) + .ok(); + initial_value.set(clamped); + on_change(clamped, cx); + } + } + })); + + subscriptions.push(cx.subscribe(&state.clone(), { + let state = state.clone(); + let initial_value = initial_value.clone(); + let on_change = on_change.clone(); + move |_, event: &InputEvent, cx| { + if matches!(event, InputEvent::Change) { + let value = state.read(cx).value(); + if value == initial_value.get().to_string() { + return; + } + if let Ok(parsed) = value.parse::() { + let clamped = parsed.clamp(min, max); + initial_value.set(clamped); + on_change(clamped, cx); + if clamped != parsed { + window_handle + .update(cx, |_, window, cx| { + state.update(cx, |input, cx| { + input.set_value( + SharedString::from(clamped.to_string()), + window, + cx, + ); + }); + }) + .ok(); + } + } + } + } + })); +} + +/// Apply the persisted appearance to the live theme. +fn apply_appearance(appearance: AppearanceMode, cx: &mut App) { + match appearance { + AppearanceMode::System => Theme::sync_system_appearance(None, cx), + AppearanceMode::Light => Theme::change(ThemeMode::Light, None, cx), + AppearanceMode::Dark => Theme::change(ThemeMode::Dark, None, cx), + } +} + +/// Re-apply the persisted theme configuration to the live theme. +fn apply_theme(cx: &mut App) { + let store = SettingsStore::global(cx); + let settings = store.read(cx).settings().theme.clone(); + + let registry = ThemeRegistry::global(cx); + let light_config = registry + .themes() + .get(settings.light_theme.as_str()) + .cloned(); + let dark_config = registry.themes().get(settings.dark_theme.as_str()).cloned(); + let mode = Theme::global(cx).mode; + + let theme = Theme::global_mut(cx); + theme.radius = px(settings.radius); + theme.radius_lg = px(settings.radius_lg); + theme.focus_ring = settings.focus_ring; + theme.shadow = settings.shadow; + theme.font_size = px(settings.font_size); + theme.mono_font_size = px(settings.mono_font_size); + if let Some(config) = light_config { + theme.light_theme = config; + } + if let Some(config) = dark_config { + theme.dark_theme = config; + } + + // Re-apply the active mode so the updated configs take effect. + Theme::change(mode, None, cx); +} -- 2.54.0 From 3fa2f4db81a98a9aa8fb139e53c88668ddb37a33 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Tue, 1 Sep 2026 20:07:14 +0700 Subject: [PATCH 3/3] update ui --- .../src/views/sidebar/settings_dialog.rs | 66 ++++++++++--------- 1 file changed, 34 insertions(+), 32 deletions(-) diff --git a/crates/workspace/src/views/sidebar/settings_dialog.rs b/crates/workspace/src/views/sidebar/settings_dialog.rs index 3a24019..2d58011 100644 --- a/crates/workspace/src/views/sidebar/settings_dialog.rs +++ b/crates/workspace/src/views/sidebar/settings_dialog.rs @@ -22,7 +22,8 @@ use gpui_component::separator::Separator; use gpui_component::setting::NumberFieldOptions; use gpui_component::switch::Switch; use gpui_component::{ - ActiveTheme, IconName, IndexPath, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex, v_flex, + ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex, + v_flex, }; use nostr::prelude::RelayUrl; use settings::{AppearanceMode, Settings, SettingsStore}; @@ -78,7 +79,7 @@ impl SettingsControls { let settings = store.read(cx).settings().clone(); let appearance_options = vec![ - SelectOption::new("system", "Follow system"), + SelectOption::new("system", "System"), SelectOption::new("light", "Light"), SelectOption::new("dark", "Dark"), ]; @@ -275,7 +276,6 @@ impl SettingsControls { /// Open the Settings dialog. pub fn open(window: &mut Window, cx: &mut App) { let controls = Rc::new(SettingsControls::new(window, cx)); - let store = SettingsStore::global(cx); let window_handle = window.window_handle(); let store_subscription = cx.observe(&store, move |_, cx| { @@ -290,7 +290,7 @@ pub fn open(window: &mut Window, cx: &mut App) { let dialog_state = dialog_state.clone(); dialog .title("Settings") - .width(px(640.)) + .width(px(650.)) .h(px(560.)) .child(settings_view(&dialog_state.0, cx)) }); @@ -303,8 +303,9 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement let settings = store.read(cx).settings().clone(); v_flex() - .w_full() + .mt_2() .gap_4() + .w_full() .child(appearance_section(controls, cx)) .child(Separator::horizontal()) .child(theme_section(&settings, controls, cx)) @@ -319,7 +320,7 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement v_flex().w_full().gap_3().child(setting_row( cx, "Appearance", - "How the app picks its appearance: follow the system, or always light or dark.", + "Choose whether the app follows the system theme or uses a light/dark theme.", Select::new(&controls.appearance).w_full(), )) } @@ -328,47 +329,47 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement /// the visual tweaks the application customizes at startup. fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { v_flex() - .w_full() .gap_3() + .w_full() .child(setting_row( cx, - "Light theme", - "The light theme used when the appearance is light.", + "Light Theme", + "The theme to use when the appearance is light.", Select::new(&controls.light_theme).w_full(), )) .child(setting_row( cx, - "Dark theme", - "The dark theme used when the appearance is dark.", + "Dark Theme", + "The theme to use when the appearance is dark.", Select::new(&controls.dark_theme).w_full(), )) .child(setting_row( cx, - "Base font size", - "The base font size in pixels.", + "UI Font Size", + "Font size for the UI.", NumberInput::new(&controls.font_size).w_full(), )) .child(setting_row( cx, - "Monospace font size", - "The monospace font size in pixels.", + "Editor Font Size", + "Font size for editor text.", NumberInput::new(&controls.mono_font_size).w_full(), )) .child(setting_row( cx, - "Corner radius", - "The corner radius of general elements in pixels.", + "Corner Radius", + "The corner radius for UI elements.", NumberInput::new(&controls.radius).w_full(), )) .child(setting_row( cx, - "Large corner radius", - "The corner radius of large elements (dialogs, notifications) in pixels.", + "Large Corner Radius", + "The corner radius for large UI elements (dialogs, notifications).", NumberInput::new(&controls.radius_lg).w_full(), )) .child(setting_row( cx, - "Focus ring", + "Focus Ring", "Draw a ring around focused controls.", Switch::new("focus-ring") .checked(settings.theme.focus_ring) @@ -383,7 +384,7 @@ fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> .child(setting_row( cx, "Shadows", - "Render shadows.", + "The shadow effect for UI elements.", Switch::new("shadows") .checked(settings.theme.shadow) .on_click(move |checked: &bool, _window, cx| { @@ -406,8 +407,8 @@ fn grasp_servers_section( v_flex().w_full().gap_3().child(setting_block( cx, - "Default servers", - "Servers offered when you haven't published a grasp list (kind 10317).", + "Grasp Servers", + "Servers used to host your git repositories via the Grasp protocol", grasp_server_editor(&servers, controls, cx), )) } @@ -500,14 +501,14 @@ fn repositories_section( .gap_3() .child(setting_block( cx, - "Scan directories", + "Scan Directories", "Directories scanned for local git repositories.", scan_paths_editor(&scan_paths, cx), )) .child(setting_block( cx, - "Default folder", - "The folder the Create Repository dialog defaults to.", + "Default Folder", + "The folder for newly created repositories.", folder_selector(controls), )) } @@ -554,11 +555,12 @@ fn scan_paths_editor(scan_paths: &[PathBuf], cx: &App) -> impl IntoElement { ) })) .child( - h_flex().w_full().justify_end().child( + h_flex().justify_end().items_center().child( Button::new("settings-add-path") - .icon(IconName::FolderOpen) - .ghost() - .tooltip("Add directory") + .icon(IconName::Plus) + .label("Add directory") + .secondary() + .small() .on_click(move |_event, _window, cx| add_scan_path(cx)), ), ) @@ -579,7 +581,7 @@ fn folder_selector(controls: &SettingsControls) -> impl IntoElement { ) .child( Button::new("settings-choose-folder") - .icon(IconName::FolderOpen) + .icon(IconName::Folder) .ghost() .tooltip("Choose folder") .on_click(move |_event, window, cx| { @@ -627,7 +629,7 @@ fn add_scan_path(cx: &mut App) { files: false, directories: true, multiple: true, - prompt: Some("Choose directories to scan".into()), + prompt: Some("Choose directories".into()), }); cx.spawn(async move |cx| { -- 2.54.0