app

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

commit a8cca05d2e7782780dea9c85f4add0424debbf97
parent 68132c54581d5fab068714f91d206a40a4635fe8
Author: triesap <tyson@radroots.org>
Date:   Sat, 18 Apr 2026 03:51:56 +0000

app: route startup through account surfaces

Diffstat:
Mcrates/launchers/desktop/src/accounts.rs | 25+++++++++++++++++++++++--
Mcrates/launchers/desktop/src/app.rs | 173++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------
Mcrates/launchers/desktop/src/menus.rs | 36++++++------------------------------
Mcrates/launchers/desktop/src/runtime.rs | 108+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/launchers/desktop/src/window.rs | 502+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------
5 files changed, 651 insertions(+), 193 deletions(-)

diff --git a/crates/launchers/desktop/src/accounts.rs b/crates/launchers/desktop/src/accounts.rs @@ -5,8 +5,9 @@ use std::{ use radroots_app_core::AppSharedAccountsPaths; use radroots_app_models::{ - AccountSummary, AppIdentityProjection, FarmerActivationProjection, IdentityBlockedReason, - SelectedAccountProjection, SelectedSurfaceProjection, + AccountSummary, AccountSurfaceActivationProjection, ActiveSurface, AppIdentityProjection, + FarmerActivationProjection, IdentityBlockedReason, SelectedAccountProjection, + SelectedSurfaceProjection, }; use radroots_app_sqlite::{AppSqliteError, AppSqliteStore}; use radroots_identity::{IdentityError, RadrootsIdentity, RadrootsIdentityId}; @@ -117,6 +118,26 @@ pub fn select_local_account( Ok(identity_projection_from_manager(manager, sqlite_store)?) } +pub fn select_active_surface( + manager: &RadrootsNostrAccountsManager, + sqlite_store: &AppSqliteStore, + active_surface: ActiveSurface, +) -> Result<AppIdentityProjection, DesktopAccountsCommandError> { + let Some(selected_account) = manager.selected_account()? else { + return Ok(identity_projection_from_manager(manager, sqlite_store)?); + }; + let selected_projection = + selected_account_projection_from_record(&selected_account, sqlite_store)?; + let activation = AccountSurfaceActivationProjection::new( + selected_projection.account.account_id.clone(), + SelectedSurfaceProjection::new(active_surface), + selected_projection.farmer_activation.clone(), + ); + + sqlite_store.save_surface_activation(&activation)?; + Ok(identity_projection_from_manager(manager, sqlite_store)?) +} + pub fn remove_selected_local_key( manager: &RadrootsNostrAccountsManager, sqlite_store: &AppSqliteStore, diff --git a/crates/launchers/desktop/src/app.rs b/crates/launchers/desktop/src/app.rs @@ -1,16 +1,18 @@ -use gpui::{Application, Bounds, WindowBounds, WindowOptions, px, size}; +use gpui::Application; use radroots_app_core::{ APP_PROJECTION_SOURCE, AppBuildIdentity, AppRuntimeConfig, AppRuntimeConfigError, AppRuntimeSnapshot, bootstrap_logging, install_panic_hook, launch_startup_event, }; use radroots_app_i18n::select_locale_from_host; -use radroots_app_ui::APP_UI_THEME; use thiserror::Error; use tracing::{error, info}; use crate::menus::install_native_app_menu; use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary}; -use crate::window::{home_titlebar_options, open_home_window}; +use crate::window::{ + PrimaryWindowTarget, SettingsPanelViewKey, home_window_options, open_home_window, + open_settings_window, primary_window_target, settings_window_options, +}; #[derive(Debug, Error)] pub enum AppLaunchError { @@ -28,7 +30,9 @@ pub fn launch() -> Result<(), AppLaunchError> { install_panic_hook(); let runtime = DesktopAppRuntime::bootstrap(); - emit_runtime_events(&snapshot, &runtime.summary()); + let runtime_summary = runtime.summary(); + emit_runtime_events(&snapshot, &runtime_summary); + let launch_target = primary_window_target(&runtime_summary); let app = Application::new().with_assets(gpui_component_assets::Assets); @@ -46,36 +50,39 @@ pub fn launch() -> Result<(), AppLaunchError> { let snapshot = snapshot.clone(); let runtime = runtime.clone(); - let home_bounds = Bounds::centered( - None, - size( - px(APP_UI_THEME.windows.home_min_width_px), - px(APP_UI_THEME.windows.home_min_height_px), - ), - cx, - ); + let launch_target = launch_target; + let mut primary_window_options = match launch_target { + PrimaryWindowTarget::Home => home_window_options(cx), + PrimaryWindowTarget::SettingsAccount => settings_window_options(cx), + }; + primary_window_options.app_id = Some(snapshot.host.app_identifier.clone()); cx.spawn(async move |cx| { - if let Err(error) = cx.open_window( - WindowOptions { - app_id: Some(snapshot.host.app_identifier.clone()), - window_bounds: Some(WindowBounds::Windowed(home_bounds)), - window_min_size: Some(size( - px(APP_UI_THEME.windows.home_min_width_px), - px(APP_UI_THEME.windows.home_min_height_px), - )), - titlebar: Some(home_titlebar_options()), - ..Default::default() - }, - |window, cx| { - window.activate_window(); - open_home_window(window, cx, runtime.clone()) - }, - ) { + let open_result = match launch_target { + PrimaryWindowTarget::Home => { + cx.open_window(primary_window_options, |window, cx| { + window.activate_window(); + open_home_window(window, cx, runtime.clone()) + }) + } + PrimaryWindowTarget::SettingsAccount => { + cx.open_window(primary_window_options, |window, cx| { + window.activate_window(); + open_settings_window( + window, + cx, + runtime.clone(), + SettingsPanelViewKey::Account, + ) + }) + } + }; + + if let Err(error) = open_result { error!( target: "window", - event = "window.home_open_failed", + event = "window.primary_open_failed", error = %error, - "failed to open home window" + "failed to open primary window" ); let _ = cx.update(|cx| cx.quit()); return; @@ -83,9 +90,9 @@ pub fn launch() -> Result<(), AppLaunchError> { info!( target: "window", - event = "window.home_opened", + event = "window.primary_opened", app_id = %snapshot.host.app_identifier, - "home window opened" + "primary window opened" ); if let Err(error) = cx.update(|cx| cx.activate(true)) { @@ -164,6 +171,7 @@ mod tests { use crate::runtime::DesktopAppRuntimeSummary; use super::emit_runtime_events; + use crate::window::{HomeStage, PrimaryWindowTarget, home_stage, primary_window_target}; #[derive(Clone, Debug, Eq, PartialEq)] struct CapturedEvent { @@ -279,4 +287,105 @@ mod tests { Some("desktop runtime degraded") ); } + + #[test] + fn blocked_and_setup_runtime_target_the_settings_account_window() { + let blocked = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Blocked, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + let setup = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::SetupRequired, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + + assert_eq!( + primary_window_target(&blocked), + PrimaryWindowTarget::SettingsAccount + ); + assert_eq!( + primary_window_target(&setup), + PrimaryWindowTarget::SettingsAccount + ); + } + + #[test] + fn ready_runtime_targets_the_home_window() { + let personal = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Personal, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + let farmer = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Farmer, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + + assert_eq!(primary_window_target(&personal), PrimaryWindowTarget::Home); + assert_eq!(primary_window_target(&farmer), PrimaryWindowTarget::Home); + } + + #[test] + fn degraded_runtime_targets_the_settings_account_window() { + let degraded = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Personal, + today_projection: TodayAgendaProjection::default(), + startup_issue: Some("runtime unavailable".to_owned()), + }; + + assert_eq!( + primary_window_target(&degraded), + PrimaryWindowTarget::SettingsAccount + ); + } + + #[test] + fn home_stage_tracks_setup_personal_and_farmer_states() { + let setup = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::SetupRequired, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + let personal = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Personal, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + let farmer = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Farmer, + today_projection: TodayAgendaProjection::default(), + startup_issue: None, + }; + let blocked = DesktopAppRuntimeSummary { + shell_projection: AppShellProjection::default(), + settings_account_projection: SettingsAccountProjection::default(), + startup_gate: AppStartupGate::Farmer, + today_projection: TodayAgendaProjection::default(), + startup_issue: Some("runtime unavailable".to_owned()), + }; + + assert_eq!(home_stage(&setup), HomeStage::Setup); + assert_eq!(home_stage(&personal), HomeStage::PersonalHolding); + assert_eq!(home_stage(&farmer), HomeStage::FarmerWorkspace); + assert_eq!(home_stage(&blocked), HomeStage::Setup); + } } diff --git a/crates/launchers/desktop/src/menus.rs b/crates/launchers/desktop/src/menus.rs @@ -1,13 +1,9 @@ -use gpui::{ - App, Bounds, KeyBinding, Menu, MenuItem, SystemMenuType, WindowBackgroundAppearance, - WindowBounds, WindowOptions, actions, px, size, -}; +use gpui::{App, KeyBinding, Menu, MenuItem, SystemMenuType, actions}; use radroots_app_i18n::{AppTextKey, app_text}; -use radroots_app_ui::APP_UI_THEME; use crate::{ runtime::DesktopAppRuntime, - window::{SettingsPanelViewKey, open_settings_window, settings_titlebar_options}, + window::{SettingsPanelViewKey, open_settings_window, settings_window_options}, }; actions!(radroots_app, [OpenAboutWindow, QuitApp]); @@ -18,30 +14,10 @@ const fn about_menu_settings_view() -> SettingsPanelViewKey { pub fn install_native_app_menu(runtime: DesktopAppRuntime, cx: &mut App) { cx.on_action(move |_: &OpenAboutWindow, cx| { - let bounds = Bounds::centered( - None, - size( - px(APP_UI_THEME.windows.settings_width_px), - px(APP_UI_THEME.windows.settings_height_px), - ), - cx, - ); - - cx.open_window( - WindowOptions { - window_bounds: Some(WindowBounds::Windowed(bounds)), - window_min_size: Some(size( - px(APP_UI_THEME.windows.settings_width_px), - px(APP_UI_THEME.windows.settings_height_px), - )), - titlebar: Some(settings_titlebar_options()), - window_background: WindowBackgroundAppearance::Transparent, - ..Default::default() - }, - |window, cx| { - open_settings_window(window, cx, runtime.clone(), about_menu_settings_view()) - }, - ) + let options = settings_window_options(cx); + cx.open_window(options, |window, cx| { + open_settings_window(window, cx, runtime.clone(), about_menu_settings_view()) + }) .expect("settings window should open"); }); cx.on_action(|_: &QuitApp, cx| cx.quit()); diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -3,7 +3,7 @@ use std::sync::{Arc, Mutex, MutexGuard, PoisonError}; use radroots_app_core::{AppDesktopRuntimePaths, AppRuntimePathsError, AppSharedAccountsPaths}; use radroots_app_models::{ - AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection, + ActiveSurface, AppActivityContext, AppActivityKind, AppStartupGate, SettingsAccountProjection, SettingsPreference, SettingsSection, TodayAgendaProjection, }; use radroots_app_sqlite::{ @@ -20,7 +20,8 @@ use tracing::error; use crate::accounts::{ DesktopAccountsBootstrapError, DesktopAccountsCommandError, DesktopLocalIdentityImportRequest, bootstrap_desktop_accounts, generate_local_account, import_local_account, - remove_selected_local_key, reset_local_device_state, select_local_account, + remove_selected_local_key, reset_local_device_state, select_active_surface, + select_local_account, }; const APP_DATABASE_FILE_NAME: &str = "app.sqlite3"; @@ -112,6 +113,13 @@ impl DesktopAppRuntime { self.lock_state_mut().select_local_account(account_id) } + pub fn select_active_surface( + &self, + active_surface: ActiveSurface, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + self.lock_state_mut().select_active_surface(active_surface) + } + pub fn remove_selected_local_key(&self) -> Result<bool, DesktopAppRuntimeCommandError> { self.lock_state_mut().remove_selected_local_key() } @@ -286,6 +294,19 @@ impl DesktopAppRuntimeState { Ok(self.replace_identity_projection(projection)) } + fn select_active_surface( + &mut self, + active_surface: ActiveSurface, + ) -> Result<bool, DesktopAppRuntimeCommandError> { + let projection = { + let accounts_manager = self.accounts_manager()?; + let sqlite_store = self.sqlite_store()?; + select_active_surface(accounts_manager, sqlite_store, active_surface)? + }; + + Ok(self.replace_identity_projection(projection)) + } + fn remove_selected_local_key(&mut self) -> Result<bool, DesktopAppRuntimeCommandError> { let projection = { let accounts_manager = self.accounts_manager()?; @@ -740,6 +761,89 @@ mod tests { } #[test] + fn runtime_select_active_surface_persists_selected_surface() { + let runtime = memory_runtime(); + + assert!( + runtime + .generate_local_account(Some("Farmer".to_owned())) + .expect("account should generate") + ); + let account_id = runtime + .summary() + .settings_account_projection + .selected_account + .as_ref() + .expect("selected account") + .account + .account_id + .clone(); + save_surface_activation(&runtime, account_id.as_str(), ActiveSurface::Farmer, true); + assert!( + runtime + .select_local_account(account_id.as_str()) + .expect("account should select") + ); + assert_eq!(runtime.summary().startup_gate, AppStartupGate::Farmer); + + assert!( + runtime + .select_active_surface(ActiveSurface::Personal) + .expect("surface should select") + ); + let personal_summary = runtime.summary(); + assert_eq!(personal_summary.startup_gate, AppStartupGate::Personal); + assert_eq!( + personal_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.active_surface()), + Some(ActiveSurface::Personal) + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_surface_activation(account_id.as_str()) + .expect("surface activation should load") + .expect("surface activation should exist") + .active_surface(), + ActiveSurface::Personal + ); + + assert!( + runtime + .select_active_surface(ActiveSurface::Farmer) + .expect("surface should reselect") + ); + let farmer_summary = runtime.summary(); + assert_eq!(farmer_summary.startup_gate, AppStartupGate::Farmer); + assert_eq!( + farmer_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| account.active_surface()), + Some(ActiveSurface::Farmer) + ); + assert_eq!( + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .load_surface_activation(account_id.as_str()) + .expect("surface activation should load") + .expect("surface activation should exist") + .active_surface(), + ActiveSurface::Farmer + ); + } + + #[test] fn runtime_reset_local_device_state_clears_store_file_and_projection() { let (runtime, paths) = file_backed_runtime("reset"); diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -1,9 +1,13 @@ use gpui::{ - AnyElement, App, AppContext, Context, InteractiveElement, IntoElement, ParentElement, Render, - SharedString, StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px, - relative, rgb, + AnyElement, App, AppContext, Bounds, ClickEvent, Context, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, + WindowBackgroundAppearance, WindowBounds, WindowOptions, div, prelude::FluentBuilder, px, + relative, rgb, size, transparent_black, +}; +use gpui_component::{ + IconName, Root, + button::{Button, ButtonCustomVariant, ButtonRounded, ButtonVariants}, }; -use gpui_component::{IconName, Root}; use radroots_app_i18n::AppTextKey; pub use radroots_app_models::SettingsSection as SettingsPanelViewKey; use radroots_app_models::{ @@ -13,9 +17,9 @@ use radroots_app_models::{ }; 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, - app_shared_text, app_window_shell, icon_segment_button, label_value_list, section_divider, - status_indicator, utility_title_row, + action_button_compact, action_icon_button, app_center_stage, app_checkbox_field, + app_shared_label_text, app_shared_text, app_window_shell, icon_segment_button, + label_value_list, section_divider, status_indicator, utility_title_row, }; use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary}; @@ -36,6 +40,90 @@ pub fn settings_titlebar_options() -> gpui::TitlebarOptions { } } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum PrimaryWindowTarget { + Home, + SettingsAccount, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum HomeStage { + Setup, + PersonalHolding, + FarmerWorkspace, +} + +pub fn primary_window_target(summary: &DesktopAppRuntimeSummary) -> PrimaryWindowTarget { + if summary.startup_issue.is_some() + || matches!( + summary.startup_gate, + AppStartupGate::Blocked | AppStartupGate::SetupRequired + ) + { + PrimaryWindowTarget::SettingsAccount + } else { + PrimaryWindowTarget::Home + } +} + +pub fn home_stage(summary: &DesktopAppRuntimeSummary) -> HomeStage { + if summary.startup_issue.is_some() + || matches!( + summary.startup_gate, + AppStartupGate::Blocked | AppStartupGate::SetupRequired + ) + { + HomeStage::Setup + } else if summary.startup_gate == AppStartupGate::Farmer { + HomeStage::FarmerWorkspace + } else { + HomeStage::PersonalHolding + } +} + +pub fn home_window_options(cx: &mut App) -> WindowOptions { + let bounds = Bounds::centered( + None, + size( + px(APP_UI_THEME.windows.home_min_width_px), + px(APP_UI_THEME.windows.home_min_height_px), + ), + cx, + ); + + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size( + px(APP_UI_THEME.windows.home_min_width_px), + px(APP_UI_THEME.windows.home_min_height_px), + )), + titlebar: Some(home_titlebar_options()), + ..Default::default() + } +} + +pub fn settings_window_options(cx: &mut App) -> WindowOptions { + let bounds = Bounds::centered( + None, + size( + px(APP_UI_THEME.windows.settings_width_px), + px(APP_UI_THEME.windows.settings_height_px), + ), + cx, + ); + + WindowOptions { + window_bounds: Some(WindowBounds::Windowed(bounds)), + window_min_size: Some(size( + px(APP_UI_THEME.windows.settings_width_px), + px(APP_UI_THEME.windows.settings_height_px), + )), + titlebar: Some(settings_titlebar_options()), + window_background: WindowBackgroundAppearance::Transparent, + ..Default::default() + } +} + pub fn open_home_window( window: &mut Window, cx: &mut App, @@ -71,76 +159,12 @@ impl HomeView { impl Render for HomeView { fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement { let runtime_summary = self.runtime.summary(); - let home_status = home_status_presentation(&runtime_summary); - - app_window_shell( - APP_UI_THEME.surfaces.window_background, - div() - .size_full() - .overflow_hidden() - .flex() - .child( - div() - .h_full() - .w(px(APP_UI_THEME.layout.home_sidebar_width_px)) - .bg(rgb(APP_UI_THEME.surfaces.card_background)) - .p(px(APP_UI_THEME.layout.home_window_padding_px)) - .flex() - .flex_col() - .justify_between() - .child( - div() - .flex() - .flex_col() - .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) - .child( - div() - .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0)) - .font_weight(gpui::FontWeight::BOLD) - .text_color(rgb(APP_UI_THEME.text.primary)) - .child(app_shared_text(AppTextKey::HomeTodayTitle)), - ) - .child(home_status_row(&home_status)), - ) - .child( - div().child( - div() - .text_size(px(APP_UI_THEME.typography.body_text_px)) - .line_height(relative(1.2)) - .text_color(rgb(APP_UI_THEME.text.secondary)) - .when_some( - runtime_summary.today_projection.farm.as_ref(), - |this, farm| this.child(farm.display_name.clone()), - ), - ), - ), - ) - .child( - div() - .h_full() - .w(px(APP_UI_THEME.layout.divider_thickness_px)) - .bg(rgb(APP_UI_THEME.surfaces.divider)), - ) - .child( - div() - .flex_1() - .h_full() - .bg(rgb(APP_UI_THEME.surfaces.window_background)) - .overflow_hidden() - .child( - div() - .size_full() - .p(px(APP_UI_THEME.layout.home_window_padding_px)) - .child( - div() - .id("home-today-scroll") - .size_full() - .overflow_y_scroll() - .child(home_view_content(&runtime_summary)), - ), - ), - ), - ) + match home_stage(&runtime_summary) { + HomeStage::FarmerWorkspace => farmer_home_shell(&runtime_summary).into_any_element(), + HomeStage::Setup | HomeStage::PersonalHolding => { + holding_home_shell(&runtime_summary).into_any_element() + } + } } } @@ -174,6 +198,89 @@ impl SettingsWindowView { } } + fn finish_account_runtime_change( + &mut self, + changed: bool, + previous_target: PrimaryWindowTarget, + cx: &mut Context<Self>, + ) { + if changed { + cx.refresh_windows(); + cx.notify(); + } + + if previous_target == PrimaryWindowTarget::SettingsAccount + && primary_window_target(&self.runtime.summary()) == PrimaryWindowTarget::Home + { + self.ensure_home_window_if_ready(cx); + } + } + + fn ensure_home_window_if_ready(&self, cx: &mut Context<Self>) { + if primary_window_target(&self.runtime.summary()) != PrimaryWindowTarget::Home { + return; + } + + if cx.windows().len() > 1 { + cx.refresh_windows(); + return; + } + + let runtime = self.runtime.clone(); + let options = home_window_options(cx); + let _ = cx.open_window(options, |window, cx| { + window.activate_window(); + open_home_window(window, cx, runtime.clone()) + }); + } + + fn generate_local_account(&mut self, cx: &mut Context<Self>) { + let previous_target = primary_window_target(&self.runtime.summary()); + let changed = self.runtime.generate_local_account(None).unwrap_or(false); + self.finish_account_runtime_change(changed, previous_target, cx); + } + + fn select_local_account(&mut self, account_id: &str, cx: &mut Context<Self>) { + let previous_target = primary_window_target(&self.runtime.summary()); + let changed = self + .runtime + .select_local_account(account_id) + .unwrap_or(false); + self.finish_account_runtime_change(changed, previous_target, cx); + } + + fn remove_selected_local_key(&mut self, cx: &mut Context<Self>) { + let previous_target = primary_window_target(&self.runtime.summary()); + let changed = self.runtime.remove_selected_local_key().unwrap_or(false); + self.finish_account_runtime_change(changed, previous_target, cx); + } + + fn open_selected_workspace(&mut self, cx: &mut Context<Self>) { + let runtime_summary = self.runtime.summary(); + let previous_target = primary_window_target(&runtime_summary); + let Some(target_surface) = runtime_summary + .settings_account_projection + .selected_account + .as_ref() + .map(|account| { + if account.farmer_activation.is_active() { + ActiveSurface::Farmer + } else { + ActiveSurface::Personal + } + }) + else { + return; + }; + + let changed = self + .runtime + .select_active_surface(target_surface) + .unwrap_or(false); + self.finish_account_runtime_change(changed, previous_target, cx); + self.ensure_home_window_if_ready(cx); + } + fn navigation_button( &mut self, view: SettingsPanelViewKey, @@ -192,7 +299,7 @@ impl SettingsWindowView { ) } - fn account_panel(&self, cx: &mut Context<Self>) -> impl IntoElement { + fn account_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement { let detail_text_px = APP_UI_THEME.typography.settings_account_detail_text_px; let runtime_summary = self.runtime.summary(); let account_projection = &runtime_summary.settings_account_projection; @@ -273,9 +380,14 @@ impl SettingsWindowView { .roster .iter() .map(|account| { + let account_id = account.account_id.clone(); settings_account_sidebar_row( account, selected_account_id, + cx.listener(move |this, _, _, cx| { + this.select_local_account(account_id.as_str(), cx); + }), + cx, ) }) .collect::<Vec<_>>(), @@ -310,7 +422,9 @@ impl SettingsWindowView { .child(action_button( "account-add", app_shared_text(AppTextKey::SettingsAccountAddAction), - |_, _, _| {}, + cx.listener(|this, _, _, cx| { + this.generate_local_account(cx); + }), cx, )) .child(action_icon_button( @@ -515,7 +629,9 @@ impl SettingsWindowView { app_shared_text( AppTextKey::SettingsAccountLogOutAction, ), - |_, _, _| {}, + cx.listener(|this, _, _, cx| { + this.remove_selected_local_key(cx); + }), cx, )), ) @@ -525,7 +641,9 @@ impl SettingsWindowView { app_shared_text( AppTextKey::SettingsAccountAdminConsoleAction, ), - |_, _, _| {}, + cx.listener(|this, _, _, cx| { + this.open_selected_workspace(cx); + }), cx, )), ), @@ -858,70 +976,85 @@ fn settings_account_activation_key(account: &SelectedAccountProjection) -> AppTe fn settings_account_sidebar_row( account: &AccountSummary, selected_account_id: Option<&str>, + on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, + cx: &App, ) -> AnyElement { let is_selected = selected_account_id .map(|account_id| account_id == account.account_id.as_str()) .unwrap_or(false); - - div() - .w_full() + let background = if is_selected { + APP_UI_THEME.surfaces.card_background + } else { + APP_UI_THEME.surfaces.chrome_background + }; + + Button::new(SharedString::from(account.account_id.clone())) + .custom( + ButtonCustomVariant::new(cx) + .color(rgb(background).into()) + .foreground(rgb(APP_UI_THEME.text.primary).into()) + .border(transparent_black()) + .hover(rgb(APP_UI_THEME.surfaces.card_background).into()) + .active(rgb(APP_UI_THEME.surfaces.card_background).into()), + ) + .rounded(ButtonRounded::Size(px(APP_UI_THEME + .layout + .settings_account_sidebar_button_corner_radius_px))) .h(px(APP_UI_THEME .layout .settings_account_sidebar_button_height_px)) - .bg(rgb(if is_selected { - APP_UI_THEME.surfaces.card_background - } else { - APP_UI_THEME.surfaces.chrome_background - })) - .rounded(px(APP_UI_THEME - .layout - .settings_account_sidebar_button_corner_radius_px)) - .p(px(APP_UI_THEME - .layout - .settings_account_sidebar_button_padding_px)) - .flex() - .flex_row() - .justify_start() - .items_center() - .gap(px(APP_UI_THEME - .layout - .settings_account_sidebar_button_gap_px)) + .w_full() + .on_click(on_click) .child( div() - .size(px(APP_UI_THEME - .layout - .settings_account_sidebar_avatar_size_px)) - .bg(rgb(APP_UI_THEME.surfaces.window_background)) - .rounded(px(APP_UI_THEME + .size_full() + .p(px(APP_UI_THEME .layout - .settings_account_sidebar_avatar_size_px - / 2.0)), - ) - .child( - div() - .min_w_0() + .settings_account_sidebar_button_padding_px)) .flex() - .flex_col() + .flex_row() + .justify_start() + .items_center() .gap(px(APP_UI_THEME .layout - .settings_account_identity_text_gap_px)) - .justify_center() + .settings_account_sidebar_button_gap_px)) .child( div() - .text_size(px(APP_UI_THEME - .typography - .settings_account_identity_text_px)) - .font_weight(gpui::FontWeight::MEDIUM) - .text_color(rgb(APP_UI_THEME.text.primary)) - .child(settings_account_display_name(account)), + .size(px(APP_UI_THEME + .layout + .settings_account_sidebar_avatar_size_px)) + .bg(rgb(APP_UI_THEME.surfaces.window_background)) + .rounded(px(APP_UI_THEME + .layout + .settings_account_sidebar_avatar_size_px + / 2.0)), ) .child( div() - .text_size(px(APP_UI_THEME - .typography - .settings_account_identity_text_px)) - .text_color(rgb(APP_UI_THEME.text.secondary)) - .child(account.npub.clone()), + .min_w_0() + .flex() + .flex_col() + .gap(px(APP_UI_THEME + .layout + .settings_account_identity_text_gap_px)) + .justify_center() + .child( + div() + .text_size(px(APP_UI_THEME + .typography + .settings_account_identity_text_px)) + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(settings_account_display_name(account)), + ) + .child( + div() + .text_size(px(APP_UI_THEME + .typography + .settings_account_identity_text_px)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .child(account.npub.clone()), + ), ), ) .into_any_element() @@ -949,6 +1082,121 @@ struct HomeStatusPresentation { label_key: AppTextKey, } +fn farmer_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { + let home_status = home_status_presentation(runtime); + + app_window_shell( + APP_UI_THEME.surfaces.window_background, + div() + .size_full() + .overflow_hidden() + .flex() + .child( + div() + .h_full() + .w(px(APP_UI_THEME.layout.home_sidebar_width_px)) + .bg(rgb(APP_UI_THEME.surfaces.card_background)) + .p(px(APP_UI_THEME.layout.home_window_padding_px)) + .flex() + .flex_col() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px * 2.0)) + .font_weight(gpui::FontWeight::BOLD) + .text_color(rgb(APP_UI_THEME.text.primary)) + .child(app_shared_text(AppTextKey::HomeTodayTitle)), + ) + .child(home_status_row(&home_status)), + ) + .child( + div().child( + div() + .text_size(px(APP_UI_THEME.typography.body_text_px)) + .line_height(relative(1.2)) + .text_color(rgb(APP_UI_THEME.text.secondary)) + .when_some(runtime.today_projection.farm.as_ref(), |this, farm| { + this.child(farm.display_name.clone()) + }), + ), + ), + ) + .child( + div() + .h_full() + .w(px(APP_UI_THEME.layout.divider_thickness_px)) + .bg(rgb(APP_UI_THEME.surfaces.divider)), + ) + .child( + div() + .flex_1() + .h_full() + .bg(rgb(APP_UI_THEME.surfaces.window_background)) + .overflow_hidden() + .child( + div() + .size_full() + .p(px(APP_UI_THEME.layout.home_window_padding_px)) + .child( + div() + .id("home-today-scroll") + .size_full() + .overflow_y_scroll() + .child(home_view_content(runtime)), + ), + ), + ), + ) +} + +fn holding_home_shell(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { + let home_status = home_status_presentation(runtime); + let (title_key, body_key) = match home_stage(runtime) { + HomeStage::Setup => ( + AppTextKey::HomeTodayEmptySetupTitle, + AppTextKey::HomeTodayEmptySetupBody, + ), + HomeStage::PersonalHolding => ( + AppTextKey::HomeTodayEmptyNoFarmTitle, + AppTextKey::HomeTodayEmptyNoFarmBody, + ), + HomeStage::FarmerWorkspace => ( + AppTextKey::HomeTodayEmptyQuietTitle, + AppTextKey::HomeTodayEmptyQuietBody, + ), + }; + let mut sections = vec![home_empty_state_card(title_key, body_key).into_any_element()]; + + if let Some(issue) = runtime.startup_issue.as_ref() { + sections.push( + home_card( + app_shared_text(AppTextKey::MetadataStartupIssue), + home_body_text(issue.clone()), + ) + .into_any_element(), + ); + } + + app_window_shell( + APP_UI_THEME.surfaces.window_background, + app_center_stage( + div() + .w_full() + .max_w(px(APP_UI_THEME.layout.home_card_max_width_px)) + .flex() + .flex_col() + .gap(px(APP_UI_THEME.layout.home_stack_gap_px)) + .child(home_status_row(&home_status)) + .children(sections), + ), + ) +} + fn home_view_content(runtime: &DesktopAppRuntimeSummary) -> impl IntoElement { let projection = &runtime.today_projection; let home_status = home_status_presentation(runtime);