feat: app settings #11

Merged
reya merged 3 commits from feat/settings into master 2026-09-01 13:12:31 +00:00
20 changed files with 1465 additions and 63 deletions
Generated
+14 -1
View File
@@ -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",
+1
View File
@@ -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" }
+18
View File
@@ -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"] }
+11
View File
@@ -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::*;
+215
View File
@@ -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<String>,
}
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<PathBuf>,
}
fn default_scan_paths() -> Vec<PathBuf> {
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<PathBuf>,
}
/// 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\""
);
}
}
+177
View File
@@ -0,0 +1,177 @@
use std::path::{Path, PathBuf};
use anyhow::Result;
use gpui::{App, Context, Entity, Global};
use crate::Settings;
struct GlobalSettingsStore(Entity<SettingsStore>);
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<Self> {
cx.global::<GlobalSettingsStore>().0.clone()
}
/// Install the store as a global.
pub fn set_global(entity: Entity<Self>, 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<Path>, _cx: &mut Context<Self>) -> 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<Self>) {
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::<Settings>(&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);
}
}
+2 -2
View File
@@ -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<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate());
-1
View File
@@ -24,4 +24,3 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23"
paths = { path = "../paths" }
+2 -12
View File
@@ -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<PathBuf> {
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<Path>, cx: &mut App) -> Entity<Backend> {
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
// 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<Path>, cx: &mut App) -> Entity<Backend> {
// `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
}
+6 -15
View File
@@ -10,11 +10,6 @@ struct GlobalLocalReposStore(Entity<LocalReposStore>);
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<Vec<PathBuf>>,
@@ -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<Self> {
cx.global::<GlobalLocalReposStore>().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<PathBuf>, cx: &mut Context<Self>) -> 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>) {
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<Self>) {
if self.scanning {
self.scan_dirty = true;
+6
View File
@@ -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;
+113
View File
@@ -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<SharedString>, label: impl Into<SharedString>) -> 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<SharedString>,
description: impl Into<SharedString>,
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<SharedString>,
description: impl Into<SharedString>,
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)
}
+1
View File
@@ -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" }
@@ -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);
@@ -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<DockArea>, 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<DockArea>, 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.";
let name_input = name_input.clone();
let desc_input = desc_input.clone();
@@ -148,10 +158,12 @@ pub fn open(dock_area: WeakEntity<DockArea>, 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<InputState>, 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<InputState>, 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();
@@ -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<String> = 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(),
@@ -52,8 +57,7 @@ pub fn grasp_servers_field(
relay_input: &Entity<InputState>,
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();
+4 -3
View File
@@ -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);
})),
),
)
}
@@ -0,0 +1,810 @@
//! 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, Sizable, 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<IndexPath> {
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<SelectOption>, Vec<SelectOption>) {
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<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>,
dark_theme: Entity<SelectState<Vec<SelectOption>>>,
font_size: Entity<InputState>,
mono_font_size: Entity<InputState>,
radius: Entity<InputState>,
radius_lg: Entity<InputState>,
grasp_server_input: Entity<InputState>,
/// The effective default create-repository folder, shown in the disabled
/// folder selector.
default_folder: Entity<InputState>,
/// Keeps the control subscriptions alive for the dialog's lifetime.
_subscriptions: Vec<Subscription>,
}
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", "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(650.))
.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()
.mt_2()
.gap_4()
.w_full()
.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",
"Choose whether the app follows the system theme or uses a light/dark theme.",
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()
.gap_3()
.w_full()
.child(setting_row(
cx,
"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 theme to use when the appearance is dark.",
Select::new(&controls.dark_theme).w_full(),
))
.child(setting_row(
cx,
"UI Font Size",
"Font size for the UI.",
NumberInput::new(&controls.font_size).w_full(),
))
.child(setting_row(
cx,
"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 for UI elements.",
NumberInput::new(&controls.radius).w_full(),
))
.child(setting_row(
cx,
"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",
"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",
"The shadow effect for UI elements.",
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,
"Grasp Servers",
"Servers used to host your git repositories via the Grasp protocol",
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 for newly created repositories.",
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().justify_end().items_center().child(
Button::new("settings-add-path")
.icon(IconName::Plus)
.label("Add directory")
.secondary()
.small()
.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::Folder)
.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<InputState>, 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".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<InputState>, 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<InputState>,
subscriptions: &mut Vec<Subscription>,
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::<f64>() {
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::<f64>() {
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);
}
+1
View File
@@ -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" }
+37 -12
View File
@@ -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();