add settings crate
This commit is contained in:
Generated
+14
-1
@@ -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",
|
||||||
|
|||||||
@@ -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" }
|
||||||
|
|||||||
@@ -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"] }
|
||||||
@@ -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::*;
|
||||||
@@ -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\""
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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" }
|
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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
@@ -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();
|
||||||
|
|||||||
Reference in New Issue
Block a user