From d05d31ed22c68208d138720c70f5623b4a9a0cac 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 03:44:07 -0400 Subject: [PATCH] feat(sync): include open tabs in snapshots --- crates/ely_browser_core/src/state/sync.rs | 101 ++++++++++++++++++++- crates/ely_browser_core/src/sync_engine.rs | 69 ++++++++++++-- crates/ely_browser_core/tests/sync.rs | 73 ++++++++++++++- crates/ely_domain/src/tab.rs | 17 +++- 4 files changed, 248 insertions(+), 12 deletions(-) diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs index b3a9f34..d22bc9e 100644 --- a/crates/ely_browser_core/src/state/sync.rs +++ b/crates/ely_browser_core/src/state/sync.rs @@ -1,13 +1,15 @@ use std::time::{Duration, UNIX_EPOCH}; use ely_domain::{ - BookmarkEntry, BookmarkId, ProfileId, SpaceId, SyncConnectionState, SyncObjectKind, - SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, UrlText, + BookmarkEntry, BookmarkId, BrowserTab, ProfileId, SpaceId, SyncConnectionState, SyncObjectKind, + SyncObjectPolicy, SyncObjectState, SyncObjectStatus, SyncStatus, TabId, UrlText, }; use ely_sync_client::SyncClientError; use super::BrowserCore; -use crate::sync_engine::{BookmarkSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody}; +use crate::sync_engine::{ + BookmarkSyncRecord, SyncSnapshotApplySummary, SyncSnapshotBody, TabSyncRecord, +}; #[derive(Clone, Debug)] pub(super) struct SyncObjectPolicies { @@ -98,6 +100,9 @@ impl BrowserCore { body: SyncSnapshotBody, ) -> Result { let mut summary = SyncSnapshotApplySummary::default(); + for record in body.tabs { + self.apply_tab_sync_record(record, &mut summary)?; + } for record in body.bookmarks { self.apply_bookmark_sync_record(record, &mut summary)?; } @@ -172,6 +177,67 @@ impl BrowserCore { self.tabs.iter().filter(|tab| tab.sync_enabled()).count() } + pub(crate) fn visible_tabs_for_sync(&self) -> Vec<&BrowserTab> { + self.tabs + .iter() + .filter(|tab| { + tab.sync_enabled() + && self + .profiles + .iter() + .any(|profile| profile.id() == tab.profile_id() && profile.allows_sync()) + }) + .collect() + } + + fn apply_tab_sync_record( + &mut self, + record: TabSyncRecord, + summary: &mut SyncSnapshotApplySummary, + ) -> Result<(), SyncClientError> { + let tab_id = parse_tab_id(&record.id)?; + let profile_id = self.sync_profile_id(&record.profile_id)?; + let space_id = self.sync_space_id(&record.space_id, record.space_name.as_deref())?; + let url = UrlText::parse(&record.url).map_err(snapshot_schema_error)?; + let created_at = UNIX_EPOCH + Duration::from_secs(record.created_at_secs); + let last_active_at = UNIX_EPOCH + Duration::from_secs(record.last_active_at_secs); + let existing_index = self.tabs.iter().position(|tab| tab.id() == &tab_id).or_else(|| { + self.tabs.iter().position(|tab| { + tab.profile_id() == &profile_id && tab.space_id() == &space_id && tab.url() == &url + }) + }); + let id = existing_index + .and_then(|index| self.tabs.get(index).map(|tab| tab.id().clone())) + .unwrap_or(tab_id); + let mut tab = + BrowserTab::new(id.clone(), space_id.clone(), profile_id.clone(), record.title, url) + .with_sort_key(record.sort_key); + tab.restore_activity_timestamps(created_at, last_active_at); + tab.set_flags(record.flags); + tab.set_sync_enabled(record.sync_enabled); + tab.set_zoom_percent(record.zoom_percent).map_err(snapshot_schema_error)?; + if let Some(favicon_key) = record.favicon_key { + tab.set_favicon_key(favicon_key).map_err(snapshot_schema_error)?; + } + + match existing_index { + Some(index) if self.tabs[index] == tab => summary.record_skipped(), + Some(index) => { + self.tabs[index] = tab; + self.sort_tabs_within_space(&space_id); + self.ensure_synced_tab_indexes(&id, &space_id, &profile_id); + summary.record_updated(); + } + None => { + self.tabs.push(tab); + self.sort_tabs_within_space(&space_id); + self.ensure_synced_tab_indexes(&id, &space_id, &profile_id); + summary.record_imported(); + } + } + Ok(()) + } + fn apply_bookmark_sync_record( &mut self, record: BookmarkSyncRecord, @@ -253,12 +319,41 @@ impl BrowserCore { } Ok(self.active_space_id.clone()) } + + fn ensure_synced_tab_indexes( + &mut self, + tab_id: &TabId, + space_id: &SpaceId, + profile_id: &ProfileId, + ) { + if self + .active_tabs_by_space + .get(space_id) + .is_none_or(|mapped_id| !self.tab_belongs_to_space(mapped_id, space_id)) + { + self.active_tabs_by_space.insert(space_id.clone(), tab_id.clone()); + } + let key = (space_id.clone(), profile_id.clone()); + if self.active_tabs_by_space_profile.get(&key).is_none_or(|mapped_id| { + !self.tabs.iter().any(|tab| { + tab.id() == mapped_id + && tab.space_id() == space_id + && tab.profile_id() == profile_id + }) + }) { + self.active_tabs_by_space_profile.insert(key, tab_id.clone()); + } + } } fn parse_bookmark_id(raw: &str) -> Result { BookmarkId::parse(raw).map_err(snapshot_schema_error) } +fn parse_tab_id(raw: &str) -> Result { + TabId::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 6c952e8..d78c2d9 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; +use ely_domain::{BookmarkEntry, BrowserTab, TabFlags}; use ely_sync_client::{ ApiClientConfig, BearerToken, BearerTokenStore, DeviceIdentity, SnapshotPayload, SnapshotUploadRequest, SyncApiClient, SyncClientError, SyncLatestSnapshotDocument, @@ -282,6 +282,8 @@ fn device_registration_idempotency_key(identity: &DeviceIdentity) -> String { pub(crate) struct SyncSnapshotBody { pub(crate) schema_rev: u32, pub(crate) bookmarks: Vec, + #[serde(default)] + pub(crate) tabs: Vec, } impl SyncSnapshotBody { @@ -298,6 +300,13 @@ impl SyncSnapshotBody { ) }) .collect(), + tabs: core + .visible_tabs_for_sync() + .into_iter() + .map(|entry| { + TabSyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), } } } @@ -335,15 +344,63 @@ impl BookmarkSyncRecord { tags: entry.tags().to_vec(), note: entry.note().map(str::to_string), thumbnail_key: entry.thumbnail_key().map(str::to_string), - added_at_secs: entry - .added_at() - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.as_secs()) - .unwrap_or(0), + added_at_secs: system_time_secs(entry.added_at()), } } } +/// Wire representation of an open tab. The record carries only +/// user-visible tab state; runtime-only fields such as split layout +/// membership and crash state stay local to the receiving device. +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct TabSyncRecord { + pub(crate) id: String, + pub(crate) title: String, + pub(crate) url: String, + pub(crate) profile_id: String, + pub(crate) space_id: String, + #[serde(default)] + pub(crate) space_name: Option, + #[serde(default)] + pub(crate) favicon_key: Option, + #[serde(default)] + pub(crate) flags: TabFlags, + pub(crate) sort_key: u64, + #[serde(default = "default_sync_enabled")] + pub(crate) sync_enabled: bool, + pub(crate) zoom_percent: u16, + pub(crate) created_at_secs: u64, + pub(crate) last_active_at_secs: u64, +} + +impl TabSyncRecord { + fn from_entry(entry: &BrowserTab, space_name: Option) -> Self { + Self { + id: entry.id().as_str().to_string(), + title: entry.title().to_string(), + url: entry.url().as_str().to_string(), + profile_id: entry.profile_id().as_str().to_string(), + space_id: entry.space_id().as_str().to_string(), + space_name, + favicon_key: entry.favicon_key().map(str::to_string), + flags: entry.flags().clone(), + sort_key: entry.sort_key(), + sync_enabled: entry.sync_enabled(), + zoom_percent: entry.zoom_percent(), + created_at_secs: system_time_secs(entry.created_at()), + last_active_at_secs: system_time_secs(entry.last_active_at()), + } + } +} + +fn default_sync_enabled() -> bool { + true +} + +fn system_time_secs(time: SystemTime) -> u64 { + time.duration_since(UNIX_EPOCH).map(|elapsed| elapsed.as_secs()).unwrap_or(0) +} + #[derive(Debug)] pub struct SyncEngineBuilder { pub profile_data_dir: PathBuf, diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index aa66aa4..cbb2496 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -90,7 +90,10 @@ fn tab_sync_status_counts_sync_enabled_tabs() -> Result<(), Box> { #[test] fn sync_snapshot_imports_remote_bookmarks_into_active_scope() -> Result<(), Box> { let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; - source.open_tab(UrlText::parse("https://example.com/research")?); + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; let bookmark_id = source.bookmark_active_tab()?; source.set_bookmark_collection_name(&bookmark_id, "Research")?; source.set_bookmark_tags(&bookmark_id, vec!["rust".to_string(), "gpui".to_string()])?; @@ -115,10 +118,76 @@ fn sync_snapshot_imports_remote_bookmarks_into_active_scope() -> Result<(), Box< Ok(()) } +#[test] +fn sync_snapshot_imports_remote_tabs_into_active_scope() -> 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 source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.set_tab_favicon_key(&source_tab_id, "https://example.com/favicon.ico")?; + source.toggle_active_tab_pinned()?; + source.toggle_active_tab_favorite()?; + source.set_active_tab_zoom_percent(125)?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_profile_id = target.snapshot()?.active_profile_id; + let target_space_id = target.snapshot()?.active_space_id; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let imported = snapshot + .tabs + .iter() + .find(|tab| tab.url().as_str() == "https://example.com/research") + .ok_or("missing imported tab")?; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(imported.profile_id(), &target_profile_id); + assert_eq!(imported.space_id(), &target_space_id); + assert_eq!(imported.title(), "Research Brief"); + assert_eq!(imported.favicon_key(), Some("https://example.com/favicon.ico")); + assert!(imported.flags().pinned); + assert!(imported.flags().favorite); + assert_eq!(imported.zoom_percent(), 125); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_tab_metadata() -> 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 source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.set_tab_favicon_key(&source_tab_id, "https://example.com/favicon.ico")?; + let bytes = source.build_sync_snapshot_bytes()?; + + let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + let target_tab_id = target.open_tab(UrlText::parse("https://example.com/research")?); + target.set_tab_title(&target_tab_id, "Old Title")?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let updated = + snapshot.tabs.iter().find(|tab| tab.id() == &target_tab_id).ok_or("missing updated tab")?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(updated.title(), "Research Brief"); + assert_eq!(updated.favicon_key(), Some("https://example.com/favicon.ico")); + Ok(()) +} + #[test] fn sync_snapshot_updates_existing_bookmark_metadata() -> Result<(), Box> { let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; - source.open_tab(UrlText::parse("https://example.com/research")?); + let source_home_tab_id = source.snapshot()?.active_tab_id; + source.set_tab_sync_enabled(&source_home_tab_id, false)?; + let source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&source_tab_id, false)?; let source_bookmark_id = source.bookmark_active_tab()?; source.set_bookmark_collection_name(&source_bookmark_id, "Research")?; source.set_bookmark_tags(&source_bookmark_id, vec!["servo".to_string()])?; diff --git a/crates/ely_domain/src/tab.rs b/crates/ely_domain/src/tab.rs index 1780b6f..52bce5f 100644 --- a/crates/ely_domain/src/tab.rs +++ b/crates/ely_domain/src/tab.rs @@ -1,5 +1,7 @@ use std::time::SystemTime; +use serde::{Deserialize, Serialize}; + use crate::{DomainError, ProfileId, SpaceId, SplitId, TabGroupId, TabId, UrlText}; pub const DEFAULT_ZOOM_PERCENT: u16 = 100; @@ -16,7 +18,7 @@ pub enum TabState { Archived, } -#[derive(Clone, Debug, Default, Eq, PartialEq)] +#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)] pub struct TabFlags { pub pinned: bool, pub favorite: bool, @@ -208,6 +210,10 @@ impl BrowserTab { self.flags.pinned = pinned; } + pub fn set_flags(&mut self, flags: TabFlags) { + self.flags = flags; + } + pub fn move_to_space(&mut self, space_id: SpaceId) { self.space_id = space_id; } @@ -285,6 +291,15 @@ impl BrowserTab { self.sync_enabled = sync_enabled; } + pub fn restore_activity_timestamps( + &mut self, + created_at: SystemTime, + last_active_at: SystemTime, + ) { + self.created_at = created_at; + self.last_active_at = last_active_at; + } + /// Replace this tab's URL directly while preserving navigation /// stacks. Title stays as set; callers can re-derive it from the /// new URL when needed.