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:
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
+ );
+ }
+}