feat(sync): include open tabs in snapshots
This commit is contained in:
@@ -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<SyncSnapshotApplySummary, SyncClientError> {
|
||||
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, SyncClientError> {
|
||||
BookmarkId::parse(raw).map_err(snapshot_schema_error)
|
||||
}
|
||||
|
||||
fn parse_tab_id(raw: &str) -> Result<TabId, SyncClientError> {
|
||||
TabId::parse(raw).map_err(snapshot_schema_error)
|
||||
}
|
||||
|
||||
fn snapshot_schema_error(error: impl ToString) -> SyncClientError {
|
||||
SyncClientError::SnapshotSchema(error.to_string())
|
||||
}
|
||||
|
||||
@@ -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<BookmarkSyncRecord>,
|
||||
#[serde(default)]
|
||||
pub(crate) tabs: Vec<TabSyncRecord>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
#[serde(default)]
|
||||
pub(crate) favicon_key: Option<String>,
|
||||
#[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<String>) -> 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,
|
||||
|
||||
@@ -90,7 +90,10 @@ fn tab_sync_status_counts_sync_enabled_tabs() -> Result<(), Box<dyn Error>> {
|
||||
#[test]
|
||||
fn sync_snapshot_imports_remote_bookmarks_into_active_scope() -> Result<(), Box<dyn Error>> {
|
||||
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<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 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<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 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<dyn Error>> {
|
||||
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()])?;
|
||||
|
||||
Reference in New Issue
Block a user