app

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

commit 8a75d36a31d60d7e3e9147059118fb9b4e48ca4c
parent 2616ec4d16a612563dcedd36ed273fd5b38c2903
Author: triesap <tyson@radroots.org>
Date:   Mon, 20 Apr 2026 21:46:04 +0000

app: add about-panel sync conflict review actions

- thread selected-account conflict records through the desktop runtime summary
- render about-panel conflict cards with refresh and resolution actions
- localize conflict aggregate and resolution copy for the about surface
- cover sync resolution and refresh gating with runtime and window tests

Diffstat:
Mcrates/launchers/desktop/src/runtime.rs | 246+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mcrates/launchers/desktop/src/source_guards.rs | 8++++++++
Mcrates/launchers/desktop/src/window.rs | 384++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-------
Mcrates/shared/i18n/src/keys.rs | 19+++++++++++++++++++
Mcrates/shared/i18n/src/lib.rs | 37+++++++++++++++++++++++++++++++++++++
Mi18n/locales/en/messages.json | 21++++++++++++++++++++-
6 files changed, 676 insertions(+), 39 deletions(-)

diff --git a/crates/launchers/desktop/src/runtime.rs b/crates/launchers/desktop/src/runtime.rs @@ -107,6 +107,7 @@ impl DesktopAppRuntime { .map(|account| account.account.account_id.clone()), projection: state.state_store.sync_projection().clone(), pending_write_count: state.selected_account_pending_sync_write_count, + conflicts: state.selected_account_sync_conflicts.clone(), }; DesktopAppRuntimeSummary { @@ -496,6 +497,15 @@ impl DesktopAppRuntime { .attempt_sync(SyncTrigger::ManualRefresh) } + pub fn resolve_sync_conflict( + &self, + conflict_id: &str, + resolution: radroots_app_sync::SyncConflictResolutionStatus, + ) -> Result<bool, AppSqliteError> { + self.lock_state_mut() + .resolve_sync_conflict(conflict_id, resolution) + } + pub fn record_home_opened(&self) -> bool { self.record_activity(AppActivityKind::HomeOpened) } @@ -574,6 +584,7 @@ pub struct DesktopAppSyncStatusSummary { pub account_id: Option<String>, pub projection: AppSyncProjection, pub pending_write_count: usize, + pub conflicts: Vec<DesktopAppSyncConflictSummary>, } impl DesktopAppSyncStatusSummary { @@ -583,6 +594,12 @@ impl DesktopAppSyncStatusSummary { } #[derive(Clone, Debug, Eq, PartialEq)] +pub struct DesktopAppSyncConflictSummary { + pub conflict_id: String, + pub conflict: radroots_app_sync::SyncConflict, +} + +#[derive(Clone, Debug, Eq, PartialEq)] pub struct DesktopAppRuntimeMetadataSummary { pub snapshot: AppRuntimeSnapshot, pub data_root: Option<PathBuf>, @@ -667,6 +684,7 @@ struct DesktopSelectedAccountContext { struct DesktopSelectedAccountSyncContext { projection: AppSyncProjection, pending_write_count: usize, + conflicts: Vec<DesktopAppSyncConflictSummary>, } #[derive(Clone, Debug)] @@ -687,6 +705,7 @@ struct DesktopAppRuntimeState { sync_transport: Box<dyn AppSyncTransport + Send>, runtime_metadata: DesktopAppRuntimeMetadataSummary, selected_account_pending_sync_write_count: usize, + selected_account_sync_conflicts: Vec<DesktopAppSyncConflictSummary>, startup_issue: Option<String>, } @@ -717,6 +736,10 @@ impl fmt::Debug for DesktopAppRuntimeState { "selected_account_pending_sync_write_count", &self.selected_account_pending_sync_write_count, ) + .field( + "selected_account_sync_conflicts", + &self.selected_account_sync_conflicts, + ) .field("startup_issue", &self.startup_issue) .finish() } @@ -802,6 +825,7 @@ impl DesktopAppRuntimeState { selected_account_context.pack_day_projection, )); let pending_sync_write_count = selected_account_sync_context.pending_write_count; + let selected_account_sync_conflicts = selected_account_sync_context.conflicts; let _ = state_store.apply_in_memory(AppStateCommand::replace_sync_projection( selected_account_sync_context.projection, )); @@ -821,6 +845,7 @@ impl DesktopAppRuntimeState { database_schema_version, ), selected_account_pending_sync_write_count: pending_sync_write_count, + selected_account_sync_conflicts, startup_issue: None, }) } @@ -843,6 +868,7 @@ impl DesktopAppRuntimeState { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::unavailable(runtime_snapshot), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: Some(error.to_string()), } } @@ -1960,10 +1986,12 @@ impl DesktopAppRuntimeState { )); let pending_changed = self.selected_account_pending_sync_write_count != context.pending_write_count; + let conflicts_changed = self.selected_account_sync_conflicts != context.conflicts; self.selected_account_pending_sync_write_count = context.pending_write_count; + self.selected_account_sync_conflicts = context.conflicts.clone(); - projection_changed || pending_changed + projection_changed || pending_changed || conflicts_changed } fn refresh_selected_account_sync(&mut self) -> Result<bool, AppSqliteError> { @@ -1972,6 +2000,55 @@ impl DesktopAppRuntimeState { Ok(self.apply_selected_account_sync_context(&context)) } + fn resolve_sync_conflict( + &mut self, + conflict_id: &str, + resolution: radroots_app_sync::SyncConflictResolutionStatus, + ) -> Result<bool, AppSqliteError> { + let Some(sqlite_store) = self.sqlite_store.as_ref() else { + return Ok(false); + }; + let Some(selected_account) = self + .state_store + .identity_projection() + .selected_account + .as_ref() + else { + return Ok(false); + }; + let account_id = selected_account.account.account_id.as_str(); + let stored_conflicts = sqlite_store.load_sync_conflicts(account_id)?; + let Some(stored_conflict) = stored_conflicts + .iter() + .find(|stored| stored.conflict_id == conflict_id) + else { + return Ok(false); + }; + if !stored_conflict.conflict.is_unresolved() { + return Ok(false); + } + if matches!( + (stored_conflict.conflict.severity, resolution,), + ( + SyncConflictSeverity::Blocking, + radroots_app_sync::SyncConflictResolutionStatus::Dismissed, + ) + ) { + return Ok(false); + } + + if !sqlite_store.resolve_sync_conflict( + account_id, + conflict_id, + resolution, + current_utc_timestamp().as_str(), + )? { + return Ok(false); + } + + self.refresh_selected_account_sync() + } + fn attempt_sync(&mut self, trigger: SyncTrigger) -> Result<bool, AppSqliteError> { let Some(prepared) = self.prepare_sync_request(trigger)? else { return Ok(false); @@ -2755,16 +2832,23 @@ fn load_selected_account_sync_context( }; let account_id = selected_account.account.account_id.as_str(); let checkpoint = sqlite_store.load_sync_checkpoint(account_id)?; - let conflicts = sqlite_store - .load_sync_conflicts(account_id)? - .into_iter() - .map(|stored| stored.conflict) + let stored_conflicts = sqlite_store.load_sync_conflicts(account_id)?; + let conflicts = stored_conflicts + .iter() + .map(|stored| stored.conflict.clone()) .collect::<Vec<_>>(); let pending_write_count = sqlite_store.load_pending_sync_operations(account_id)?.len(); Ok(DesktopSelectedAccountSyncContext { projection: derive_sync_projection(&checkpoint, &conflicts), pending_write_count, + conflicts: stored_conflicts + .into_iter() + .map(|stored| DesktopAppSyncConflictSummary { + conflict_id: stored.conflict_id, + conflict: stored.conflict, + }) + .collect(), }) } @@ -3263,6 +3347,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); let cloned_runtime = runtime.clone(); @@ -3316,6 +3401,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); let cloned_runtime = runtime.clone(); @@ -3714,6 +3800,146 @@ mod tests { } #[test] + fn runtime_resolving_a_blocking_conflict_refreshes_sync_summary() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + + let conflict_id = runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .record_sync_conflict( + account_id.as_str(), + &SyncConflict { + aggregate: SyncAggregateRef::Farm(farm_id), + kind: SyncConflictKind::RevisionMismatch, + severity: SyncConflictSeverity::Blocking, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: "{\"farm\":\"local\"}".to_owned(), + remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), + detected_at: "2026-04-20T20:05:00Z".to_owned(), + resolved_at: None, + }, + ) + .expect("blocking conflict should save"); + assert!( + runtime + .lock_state_mut() + .refresh_selected_account_sync() + .expect("sync status should refresh") + ); + + assert!( + runtime + .resolve_sync_conflict( + conflict_id.as_str(), + SyncConflictResolutionStatus::AcceptedLocal, + ) + .expect("conflict resolution should succeed") + ); + + let summary = runtime.summary(); + + assert_eq!( + summary + .sync_status + .projection + .conflict_status + .unresolved_count, + 0 + ); + assert_eq!( + summary + .sync_status + .projection + .conflict_status + .blocking_count, + 0 + ); + assert_eq!(summary.sync_status.conflicts.len(), 1); + assert_eq!( + summary.sync_status.conflicts[0].conflict.resolution, + SyncConflictResolutionStatus::AcceptedLocal + ); + assert!( + summary.sync_status.conflicts[0] + .conflict + .resolved_at + .as_deref() + .is_some() + ); + } + + #[test] + fn runtime_review_required_conflicts_do_not_block_manual_refresh() { + let runtime = memory_runtime(); + let (account_id, farm_id) = provision_ready_farmer_account(&runtime); + + assert!( + runtime + .open_new_product_editor() + .expect("new product editor should open") + ); + + runtime + .lock_state() + .sqlite_store + .as_ref() + .expect("sqlite store") + .record_sync_conflict( + account_id.as_str(), + &SyncConflict { + aggregate: SyncAggregateRef::Farm(farm_id), + kind: SyncConflictKind::RemoteValidationReject, + severity: SyncConflictSeverity::ReviewRequired, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: "{\"farm\":\"local\"}".to_owned(), + remote_payload_json: Some("{\"farm\":\"remote\"}".to_owned()), + detected_at: "2026-04-20T20:10:00Z".to_owned(), + resolved_at: None, + }, + ) + .expect("review-required conflict should save"); + assert!( + runtime + .lock_state_mut() + .refresh_selected_account_sync() + .expect("sync status should refresh") + ); + + let recorded = install_recorded_sync_transport( + &runtime, + RecordedAppSyncTransport::succeed(AppSyncResult { + run_status: AppSyncRunStatus::Succeeded, + checkpoint: SyncCheckpointStatus::current( + Some("2026-04-20T20:10:05Z".to_owned()), + "2026-04-20T20:10:08Z", + Some("cursor-review-required".to_owned()), + ), + pushed_operation_count: 1, + pulled_record_count: 0, + conflicts: Vec::new(), + }), + ); + + assert!( + runtime + .sync_on_manual_refresh() + .expect("manual refresh should succeed") + ); + + let recorded = recorded.lock().expect("recorded transport"); + let request = recorded + .last_request() + .cloned() + .expect("manual refresh request should record"); + + assert_eq!(recorded.call_count(), 1); + assert_eq!(request.trigger, SyncTrigger::ManualRefresh); + } + + #[test] fn runtime_summary_surfaces_runtime_metadata_from_bootstrap() { let (runtime, paths) = bootstrapped_runtime("runtime_metadata"); let summary = runtime.summary(); @@ -3759,6 +3985,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -3789,6 +4016,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -3888,6 +4116,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); let cloned_runtime = runtime.clone(); @@ -3987,6 +4216,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -4039,6 +4269,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -4075,6 +4306,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -4109,6 +4341,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -6314,6 +6547,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }); @@ -6348,6 +6582,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }) } @@ -6380,6 +6615,7 @@ mod tests { sync_transport: default_sync_transport(), runtime_metadata: DesktopAppRuntimeMetadataSummary::default(), selected_account_pending_sync_write_count: 0, + selected_account_sync_conflicts: Vec::new(), startup_issue: None, }), paths, diff --git a/crates/launchers/desktop/src/source_guards.rs b/crates/launchers/desktop/src/source_guards.rs @@ -208,17 +208,25 @@ const ALLOWED_WINDOW_LITERALS: &[&str] = &[ "settings-nav-farm", "settings-nav-settings", "settings-panel-scroll", + "settings-about-conflict-action", + "settings-about-refresh-sync", + "settings-about-refresh-sync-disabled", "settings-remove-blackout-period", "settings-remove-fulfillment-window", "settings-use-media-servers", "settings-use-nip05", "settings.farm.load_failed", "settings.farm.save_failed", + "settings.about.sync_refresh_failed", + "settings.about.conflict_resolution_failed", + "failed to refresh sync from the about panel", + "failed to resolve sync conflict from the about panel", "sign_event:kind:1, switch_relays", "startup-title-radroots", "startup-title-starting", "wss://relay.radroots.example", "{currency_code} {dollars}.{cents:02}", + "{}: {}", "{} {} {}.", "{quantity} {unit_label}", "{} {}", diff --git a/crates/launchers/desktop/src/window.rs b/crates/launchers/desktop/src/window.rs @@ -35,7 +35,10 @@ use radroots_app_sqlite::derive_farm_rules_readiness; use radroots_app_state::{ FarmSetupFlowStage, FarmWorkspaceStatus, HomeRoute, derive_product_publish_blockers, }; -use radroots_app_sync::{AppSyncRunStatus, SyncCheckpointState}; +use radroots_app_sync::{ + AppSyncRunStatus, SyncAggregateRef, SyncCheckpointState, SyncConflict, SyncConflictKind, + SyncConflictResolutionStatus, SyncConflictSeverity, +}; use radroots_app_ui::{ APP_UI_THEME, AppCheckboxFieldSpec, AppFormFieldSpec, AppSegmentButtonIconSpec as IconSegmentButtonSpec, LabelValueRow, app_button_card, @@ -59,7 +62,10 @@ use radroots_nostr::prelude::RadrootsNostrClient; use std::{collections::BTreeSet, path::PathBuf, sync::Arc, time::Duration}; use tracing::error; -use crate::runtime::{DesktopAppRuntime, DesktopAppRuntimeSummary, DesktopAppSyncStatusSummary}; +use crate::runtime::{ + DesktopAppRuntime, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, + DesktopAppSyncStatusSummary, +}; const HOME_WINDOW_MIN_WIDTH_PX: f32 = 1080.0; const HOME_WINDOW_MIN_HEIGHT_PX: f32 = 720.0; @@ -4490,6 +4496,7 @@ pub struct SettingsWindowView { runtime: DesktopAppRuntime, farm_panel_state: Option<SettingsFarmPanelState>, farm_panel_error: Option<String>, + about_panel_notice: Option<String>, } impl SettingsWindowView { @@ -4499,10 +4506,12 @@ impl SettingsWindowView { runtime, farm_panel_state: None, farm_panel_error: None, + about_panel_notice: None, } } fn select_view(&mut self, view: SettingsPanelViewKey, cx: &mut Context<Self>) { + self.about_panel_notice = None; if self.runtime.select_settings_section(view) { cx.notify(); } @@ -4702,6 +4711,122 @@ impl SettingsWindowView { } } + fn refresh_about_sync(&mut self, cx: &mut Context<Self>) { + match self.runtime.sync_on_manual_refresh() { + Ok(changed) => { + if changed { + self.about_panel_notice = None; + cx.refresh_windows(); + } else { + self.about_panel_notice = Some(app_text(about_conflict_review_body_key( + &self.runtime.summary().sync_status, + ))); + } + cx.notify(); + } + Err(runtime_error) => { + error!( + target: "settings", + event = "settings.about.sync_refresh_failed", + error = %runtime_error, + "failed to refresh sync from the about panel" + ); + self.about_panel_notice = Some(runtime_error.to_string()); + cx.notify(); + } + } + } + + fn resolve_about_conflict( + &mut self, + conflict_id: String, + resolution: SyncConflictResolutionStatus, + cx: &mut Context<Self>, + ) { + match self + .runtime + .resolve_sync_conflict(conflict_id.as_str(), resolution) + { + Ok(changed) => { + if changed { + self.about_panel_notice = None; + cx.refresh_windows(); + } else { + self.about_panel_notice = Some(app_text(about_conflict_review_body_key( + &self.runtime.summary().sync_status, + ))); + } + cx.notify(); + } + Err(runtime_error) => { + error!( + target: "settings", + event = "settings.about.conflict_resolution_failed", + conflict_id = %conflict_id, + error = %runtime_error, + "failed to resolve sync conflict from the about panel" + ); + self.about_panel_notice = Some(runtime_error.to_string()); + cx.notify(); + } + } + } + + fn about_conflict_card( + &mut self, + conflict_index: usize, + conflict: &DesktopAppSyncConflictSummary, + cx: &mut Context<Self>, + ) -> impl IntoElement { + let action_specs = about_conflict_action_specs(&conflict.conflict); + + app_surface_panel( + app_stack_v(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .p(px(APP_UI_THEME.shells.home_card_padding_px)) + .child(app_text_value(about_conflict_aggregate_text( + &conflict.conflict, + ))) + .child(label_value_list(about_conflict_detail_rows(conflict))) + .when(!action_specs.is_empty(), |this| { + this.child( + app_cluster(APP_UI_THEME.foundation.spacing.small_px) + .w_full() + .children( + action_specs + .into_iter() + .enumerate() + .map(|(action_index, (key, resolution))| { + action_button_compact( + ( + gpui::ElementId::from(( + "settings-about-conflict-action", + conflict_index, + )), + action_index.to_string(), + ), + app_shared_text(key), + cx.listener({ + let conflict_id = conflict.conflict_id.clone(); + move |this, _, _, cx| { + this.resolve_about_conflict( + conflict_id.clone(), + resolution, + cx, + ) + } + }), + cx, + ) + .into_any_element() + }) + .collect::<Vec<_>>(), + ), + ) + }), + ) + } + fn navigation_button( &mut self, view: SettingsPanelViewKey, @@ -5491,11 +5616,21 @@ impl SettingsWindowView { ) } - fn about_panel(&self) -> impl IntoElement { + fn about_panel(&mut self, cx: &mut Context<Self>) -> impl IntoElement { let runtime = self.runtime.summary(); let status_rows = about_status_rows(&runtime); - let conflict_rows = about_conflict_review_rows(&runtime.sync_status); let runtime_rows = about_runtime_rows(&runtime); + let manual_refresh_enabled = about_manual_refresh_enabled(&runtime.sync_status); + let conflict_cards = runtime + .sync_status + .conflicts + .iter() + .enumerate() + .map(|(conflict_index, conflict)| { + self.about_conflict_card(conflict_index, conflict, cx) + .into_any_element() + }) + .collect::<Vec<_>>(); app_scroll_panel( "settings-panel-scroll", @@ -5510,7 +5645,23 @@ impl SettingsWindowView { .child(app_heading_section(app_shared_text( AppTextKey::SettingsAboutStatusSectionLabel, ))) - .child(label_value_list(status_rows)), + .child(label_value_list(status_rows)) + .child(if manual_refresh_enabled { + action_button_primary( + "settings-about-refresh-sync", + app_shared_text(AppTextKey::SettingsAboutRefreshAction), + cx.listener(|this, _, _, cx| this.refresh_about_sync(cx)), + cx, + ) + .into_any_element() + } else { + action_button_primary_disabled( + "settings-about-refresh-sync-disabled", + app_shared_text(AppTextKey::SettingsAboutRefreshAction), + cx, + ) + .into_any_element() + }), )) .child(app_surface_card( app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) @@ -5521,7 +5672,10 @@ impl SettingsWindowView { .child(home_body_text(app_text(about_conflict_review_body_key( &runtime.sync_status, )))) - .child(label_value_list(conflict_rows)), + .when_some(self.about_panel_notice.as_deref(), |this, notice| { + this.child(home_body_text(notice.to_owned())) + }) + .children(conflict_cards), )) .child(app_surface_card( app_stack_v(APP_UI_THEME.shells.home_stack_gap_px) @@ -5543,7 +5697,7 @@ impl SettingsWindowView { SettingsPanelViewKey::Account => self.account_panel(cx).into_any_element(), SettingsPanelViewKey::Farm => self.farm_panel(window, cx).into_any_element(), SettingsPanelViewKey::Settings => self.settings_panel(window, cx).into_any_element(), - SettingsPanelViewKey::About => self.about_panel().into_any_element(), + SettingsPanelViewKey::About => self.about_panel(cx).into_any_element(), } } } @@ -5622,28 +5776,126 @@ fn about_conflict_review_body_key(sync_status: &DesktopAppSyncStatusSummary) -> } } -fn about_conflict_review_rows(sync_status: &DesktopAppSyncStatusSummary) -> Vec<LabelValueRow> { - let mut rows = vec![LabelValueRow::new( - app_shared_text(AppTextKey::MetadataSyncConflictCount), - sync_status +fn about_manual_refresh_enabled(sync_status: &DesktopAppSyncStatusSummary) -> bool { + sync_status.is_enabled() + && !sync_status .projection .conflict_status - .unresolved_count - .to_string(), - )]; + .has_blocking_conflicts() +} - if sync_status.projection.conflict_status.blocking_count > 0 { - rows.push(LabelValueRow::new( - app_shared_text(AppTextKey::MetadataSyncBlockingConflictCount), - sync_status - .projection - .conflict_status - .blocking_count - .to_string(), +fn about_conflict_action_specs( + conflict: &SyncConflict, +) -> Vec<(AppTextKey, SyncConflictResolutionStatus)> { + if !conflict.is_unresolved() { + return Vec::new(); + } + + let mut actions = vec![ + ( + AppTextKey::SettingsAboutConflictAcceptLocalAction, + SyncConflictResolutionStatus::AcceptedLocal, + ), + ( + AppTextKey::SettingsAboutConflictAcceptRemoteAction, + SyncConflictResolutionStatus::AcceptedRemote, + ), + ]; + if !matches!( + conflict.severity, + radroots_app_sync::SyncConflictSeverity::Blocking + ) { + actions.push(( + AppTextKey::SettingsAboutConflictDismissAction, + SyncConflictResolutionStatus::Dismissed, )); } - rows + actions +} + +fn about_conflict_detail_rows(conflict: &DesktopAppSyncConflictSummary) -> Vec<LabelValueRow> { + vec![ + LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSyncConflictAggregate), + about_conflict_aggregate_text(&conflict.conflict), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSyncConflictKind), + about_conflict_kind_text(&conflict.conflict), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSyncConflictSeverity), + about_conflict_severity_text(&conflict.conflict), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSyncConflictDetectedAt), + conflict.conflict.detected_at.clone(), + ), + LabelValueRow::new( + app_shared_text(AppTextKey::MetadataSyncConflictResolution), + about_conflict_resolution_text(&conflict.conflict), + ), + ] +} + +fn about_conflict_aggregate_text(conflict: &SyncConflict) -> String { + let (aggregate_kind_key, aggregate_id) = match &conflict.aggregate { + SyncAggregateRef::Farm(farm_id) => ( + AppTextKey::ValueSyncConflictAggregateFarm, + farm_id.to_string(), + ), + SyncAggregateRef::FulfillmentWindow(fulfillment_window_id) => ( + AppTextKey::ValueSyncConflictAggregateFulfillmentWindow, + fulfillment_window_id.to_string(), + ), + SyncAggregateRef::Product(product_id) => ( + AppTextKey::ValueSyncConflictAggregateProduct, + product_id.to_string(), + ), + SyncAggregateRef::Order(order_id) => ( + AppTextKey::ValueSyncConflictAggregateOrder, + order_id.to_string(), + ), + }; + + format!("{}: {}", app_text(aggregate_kind_key), aggregate_id) +} + +fn about_conflict_kind_text(conflict: &SyncConflict) -> String { + app_text(match conflict.kind { + SyncConflictKind::RevisionMismatch => AppTextKey::ValueSyncConflictKindRevisionMismatch, + SyncConflictKind::RemoteDelete => AppTextKey::ValueSyncConflictKindRemoteDelete, + SyncConflictKind::RemoteValidationReject => { + AppTextKey::ValueSyncConflictKindRemoteValidationReject + } + }) +} + +fn about_conflict_severity_text(conflict: &SyncConflict) -> String { + match conflict.severity { + SyncConflictSeverity::ReviewRequired => { + app_text(AppTextKey::ValueSyncConflictSeverityReviewRequired) + } + SyncConflictSeverity::Blocking => app_text(AppTextKey::ValueSyncConflictSeverityBlocking), + } +} + +fn about_conflict_resolution_text(conflict: &SyncConflict) -> String { + match conflict.resolution { + SyncConflictResolutionStatus::Unresolved => { + app_text(AppTextKey::ValueSyncConflictResolutionUnresolved) + } + SyncConflictResolutionStatus::AcceptedLocal => { + app_text(AppTextKey::ValueSyncConflictResolutionAcceptedLocal) + } + SyncConflictResolutionStatus::AcceptedRemote => { + app_text(AppTextKey::ValueSyncConflictResolutionAcceptedRemote) + } + SyncConflictResolutionStatus::Dismissed => { + app_text(AppTextKey::ValueSyncConflictResolutionDismissed) + } + } } fn about_runtime_rows(runtime: &DesktopAppRuntimeSummary) -> Vec<LabelValueRow> { @@ -9717,7 +9969,8 @@ mod tests { AppTextKey, FarmerHomeFarmState, HomeStage, SETTINGS_FARM_PANEL_SECTIONS, SETTINGS_NAVIGATION_ORDER, SETTINGS_OPERATIONS_PANEL_SECTIONS, SettingsInventorySectionSpec, SettingsPanelViewKey, StartupHomeSurface, - StartupSignerConnectState, about_conflict_review_body_key, about_conflict_review_rows, + StartupSignerConnectState, about_conflict_action_specs, about_conflict_aggregate_text, + about_conflict_detail_rows, about_conflict_review_body_key, about_manual_refresh_enabled, about_runtime_rows, about_status_rows, app_text, buyer_orders_status_key, farm_setup_onboarding_card_spec, farmer_home_farm_state, farmer_pack_day_available, home_content_scroll_id, home_saved_farm, home_sidebar_navigation_sections, home_stage, @@ -9728,7 +9981,8 @@ mod tests { startup_signer_status_spec, startup_signer_transport_failure_requires_notice, }; use crate::runtime::{ - DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncStatusSummary, + DesktopAppRuntimeMetadataSummary, DesktopAppRuntimeSummary, DesktopAppSyncConflictSummary, + DesktopAppSyncStatusSummary, }; use radroots_app_models::SettingsAccountProjection; use radroots_app_models::{ @@ -9746,7 +10000,8 @@ mod tests { AppShellProjection, FarmWorkspaceReadinessProjection, FarmWorkspaceStatus, HomeRoute, }; use radroots_app_sync::{ - AppSyncProjection, AppSyncRunStatus, SyncCheckpointStatus, SyncConflictStatus, + AppSyncProjection, AppSyncRunStatus, SyncAggregateRef, SyncCheckpointStatus, SyncConflict, + SyncConflictKind, SyncConflictResolutionStatus, SyncConflictSeverity, SyncConflictStatus, }; use radroots_identity::RadrootsIdentity; use std::path::PathBuf; @@ -10268,7 +10523,33 @@ mod tests { } #[test] - fn about_conflict_review_helpers_surface_blocking_attention_truthfully() { + fn about_conflict_review_helpers_surface_actions_and_details_truthfully() { + let blocking_conflict = DesktopAppSyncConflictSummary { + conflict_id: String::new(), + conflict: SyncConflict { + aggregate: SyncAggregateRef::Farm(FarmId::new()), + kind: SyncConflictKind::RevisionMismatch, + severity: SyncConflictSeverity::Blocking, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: String::new(), + remote_payload_json: Some(String::new()), + detected_at: "0".to_owned(), + resolved_at: None, + }, + }; + let review_conflict = DesktopAppSyncConflictSummary { + conflict_id: String::new(), + conflict: SyncConflict { + aggregate: SyncAggregateRef::Order(radroots_app_models::OrderId::new()), + kind: SyncConflictKind::RemoteValidationReject, + severity: SyncConflictSeverity::ReviewRequired, + resolution: SyncConflictResolutionStatus::Unresolved, + local_payload_json: String::new(), + remote_payload_json: Some(String::new()), + detected_at: "0".to_owned(), + resolved_at: None, + }, + }; let mut runtime = summary( HomeRoute::Today, TodayAgendaProjection::default(), @@ -10285,21 +10566,58 @@ mod tests { }, }, pending_write_count: 3, + conflicts: vec![blocking_conflict.clone(), review_conflict.clone()], }; - let rows = about_conflict_review_rows(&runtime.sync_status); - assert_eq!( about_conflict_review_body_key(&runtime.sync_status), AppTextKey::SettingsAboutConflictReviewBlocking ); + assert!(!about_manual_refresh_enabled(&runtime.sync_status)); + + let blocking_actions = about_conflict_action_specs(&blocking_conflict.conflict); + assert_eq!( + blocking_actions, + vec![ + ( + AppTextKey::SettingsAboutConflictAcceptLocalAction, + SyncConflictResolutionStatus::AcceptedLocal, + ), + ( + AppTextKey::SettingsAboutConflictAcceptRemoteAction, + SyncConflictResolutionStatus::AcceptedRemote, + ), + ] + ); + + let review_actions = about_conflict_action_specs(&review_conflict.conflict); + assert_eq!( + review_actions, + vec![ + ( + AppTextKey::SettingsAboutConflictAcceptLocalAction, + SyncConflictResolutionStatus::AcceptedLocal, + ), + ( + AppTextKey::SettingsAboutConflictAcceptRemoteAction, + SyncConflictResolutionStatus::AcceptedRemote, + ), + ( + AppTextKey::SettingsAboutConflictDismissAction, + SyncConflictResolutionStatus::Dismissed, + ), + ] + ); + + let rows = about_conflict_detail_rows(&blocking_conflict); + assert_eq!(rows.len(), 5); assert!(rows.iter().any(|row| { - row.label == app_text(AppTextKey::MetadataSyncConflictCount) - && row.value == 2.to_string() + row.label == app_text(AppTextKey::MetadataSyncConflictAggregate) + && row.value == about_conflict_aggregate_text(&blocking_conflict.conflict) })); assert!(rows.iter().any(|row| { - row.label == app_text(AppTextKey::MetadataSyncBlockingConflictCount) - && row.value == 1.to_string() + row.label == app_text(AppTextKey::MetadataSyncConflictResolution) + && row.value == app_text(AppTextKey::ValueSyncConflictResolutionUnresolved) })); } diff --git a/crates/shared/i18n/src/keys.rs b/crates/shared/i18n/src/keys.rs @@ -359,6 +359,7 @@ define_app_text_keys! { SettingsAboutConflictReviewClear => "settings.about.conflict_review.clear", SettingsAboutConflictReviewNeedsAttention => "settings.about.conflict_review.needs_attention", SettingsAboutConflictReviewBlocking => "settings.about.conflict_review.blocking", + SettingsAboutRefreshAction => "settings.about.status.action.refresh", SettingsAboutConflictAcceptLocalAction => "settings.about.conflict_review.action.accept_local", SettingsAboutConflictAcceptRemoteAction => "settings.about.conflict_review.action.accept_remote", SettingsAboutConflictDismissAction => "settings.about.conflict_review.action.dismiss", @@ -392,6 +393,11 @@ define_app_text_keys! { MetadataSyncPendingWriteCount => "metadata.sync_pending_write_count", MetadataSyncConflictCount => "metadata.sync_conflict_count", MetadataSyncBlockingConflictCount => "metadata.sync_blocking_conflict_count", + MetadataSyncConflictAggregate => "metadata.sync_conflict_aggregate", + MetadataSyncConflictKind => "metadata.sync_conflict_kind", + MetadataSyncConflictSeverity => "metadata.sync_conflict_severity", + MetadataSyncConflictDetectedAt => "metadata.sync_conflict_detected_at", + MetadataSyncConflictResolution => "metadata.sync_conflict_resolution", MetadataStartupIssue => "metadata.startup_issue", ValueNone => "value.none", ValueEnabled => "value.enabled", @@ -407,4 +413,17 @@ define_app_text_keys! { ValueSyncCheckpointSyncing => "value.sync_checkpoint_state.syncing", ValueSyncCheckpointCurrent => "value.sync_checkpoint_state.current", ValueSyncCheckpointFailed => "value.sync_checkpoint_state.failed", + ValueSyncConflictAggregateFarm => "value.sync_conflict_aggregate.farm", + ValueSyncConflictAggregateFulfillmentWindow => "value.sync_conflict_aggregate.fulfillment_window", + ValueSyncConflictAggregateProduct => "value.sync_conflict_aggregate.product", + ValueSyncConflictAggregateOrder => "value.sync_conflict_aggregate.order", + ValueSyncConflictKindRevisionMismatch => "value.sync_conflict_kind.revision_mismatch", + ValueSyncConflictKindRemoteDelete => "value.sync_conflict_kind.remote_delete", + ValueSyncConflictKindRemoteValidationReject => "value.sync_conflict_kind.remote_validation_reject", + ValueSyncConflictSeverityReviewRequired => "value.sync_conflict_severity.review_required", + ValueSyncConflictSeverityBlocking => "value.sync_conflict_severity.blocking", + ValueSyncConflictResolutionUnresolved => "value.sync_conflict_resolution.unresolved", + ValueSyncConflictResolutionAcceptedLocal => "value.sync_conflict_resolution.accepted_local", + ValueSyncConflictResolutionAcceptedRemote => "value.sync_conflict_resolution.accepted_remote", + ValueSyncConflictResolutionDismissed => "value.sync_conflict_resolution.dismissed", } diff --git a/crates/shared/i18n/src/lib.rs b/crates/shared/i18n/src/lib.rs @@ -190,6 +190,10 @@ mod tests { "Blocking conflicts pause sync until you resolve them." ); assert_eq!( + app_text(AppTextKey::SettingsAboutRefreshAction), + "Refresh sync" + ); + assert_eq!( app_text(AppTextKey::SettingsAboutConflictAcceptLocalAction), "Accept local" ); @@ -209,6 +213,39 @@ mod tests { app_text(AppTextKey::MetadataSyncBlockingConflictCount), "blocking conflict count" ); + assert_eq!( + app_text(AppTextKey::MetadataSyncConflictAggregate), + "aggregate" + ); + assert_eq!(app_text(AppTextKey::MetadataSyncConflictKind), "kind"); + assert_eq!( + app_text(AppTextKey::MetadataSyncConflictSeverity), + "severity" + ); + assert_eq!( + app_text(AppTextKey::MetadataSyncConflictDetectedAt), + "detected" + ); + assert_eq!( + app_text(AppTextKey::MetadataSyncConflictResolution), + "resolution" + ); + assert_eq!( + app_text(AppTextKey::ValueSyncConflictAggregateFulfillmentWindow), + "Fulfillment window" + ); + assert_eq!( + app_text(AppTextKey::ValueSyncConflictKindRevisionMismatch), + "Revision mismatch" + ); + assert_eq!( + app_text(AppTextKey::ValueSyncConflictSeverityBlocking), + "Blocking" + ); + assert_eq!( + app_text(AppTextKey::ValueSyncConflictResolutionAcceptedRemote), + "Accepted remote" + ); } #[test] diff --git a/i18n/locales/en/messages.json b/i18n/locales/en/messages.json @@ -338,6 +338,7 @@ "settings.about.conflict_review.clear": "No conflicts need review right now.", "settings.about.conflict_review.needs_attention": "Conflicts need review before you trust the remote state.", "settings.about.conflict_review.blocking": "Blocking conflicts pause sync until you resolve them.", + "settings.about.status.action.refresh": "Refresh sync", "settings.about.conflict_review.action.accept_local": "Accept local", "settings.about.conflict_review.action.accept_remote": "Accept remote", "settings.about.conflict_review.action.dismiss": "Dismiss", @@ -371,6 +372,11 @@ "metadata.sync_pending_write_count": "pending writes", "metadata.sync_conflict_count": "sync conflict count", "metadata.sync_blocking_conflict_count": "blocking conflict count", + "metadata.sync_conflict_aggregate": "aggregate", + "metadata.sync_conflict_kind": "kind", + "metadata.sync_conflict_severity": "severity", + "metadata.sync_conflict_detected_at": "detected", + "metadata.sync_conflict_resolution": "resolution", "metadata.startup_issue": "startup issue", "value.none": "none", "value.enabled": "enabled", @@ -385,5 +391,18 @@ "value.sync_checkpoint_state.never_synced": "never synced", "value.sync_checkpoint_state.syncing": "syncing", "value.sync_checkpoint_state.current": "current", - "value.sync_checkpoint_state.failed": "failed" + "value.sync_checkpoint_state.failed": "failed", + "value.sync_conflict_aggregate.farm": "Farm", + "value.sync_conflict_aggregate.fulfillment_window": "Fulfillment window", + "value.sync_conflict_aggregate.product": "Product", + "value.sync_conflict_aggregate.order": "Order", + "value.sync_conflict_kind.revision_mismatch": "Revision mismatch", + "value.sync_conflict_kind.remote_delete": "Remote delete", + "value.sync_conflict_kind.remote_validation_reject": "Remote validation reject", + "value.sync_conflict_severity.review_required": "Review required", + "value.sync_conflict_severity.blocking": "Blocking", + "value.sync_conflict_resolution.unresolved": "Unresolved", + "value.sync_conflict_resolution.accepted_local": "Accepted local", + "value.sync_conflict_resolution.accepted_remote": "Accepted remote", + "value.sync_conflict_resolution.dismissed": "Dismissed" }