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:
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"
}