feat(sync): preserve spaces in snapshots

This commit is contained in:
2026-05-16 05:42:17 -04:00
parent 973fd0a582
commit bfdc97d7fd
4 changed files with 212 additions and 13 deletions
+96 -3
View File
@@ -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<SyncSnapshotApplySummary, SyncClientError> {
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, SyncClientError> {
TabId::parse(raw).map_err(snapshot_schema_error)
}
fn parse_space_id(raw: &str) -> Result<SpaceId, SyncClientError> {
SpaceId::parse(raw).map_err(snapshot_schema_error)
}
fn snapshot_schema_error(error: impl ToString) -> SyncClientError {
SyncClientError::SnapshotSchema(error.to_string())
}
+62 -8
View File
@@ -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<SpaceSyncRecord>,
pub(crate) bookmarks: Vec<BookmarkSyncRecord>,
#[serde(default)]
pub(crate) tabs: Vec<TabSyncRecord>,
@@ -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<SpaceArchivePolicySyncRecord> 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,
+42 -2
View File
@@ -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<dyn E
Ok(())
}
#[test]
fn sync_snapshot_imports_remote_spaces_before_tabs() -> Result<(), Box<dyn Error>> {
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<dyn Error>> {
let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?;
+12
View File
@@ -80,6 +80,18 @@ impl Space {
self.record_update();
}
pub fn set_presentation(
&mut self,
name: impl Into<String>,
icon: impl Into<String>,
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