add settings crate

This commit is contained in:
2026-09-01 14:23:52 +07:00
parent 5c6ab88710
commit a0773d38aa
16 changed files with 531 additions and 58 deletions
Generated
+14 -1
View File
@@ -7764,6 +7764,18 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "settings"
version = "1.0.0"
dependencies = [
"anyhow",
"gpui",
"log",
"paths",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "sha1" name = "sha1"
version = "0.10.7" version = "0.10.7"
@@ -7874,6 +7886,7 @@ dependencies = [
"log", "log",
"paths", "paths",
"reqwest_client", "reqwest_client",
"settings",
"signed_state", "signed_state",
"tracing", "tracing",
"tracing-subscriber", "tracing-subscriber",
@@ -7927,7 +7940,6 @@ dependencies = [
"nostr", "nostr",
"nostr-connect", "nostr-connect",
"nostr-sdk", "nostr-sdk",
"paths",
"rustls", "rustls",
"signed_core", "signed_core",
"signed_git", "signed_git",
@@ -10774,6 +10786,7 @@ dependencies = [
"log", "log",
"nostr", "nostr",
"paths", "paths",
"settings",
"signed_core", "signed_core",
"signed_git", "signed_git",
"signed_state", "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" } gpui-base = { git = "https://github.com/longbridge/gpui-component" }
dock = { path = "crates/dock" } dock = { path = "crates/dock" }
settings = { path = "crates/settings" }
nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr = { git = "https://github.com/rust-nostr/nostr", features = ["nip59", "nip49", "nip44", "os-rng"] }
nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } 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 /// Open (or create) the LMDB database at `db_path` and build a client
/// configured for Signed, together with a fresh signer. /// configured for Signed, together with a fresh signer.
/// ///
/// The SDK manages its own internal tokio runtime; the returned client can be /// The SDK manages its own internal tokio runtime.
/// driven by GPUI's executors. /// the returned client can be driven by GPUI's executors.
#[cfg(not(target_arch = "wasm32"))] #[cfg(not(target_arch = "wasm32"))]
pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> { pub async fn new_backend(db_path: impl AsRef<Path>) -> Result<(Client, UniversalSigner)> {
let signer = UniversalSigner::new(Keys::generate()); let signer = UniversalSigner::new(Keys::generate());
-1
View File
@@ -24,4 +24,3 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies] [target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23" 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; use signed_nostr::new_backend;
pub use utils::shorten_pubkey; 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. /// Initialize the backend and stores, and install them as globals.
/// Call once at startup, before opening any window that uses the stores. /// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))] #[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 uses the `aws_lc_rs` provider by default; ignore if already installed.
rustls::crypto::aws_lc_rs::default_provider() rustls::crypto::aws_lc_rs::default_provider()
.install_default() .install_default()
@@ -53,10 +46,7 @@ pub fn init(db_path: impl AsRef<Path>, cx: &mut App) -> Entity<Backend> {
// `GitStore::global` still works. // `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), cx); GitStore::set_global(PathBuf::new(), cx);
LocalReposStore::set_global( LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
cx.new(|cx| LocalReposStore::new(default_scan_paths(), cx)),
cx,
);
entity entity
} }
+6 -15
View File
@@ -10,11 +10,6 @@ struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {} impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths. /// 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 { pub struct LocalReposStore {
/// The directories being scanned. /// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>, pub roots: Arc<Vec<PathBuf>>,
@@ -28,8 +23,7 @@ pub struct LocalReposStore {
} }
impl LocalReposStore { impl LocalReposStore {
/// Retrieve the global local-repositories store /// Retrieve the global local-repositories store.
/// (created at startup by [`crate::init`]).
pub fn global(cx: &App) -> Entity<Self> { pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone() cx.global::<GlobalLocalReposStore>().0.clone()
} }
@@ -38,8 +32,7 @@ impl LocalReposStore {
cx.set_global(GlobalLocalReposStore(entity)); cx.set_global(GlobalLocalReposStore(entity));
} }
/// Create a store scanning `roots` right away /// Create a store scanning `roots` right away.
/// (a no-op when the list is empty, e.g. on wasm).
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self { pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let mut store = Self { let mut store = Self {
roots: Arc::new(roots), roots: Arc::new(roots),
@@ -52,10 +45,9 @@ impl LocalReposStore {
store store
} }
/// Forget a repository that has just been published to NIP-34, so it /// Forget a repository that has just been published to NIP-34,
/// leaves the local list immediately. A later rescan re-discovers it /// so it leaves the local list immediately. A later rescan re-discovers it from disk,
/// from disk; the sidebar additionally hides published repositories by /// the sidebar additionally hides published repositories by identifier.
/// identifier.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) { pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new( self.repos = Arc::new(
self.repos self.repos
@@ -67,8 +59,7 @@ impl LocalReposStore {
cx.notify(); cx.notify();
} }
/// Re-run the scan. Requests that arrive while a scan is running are /// Re-run the scan.
/// folded into one follow-up scan; the results replace the list atomically.
pub fn rescan(&mut self, cx: &mut Context<Self>) { pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning { if self.scanning {
self.scan_dirty = true; self.scan_dirty = true;
+1
View File
@@ -8,6 +8,7 @@ publish.workspace = true
assets = { path = "../assets" } assets = { path = "../assets" }
dock = { workspace = true } dock = { workspace = true }
paths = { path = "../paths" } paths = { path = "../paths" }
settings = { path = "../settings" }
signed_core = { path = "../signed_core" } signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" } signed_git = { path = "../signed_git" }
signed_state = { path = "../signed_state" } 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::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, WindowExt}; use gpui_component::{ActiveTheme, Disableable, WindowExt};
use settings::SettingsStore;
use signed_state::Backend; use signed_state::Backend;
use super::RepoDetailView; use super::RepoDetailView;
@@ -49,7 +50,12 @@ pub fn open(
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
}); });
let state = cx.new(|_| InitRepoState::default()); 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); 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::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::input::{Input, InputState, Textarea};
use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex}; use gpui_component::{ActiveTheme, Disableable, IconName, WindowExt, h_flex};
use settings::SettingsStore;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::Backend; use signed_state::Backend;
@@ -26,6 +27,15 @@ pub struct CreateRepoState {
/// list) and falls back to the shared defaults when none are set. On /// 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. /// 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) { 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 name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Repository name"));
let desc_input = cx.new(|cx| { let desc_input = cx.new(|cx| {
TextareaState::new(window, 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") .placeholder("Short description")
}); });
let folder_input = cx.new(|cx| { let folder_input = cx.new(|cx| {
InputState::new(window, cx) InputState::new(window, cx).default_value(default_folder.to_string_lossy().to_string())
.default_value(paths::desktop_dir().to_string_lossy().to_string())
}); });
let relay_input = cx.new(|cx| { let relay_input = cx.new(|cx| {
InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com") InputState::new(window, cx).placeholder("wss://relay.example.com or relay.example.com")
}); });
let state = cx.new(|_| CreateRepoState::default()); 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); load_user_grasp_servers(grasp_state.clone(), window, cx);
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
const DESC: &str = "Publish a new repository to your grasp servers."; 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 name_input = name_input.clone();
let desc_input = desc_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 /// 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 /// 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) { fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mut App) {
let handle = window.window_handle(); let handle = window.window_handle();
let folder_input = folder_input.clone(); let folder_input = folder_input.clone();
let store = SettingsStore::global(cx);
let prompt = cx.prompt_for_paths(PathPromptOptions { let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false, 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(); let path = path.to_string_lossy().to_string();
cx.update_window(handle, |_, window, cx| { 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)); folder_input.update(cx, |input, cx| input.set_value(path, window, cx));
}) })
.ok(); .ok();
@@ -5,16 +5,10 @@ use gpui_component::form::{Field, field};
use gpui_component::input::{Input, InputState}; use gpui_component::input::{Input, InputState};
use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex}; use gpui_component::{ActiveTheme, IconName, Sizable, h_flex, v_flex};
use nostr::prelude::*; use nostr::prelude::*;
use settings::{DEFAULT_GRASP_SERVERS, GraspServersSettings};
use signed_core::filters; use signed_core::filters;
use signed_state::Backend; 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 /// State of the grasp-server section of a publish dialog, so async
/// results can be rendered. /// results can be rendered.
#[derive(Default)] #[derive(Default)]
@@ -30,11 +24,22 @@ pub struct GraspServersState {
impl GraspServersState { impl GraspServersState {
/// Defaults until the user's grasp list arrives; replaced by it when it lists any servers. /// 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 { Self {
loading_servers: true, loading_servers: true,
servers_enabled: false, servers_enabled: false,
grasp_servers: DEFAULT_GRASP_SERVERS grasp_servers: urls
.iter() .iter()
.filter_map(|url| RelayUrl::parse(url).ok()) .filter_map(|url| RelayUrl::parse(url).ok())
.collect(), .collect(),
+1
View File
@@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
assets = { path = "../crates/assets" } assets = { path = "../crates/assets" }
paths = { path = "../crates/paths" } paths = { path = "../crates/paths" }
settings = { path = "../crates/settings" }
signed_state = { path = "../crates/signed_state" } signed_state = { path = "../crates/signed_state" }
workspace = { path = "../crates/workspace" } workspace = { path = "../crates/workspace" }
+37 -12
View File
@@ -3,8 +3,9 @@ use std::sync::Arc;
use assets::Assets; use assets::Assets;
use dock::TAB_BAR_HEIGHT; use dock::TAB_BAR_HEIGHT;
use gpui::*; use gpui::*;
use gpui_component::{Theme, ThemeRegistry, theme}; use gpui_component::{Theme, ThemeMode, ThemeRegistry, theme};
use gpui_platform::application; use gpui_platform::application;
use settings::{AppearanceMode, SettingsStore};
fn main() { fn main() {
tracing_subscriber::fmt::init(); tracing_subscriber::fmt::init();
@@ -16,40 +17,64 @@ fn main() {
gpui_component::init(cx); gpui_component::init(cx);
theme::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) // 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); let registry = ThemeRegistry::global_mut(cx);
for (name, content) in Assets.themes() { for (name, content) in Assets.themes() {
if let Err(err) = registry.load_themes_from_str(&content) { if let Err(err) = registry.load_themes_from_str(&content) {
tracing::error!("Failed to load theme {name}: {err}"); tracing::error!("Failed to load theme {name}: {err}");
} }
} }
let light_theme = registry.themes().get("Signed Light").cloned(); let light_theme = registry
let dark_theme = registry.themes().get("Signed Dark").cloned(); .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); let theme = Theme::global_mut(cx);
theme.radius = px(2.); theme.radius = px(settings.theme.radius);
theme.radius_lg = px(6.); theme.radius_lg = px(settings.theme.radius_lg);
theme.focus_ring = false; theme.focus_ring = settings.theme.focus_ring;
theme.shadow = false; 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 { if let Some(light) = light_theme {
theme.light_theme = light; theme.light_theme = light;
} else { } 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 { if let Some(dark) = dark_theme {
theme.dark_theme = dark; theme.dark_theme = dark;
} else { } 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. // Connects relays and restores the session.
std::fs::create_dir_all(paths::nostr_dir()).ok(); 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. // Local git clone cache for browsing repository contents.
std::fs::create_dir_all(paths::repos_dir()).ok(); std::fs::create_dir_all(paths::repos_dir()).ok();