From bfdc97d7fde22ed8eed16d6d8fe60c5e1283accb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sat, 16 May 2026 05:42:17 -0400 Subject: [PATCH] feat(sync): preserve spaces in snapshots --- crates/ely_browser_core/src/state/sync.rs | 99 +++++++++++++++++++++- crates/ely_browser_core/src/sync_engine.rs | 70 +++++++++++++-- crates/ely_browser_core/tests/sync.rs | 44 +++++++++- crates/ely_domain/src/space.rs | 12 +++ 4 files changed, 212 insertions(+), 13 deletions(-) diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index 9d9884e..22c4d12 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -1,14 +1,15 @@ use std::time::{Duration, UNIX_EPOCH}; use ely_domain::{ - BookmarkEntry, BookmarkId, BrowserTab, ProfileId, SpaceId, SyncConnectionState, SyncObjectKind, - SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, TabId, UrlText, + ArchivePolicy, BookmarkEntry, BookmarkId, BrowserTab, ProfileId, Space, SpaceId, + SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, + SyncStatus, TabId, UrlText, }; use ely_sync_client::SyncClientError; use super::BrowserCore; use crate::sync_engine::{ - BookmarkSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord, + BookmarkSyncRecord, SpaceSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord, }; #[derive(Clone, Debug)] @@ -111,6 +112,9 @@ impl BrowserCore { body: SyncSnapshotBody, ) -> Result { let mut summary = SyncSnapshotApplySummary::default(); + for record in body.spaces { + self.apply_space_sync_record(record, &mut summary)?; + } for record in body.tabs { self.apply_tab_sync_record(record, &mut summary)?; } @@ -204,6 +208,91 @@ impl BrowserCore { .collect() } + pub(crate) fn visible_spaces_for_sync(&self) -> Vec<&Space> { + if self.sync_object_policy(SyncObjectKind::Spaces) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.spaces.iter().collect() + } + + fn apply_space_sync_record( + &mut self, + record: SpaceSyncRecord, + summary: &mut SyncSnapshotApplySummary, + ) -> Result<(), SyncClientError> { + let space_id = parse_space_id(&record.id)?; + let default_profile_id = self.sync_profile_id(&record.default_profile_id)?; + let archive_policy = ArchivePolicy::from(record.archive_policy.clone()); + let existing_index = + self.spaces.iter().position(|space| space.id() == &space_id).or_else(|| { + self.spaces + .iter() + .position(|space| space.name().eq_ignore_ascii_case(record.name.trim())) + }); + + match existing_index { + Some(index) => { + if self.update_space_from_sync_record( + index, + record, + default_profile_id, + archive_policy, + ) { + summary.record_updated(); + } + } + None => { + let mut space = Space::new( + record.name, + record.icon, + record.accent_hex, + default_profile_id, + record.sort_key, + ); + space.set_archive_policy(archive_policy); + space.set_sidebar_width_px(record.sidebar_width_px); + self.spaces.push(space); + summary.record_imported(); + } + } + Ok(()) + } + + fn update_space_from_sync_record( + &mut self, + index: usize, + record: SpaceSyncRecord, + default_profile_id: ProfileId, + archive_policy: ArchivePolicy, + ) -> bool { + let space = &mut self.spaces[index]; + let mut changed = false; + if space.name() != record.name + || space.icon() != record.icon + || space.accent_hex() != record.accent_hex + { + space.set_presentation(record.name, record.icon, record.accent_hex); + changed = true; + } + if space.default_profile_id() != &default_profile_id { + space.set_default_profile_id(default_profile_id); + changed = true; + } + if space.archive_policy() != &archive_policy { + space.set_archive_policy(archive_policy); + changed = true; + } + if space.sidebar_width_px() != record.sidebar_width_px { + space.set_sidebar_width_px(record.sidebar_width_px); + changed = true; + } + if space.sort_key() != record.sort_key { + space.set_sort_key(record.sort_key); + changed = true; + } + changed + } + fn apply_tab_sync_record( &mut self, record: TabSyncRecord, @@ -368,6 +457,10 @@ fn parse_tab_id(raw: &str) -> Result { TabId::parse(raw).map_err(snapshot_schema_error) } +fn parse_space_id(raw: &str) -> Result { + SpaceId::parse(raw).map_err(snapshot_schema_error) +} + fn snapshot_schema_error(error: impl ToString) -> SyncClientError { SyncClientError::SnapshotSchema(error.to_string()) } diff --git a/crates/ely_browser_core/src/sync_engine.rs b/crates/ely_browser_core/src/sync_engine.rs index d78c2d9..8e539a9 100644 --- a/crates/ely_browser_core/src/sync_engine.rs +++ b/crates/ely_browser_core/src/sync_engine.rs @@ -3,7 +3,7 @@ use std::{ time::{SystemTime, UNIX_EPOCH}, }; -use ely_domain::{BookmarkEntry, BrowserTab, TabFlags}; +use ely_domain::{ArchivePolicy, BookmarkEntry, BrowserTab, Space, TabFlags}; use ely_sync_client::{ ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload, SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument, @@ -12,10 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::state::BrowserCore; -/// Per-profile sync scaffolding. Holds the persisted device identity -/// and the bearer-token store; the API client is only constructed at -/// the moment the user runs a manual sync (so a logged-out user -/// doesn't pay TLS handshake cost on startup). +/// Per-profile sync engine for device identity, bearer-token storage, and snapshot IO. #[derive(Debug)] pub struct SyncEngine { api_config: ApiClientConfig, @@ -281,6 +278,8 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String { #[derive(Serialize, Deserialize)] pub(crate) struct SyncSnapshotBody { pub(crate) schema_rev: u32, + #[serde(default)] + pub(crate) spaces: Vec, pub(crate) bookmarks: Vec, #[serde(default)] pub(crate) tabs: Vec, @@ -290,6 +289,11 @@ impl SyncSnapshotBody { fn from_core(core: &BrowserCore) -> Self { Self { schema_rev: SNAPSHOT_SCHEMA_REV, + spaces: core + .visible_spaces_for_sync() + .into_iter() + .map(SpaceSyncRecord::from_space) + .collect(), bookmarks: core .visible_bookmarks_for_sync() .into_iter() @@ -311,9 +315,59 @@ impl SyncSnapshotBody { } } -/// Wire representation of a bookmark. We keep this struct stable so a -/// future deserializer can read snapshots written by earlier app -/// versions; new fields must default-fill on read. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct SpaceSyncRecord { + pub(crate) id: String, + pub(crate) name: String, + pub(crate) icon: String, + pub(crate) accent_hex: u32, + pub(crate) default_profile_id: String, + pub(crate) archive_policy: SpaceArchivePolicySyncRecord, + pub(crate) sidebar_width_px: u16, + pub(crate) sort_key: u64, +} + +impl SpaceSyncRecord { + fn from_space(space: &Space) -> Self { + Self { + id: space.id().as_str().to_string(), + name: space.name().to_string(), + icon: space.icon().to_string(), + accent_hex: space.accent_hex(), + default_profile_id: space.default_profile_id().as_str().to_string(), + archive_policy: SpaceArchivePolicySyncRecord::from(space.archive_policy()), + sidebar_width_px: space.sidebar_width_px(), + sort_key: space.sort_key(), + } + } +} + +#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub(crate) enum SpaceArchivePolicySyncRecord { + Manual, + IdleDays { days: u16 }, +} + +impl From<&ArchivePolicy> for SpaceArchivePolicySyncRecord { + fn from(policy: &ArchivePolicy) -> Self { + match policy { + ArchivePolicy::Manual => Self::Manual, + ArchivePolicy::IdleDays(days) => Self::IdleDays { days: *days }, + } + } +} + +impl From for ArchivePolicy { + fn from(policy: SpaceArchivePolicySyncRecord) -> Self { + match policy { + SpaceArchivePolicySyncRecord::Manual => Self::Manual, + SpaceArchivePolicySyncRecord::IdleDays { days } => Self::IdleDays(days), + } + } +} + +/// Wire representation of a bookmark with default-fill fields for older snapshots. #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct BookmarkSyncRecord { pub(crate) id: String, diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index a876271..b7ea925 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -2,8 +2,8 @@ use std::error::Error; use ely_browser_core::{BrowserCore, InitialBrowserConfig}; use ely_domain::{ - SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus, - UrlText, + ArchivePolicy, SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, + SyncObjectStatus, UrlText, }; #[test] @@ -155,6 +155,46 @@ fn sync_snapshot_imports_remote_tabs_into_active_scope() -> Result<(), Box Result<(), Box> { + let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let research_space_id = source.create_space("Research", "R", 0xf54e00)?; + source.set_active_space_archive_policy(ArchivePolicy::IdleDays(14))?; + source.set_space_sidebar_width(&research_space_id, 320)?; + let research_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&research_home_tab_id, false)?; + source.open_tab(UrlText::parse("https://example.com/research")?); + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let research_space = snapshot + .spaces + .iter() + .find(|space| space.name() == "Research") + .ok_or("missing imported research space")?; + let research_space_id = research_space.id().clone(); + assert_eq!(research_space.archive_policy(), &ArchivePolicy::IdleDays(14)); + assert_eq!(research_space.sidebar_width_px(), 320); + + target.select_space(&research_space_id)?; + let snapshot = target.snapshot()?; + let imported_tab = snapshot + .tabs + .iter() + .find(|tab| tab.url().as_str() == "https://example.com/research") + .ok_or("missing imported research tab")?; + + assert_eq!(summary.imported(), 2); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(imported_tab.space_id(), &research_space_id); + Ok(()) +} + #[test] fn sync_snapshot_updates_existing_tab_metadata() -> Result<(), Box> { let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_domain/src/space.rs b/crates/ely_domain/src/space.rs index f43e2f7..dce269d 100644 --- a/crates/ely_domain/src/space.rs +++ b/crates/ely_domain/src/space.rs @@ -80,6 +80,18 @@ impl Space { self.record_update(); } + pub fn set_presentation( + &mut self, + name: impl Into, + icon: impl Into, + accent_hex: u32, + ) { + self.name = name.into(); + self.icon = icon.into(); + self.accent_hex = accent_hex; + self.record_update(); + } + #[must_use] pub fn archive_policy(&self) -> &ArchivePolicy { &self.archive_policy