app

Local-first trade for farms and co-ops
git clone https://radroots.dev/git/app.git
Log | Files | Refs | README | LICENSE

commit 93c30050cdfa05ffa3496174539ad43622eb3724
parent 38e2fe77da3dbb0eb29d9a7dfda338149c48105f
Author: triesap <tyson@radroots.org>
Date:   Fri, 17 Apr 2026 19:25:51 +0000

state: add the radroots_app shell state crate

Diffstat:
MCargo.lock | 9+++++++++
MCargo.toml | 2++
Mcrates/launchers/desktop/Cargo.toml | 1+
Mcrates/launchers/desktop/src/window.rs | 92++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----------------------
Acrates/shared/state/Cargo.toml | 15+++++++++++++++
Acrates/shared/state/src/lib.rs | 442+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
6 files changed, 535 insertions(+), 26 deletions(-)

diff --git a/Cargo.lock b/Cargo.lock @@ -4607,6 +4607,7 @@ dependencies = [ "radroots_app_core", "radroots_app_i18n", "radroots_app_models", + "radroots_app_state", "radroots_app_ui", ] @@ -4644,6 +4645,14 @@ dependencies = [ ] [[package]] +name = "radroots_app_state" +version = "0.1.0" +dependencies = [ + "radroots_app_models", + "thiserror 2.0.18", +] + +[[package]] name = "radroots_app_ui" version = "0.1.0" dependencies = [ diff --git a/Cargo.toml b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/shared/i18n", "crates/shared/models", "crates/shared/sqlite", + "crates/shared/state", "crates/shared/ui", "crates/launchers/desktop", ] @@ -31,6 +32,7 @@ radroots_app_core = { path = "crates/shared/core", version = "0.1.0" } radroots_app_i18n = { path = "crates/shared/i18n", version = "0.1.0" } radroots_app_models = { path = "crates/shared/models", version = "0.1.0" } radroots_app_sqlite = { path = "crates/shared/sqlite", version = "0.1.0" } +radroots_app_state = { path = "crates/shared/state", version = "0.1.0" } radroots_app_ui = { path = "crates/shared/ui", version = "0.1.0" } rusqlite = { version = "0.32", features = ["bundled"] } serde = { version = "1.0", features = ["derive"] } diff --git a/crates/launchers/desktop/Cargo.toml b/crates/launchers/desktop/Cargo.toml @@ -14,6 +14,7 @@ gpui-component-assets.workspace = true radroots_app_core.workspace = true radroots_app_i18n.workspace = true radroots_app_models.workspace = true +radroots_app_state.workspace = true radroots_app_ui.workspace = true [lints] diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -7,6 +7,10 @@ use gpui_component::IconName; use radroots_app_core::AppRuntimeSnapshot; use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; +use radroots_app_state::{ + AppShellCommand, AppShellProjection, AppStateStore, InMemoryAppStateRepository, + SettingsPreference, +}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, IconSegmentButtonSpec, LabelValueRow, action_button, action_button_compact, action_icon_button, app_checkbox_field, app_shared_label_text, @@ -122,27 +126,42 @@ impl Render for HomeView { } pub struct SettingsWindowView { - selected_view: SettingsPanelViewKey, - general_allow_relay_connections: bool, - general_use_media_servers: bool, - general_use_nip05: bool, - general_launch_at_login: bool, + store: AppStateStore<InMemoryAppStateRepository>, } impl SettingsWindowView { pub fn new(initial_view: SettingsPanelViewKey) -> Self { Self { - selected_view: initial_view, - general_allow_relay_connections: true, - general_use_media_servers: true, - general_use_nip05: true, - general_launch_at_login: false, + store: AppStateStore::in_memory(AppShellProjection::for_settings(initial_view)), } } + fn selected_view(&self) -> SettingsPanelViewKey { + self.store.projection().settings.selected_section + } + fn select_view(&mut self, view: SettingsPanelViewKey, cx: &mut Context<Self>) { - if self.selected_view != view { - self.selected_view = view; + if self + .store + .apply_in_memory(AppShellCommand::select_settings_section(view)) + { + cx.notify(); + } + } + + fn set_settings_preference( + &mut self, + preference: SettingsPreference, + enabled: bool, + cx: &mut Context<Self>, + ) { + if self + .store + .apply_in_memory(AppShellCommand::SetSettingsPreference { + preference, + enabled, + }) + { cx.notify(); } } @@ -159,7 +178,7 @@ impl SettingsWindowView { app_shared_text(settings_panel_label_key(view)), navigation_icon, ), - self.selected_view == view, + self.selected_view() == view, cx.listener(move |this, _, _, cx| this.select_view(view, cx)), cx, ) @@ -488,6 +507,15 @@ impl SettingsWindowView { fn settings_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement { let section_label_width_px = 72.0; let form_max_width_px = 420.0; + let general_allow_relay_connections = self + .store + .projection() + .settings + .general + .allow_relay_connections; + let general_use_media_servers = self.store.projection().settings.general.use_media_servers; + let general_use_nip05 = self.store.projection().settings.general.use_nip05; + let general_launch_at_login = self.store.projection().settings.general.launch_at_login; div() .size_full() @@ -522,53 +550,65 @@ impl SettingsWindowView { .gap(px(16.0)) .child(self.settings_checkbox_row( "settings-allow-relay-connections", - self.general_allow_relay_connections, + general_allow_relay_connections, AppTextKey::SettingsGeneralAllowRelayConnections, None, None, None, cx.listener(|this, checked: &bool, _, cx| { - this.general_allow_relay_connections = *checked; - cx.notify(); + this.set_settings_preference( + SettingsPreference::AllowRelayConnections, + *checked, + cx, + ); }), cx, )) .child(self.settings_checkbox_row( "settings-use-media-servers", - self.general_use_media_servers, + general_use_media_servers, AppTextKey::SettingsGeneralUseMediaServers, Some("settings-manage-media-servers"), Some(AppTextKey::SettingsGeneralManageAction), None, cx.listener(|this, checked: &bool, _, cx| { - this.general_use_media_servers = *checked; - cx.notify(); + this.set_settings_preference( + SettingsPreference::UseMediaServers, + *checked, + cx, + ); }), cx, )) .child(self.settings_checkbox_row( "settings-use-nip05", - self.general_use_nip05, + general_use_nip05, AppTextKey::SettingsGeneralUseNip05, None, None, Some(AppTextKey::SettingsGeneralUseNip05Note), cx.listener(|this, checked: &bool, _, cx| { - this.general_use_nip05 = *checked; - cx.notify(); + this.set_settings_preference( + SettingsPreference::UseNip05, + *checked, + cx, + ); }), cx, )) .child(self.settings_checkbox_row( "settings-launch-at-login", - self.general_launch_at_login, + general_launch_at_login, AppTextKey::SettingsGeneralLaunchAtLogin, None, None, None, cx.listener(|this, checked: &bool, _, cx| { - this.general_launch_at_login = *checked; - cx.notify(); + this.set_settings_preference( + SettingsPreference::LaunchAtLogin, + *checked, + cx, + ); }), cx, )), @@ -629,7 +669,7 @@ impl SettingsWindowView { } fn settings_panel_content(&mut self, cx: &mut Context<Self>) -> AnyElement { - match self.selected_view { + match self.selected_view() { SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(), SettingsPanelViewKey::Settings => self.settings_panel(cx).into_any_element(), SettingsPanelViewKey::About => self.about_panel().into_any_element(), diff --git a/crates/shared/state/Cargo.toml b/crates/shared/state/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "radroots_app_state" +version.workspace = true +edition.workspace = true +authors.workspace = true +rust-version.workspace = true +license.workspace = true +publish = false + +[dependencies] +radroots_app_models.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/crates/shared/state/src/lib.rs b/crates/shared/state/src/lib.rs @@ -0,0 +1,442 @@ +#![forbid(unsafe_code)] + +use radroots_app_models::{AppMode, SettingsSection, ShellSection}; +use thiserror::Error; + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct GeneralSettingsProjection { + pub allow_relay_connections: bool, + pub use_media_servers: bool, + pub use_nip05: bool, + pub launch_at_login: bool, +} + +impl Default for GeneralSettingsProjection { + fn default() -> Self { + Self { + allow_relay_connections: true, + use_media_servers: true, + use_nip05: true, + launch_at_login: false, + } + } +} + +impl GeneralSettingsProjection { + fn set_preference(&mut self, preference: SettingsPreference, enabled: bool) { + match preference { + SettingsPreference::AllowRelayConnections => { + self.allow_relay_connections = enabled; + } + SettingsPreference::UseMediaServers => { + self.use_media_servers = enabled; + } + SettingsPreference::UseNip05 => { + self.use_nip05 = enabled; + } + SettingsPreference::LaunchAtLogin => { + self.launch_at_login = enabled; + } + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SettingsShellProjection { + pub selected_section: SettingsSection, + pub general: GeneralSettingsProjection, +} + +impl Default for SettingsShellProjection { + fn default() -> Self { + Self::new(SettingsSection::default()) + } +} + +impl SettingsShellProjection { + pub fn new(selected_section: SettingsSection) -> Self { + Self { + selected_section, + general: GeneralSettingsProjection::default(), + } + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct AppShellProjection { + pub app_mode: AppMode, + pub selected_section: ShellSection, + pub settings: SettingsShellProjection, +} + +impl Default for AppShellProjection { + fn default() -> Self { + Self::new(ShellSection::default()) + } +} + +impl AppShellProjection { + pub fn new(selected_section: ShellSection) -> Self { + let settings = match selected_section { + ShellSection::Settings(section) => SettingsShellProjection::new(section), + _ => SettingsShellProjection::default(), + }; + + Self { + app_mode: selected_section.mode(), + selected_section, + settings, + } + } + + pub fn for_settings(selected_section: SettingsSection) -> Self { + Self::new(ShellSection::Settings(selected_section)) + } + + fn select_section(&mut self, selected_section: ShellSection) { + self.app_mode = selected_section.mode(); + self.selected_section = selected_section; + + if let ShellSection::Settings(settings_section) = selected_section { + self.settings.selected_section = settings_section; + } + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum SettingsPreference { + AllowRelayConnections, + UseMediaServers, + UseNip05, + LaunchAtLogin, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AppShellCommand { + SelectSection(ShellSection), + SetSettingsPreference { + preference: SettingsPreference, + enabled: bool, + }, +} + +impl AppShellCommand { + pub const fn select_settings_section(section: SettingsSection) -> Self { + Self::SelectSection(ShellSection::Settings(section)) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum AppStateRepositoryError { + #[error("app state repository load failed: {message}")] + Load { message: String }, + #[error("app state repository save failed: {message}")] + Save { message: String }, +} + +impl AppStateRepositoryError { + pub fn load(message: impl Into<String>) -> Self { + Self::Load { + message: message.into(), + } + } + + pub fn save(message: impl Into<String>) -> Self { + Self::Save { + message: message.into(), + } + } +} + +pub trait AppStateRepository { + fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError>; + + fn save_shell_projection( + &mut self, + projection: &AppShellProjection, + ) -> Result<(), AppStateRepositoryError>; +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct InMemoryAppStateRepository { + projection: AppShellProjection, +} + +impl Default for InMemoryAppStateRepository { + fn default() -> Self { + Self::new(AppShellProjection::default()) + } +} + +impl InMemoryAppStateRepository { + pub fn new(projection: AppShellProjection) -> Self { + Self { projection } + } + + pub fn projection(&self) -> &AppShellProjection { + &self.projection + } + + pub fn overwrite(&mut self, projection: AppShellProjection) { + self.projection = projection; + } +} + +impl AppStateRepository for InMemoryAppStateRepository { + fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError> { + Ok(self.projection.clone()) + } + + fn save_shell_projection( + &mut self, + projection: &AppShellProjection, + ) -> Result<(), AppStateRepositoryError> { + self.projection = projection.clone(); + Ok(()) + } +} + +#[derive(Clone, Debug, Eq, Error, PartialEq)] +pub enum AppStateStoreError { + #[error(transparent)] + Repository(#[from] AppStateRepositoryError), +} + +#[derive(Clone, Debug)] +pub struct AppStateStore<R> { + repository: R, + projection: AppShellProjection, +} + +impl<R: AppStateRepository> AppStateStore<R> { + pub fn load(repository: R) -> Result<Self, AppStateStoreError> { + let projection = repository.load_shell_projection()?; + + Ok(Self { + repository, + projection, + }) + } + + pub fn projection(&self) -> &AppShellProjection { + &self.projection + } + + pub fn repository(&self) -> &R { + &self.repository + } + + pub fn apply(&mut self, command: AppShellCommand) -> Result<bool, AppStateStoreError> { + let mut next_projection = self.projection.clone(); + + if !apply_command(&mut next_projection, command) { + return Ok(false); + } + + self.repository.save_shell_projection(&next_projection)?; + self.projection = next_projection; + + Ok(true) + } +} + +impl AppStateStore<InMemoryAppStateRepository> { + pub fn in_memory(projection: AppShellProjection) -> Self { + Self { + repository: InMemoryAppStateRepository::new(projection.clone()), + projection, + } + } + + pub fn apply_in_memory(&mut self, command: AppShellCommand) -> bool { + let mut next_projection = self.projection.clone(); + + if !apply_command(&mut next_projection, command) { + return false; + } + + self.repository.overwrite(next_projection.clone()); + self.projection = next_projection; + + true + } +} + +fn apply_command(projection: &mut AppShellProjection, command: AppShellCommand) -> bool { + let before = projection.clone(); + + match command { + AppShellCommand::SelectSection(selected_section) => { + projection.select_section(selected_section); + } + AppShellCommand::SetSettingsPreference { + preference, + enabled, + } => { + projection + .settings + .general + .set_preference(preference, enabled); + } + } + + *projection != before +} + +#[cfg(test)] +mod tests { + use super::{ + AppShellCommand, AppShellProjection, AppStateRepository, AppStateRepositoryError, + AppStateStore, AppStateStoreError, InMemoryAppStateRepository, SettingsPreference, + }; + use radroots_app_models::{AppMode, SettingsSection, ShellSection}; + + struct FailingRepository; + + impl AppStateRepository for FailingRepository { + fn load_shell_projection(&self) -> Result<AppShellProjection, AppStateRepositoryError> { + Ok(AppShellProjection::default()) + } + + fn save_shell_projection( + &mut self, + _: &AppShellProjection, + ) -> Result<(), AppStateRepositoryError> { + Err(AppStateRepositoryError::save("disk unavailable")) + } + } + + #[test] + fn default_projection_starts_on_farmer_home() { + let projection = AppShellProjection::default(); + + assert_eq!(projection.app_mode, AppMode::Farmer); + assert_eq!(projection.selected_section, ShellSection::Home); + assert_eq!( + projection.settings.selected_section, + SettingsSection::Account + ); + assert!(projection.settings.general.allow_relay_connections); + assert!(projection.settings.general.use_media_servers); + assert!(projection.settings.general.use_nip05); + assert!(!projection.settings.general.launch_at_login); + } + + #[test] + fn load_uses_repository_projection() { + let repository = InMemoryAppStateRepository::new(AppShellProjection::for_settings( + SettingsSection::About, + )); + let store = AppStateStore::load(repository).expect("in-memory repository should load"); + + assert_eq!(store.projection().app_mode, AppMode::Farmer); + assert_eq!( + store.projection().selected_section, + ShellSection::Settings(SettingsSection::About) + ); + assert_eq!( + store.projection().settings.selected_section, + SettingsSection::About + ); + } + + #[test] + fn select_settings_section_updates_projection_and_repository() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + let changed = store.apply(AppShellCommand::select_settings_section( + SettingsSection::Settings, + )); + + assert_eq!(changed, Ok(true)); + assert_eq!(store.projection().app_mode, AppMode::Farmer); + assert_eq!( + store.projection().selected_section, + ShellSection::Settings(SettingsSection::Settings) + ); + assert_eq!( + store.projection().settings.selected_section, + SettingsSection::Settings + ); + assert_eq!( + store.repository().projection().selected_section, + ShellSection::Settings(SettingsSection::Settings) + ); + } + + #[test] + fn settings_preference_command_is_a_noop_when_value_is_unchanged() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + let changed = store.apply(AppShellCommand::SetSettingsPreference { + preference: SettingsPreference::UseNip05, + enabled: true, + }); + + assert_eq!(changed, Ok(false)); + assert!(store.projection().settings.general.use_nip05); + } + + #[test] + fn settings_preference_command_updates_projection_and_repository() { + let mut store = AppStateStore::load(InMemoryAppStateRepository::default()) + .expect("in-memory repository should load"); + + let changed = store.apply(AppShellCommand::SetSettingsPreference { + preference: SettingsPreference::LaunchAtLogin, + enabled: true, + }); + + assert_eq!(changed, Ok(true)); + assert!(store.projection().settings.general.launch_at_login); + assert!( + store + .repository() + .projection() + .settings + .general + .launch_at_login + ); + } + + #[test] + fn repository_errors_bubble_out_of_the_store() { + let mut store = + AppStateStore::load(FailingRepository).expect("failing repository should still load"); + + let error = store + .apply(AppShellCommand::select_settings_section( + SettingsSection::About, + )) + .expect_err("save should fail"); + + assert_eq!( + error, + AppStateStoreError::Repository(AppStateRepositoryError::save("disk unavailable")) + ); + } + + #[test] + fn in_memory_store_construction_and_updates_are_infallible() { + let mut store = + AppStateStore::in_memory(AppShellProjection::for_settings(SettingsSection::Account)); + + let changed = store.apply_in_memory(AppShellCommand::SetSettingsPreference { + preference: SettingsPreference::AllowRelayConnections, + enabled: false, + }); + + assert!(changed); + assert!(!store.projection().settings.general.allow_relay_connections); + assert!( + !store + .repository() + .projection() + .settings + .general + .allow_relay_connections + ); + } +}