feat: app settings (#11)

Reviewed-on: https://git.reya.su/reya/signed/pulls/11
This commit was merged in pull request #11.
This commit is contained in:
2026-09-01 13:12:31 +00:00
parent 5c6ab88710
commit adbf49b6b7
20 changed files with 1465 additions and 63 deletions
+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);
}
}