diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index c4b936c..5825a9f 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -32,6 +32,7 @@ mod splits; mod sync; mod sync_apply; mod sync_context; +mod sync_history; mod sync_notes; mod sync_profiles; mod sync_reading_list; diff --git a/crates/ely_browser_core/src/state/sync_apply.rs b/crates/ely_browser_core/src/state/sync_apply.rs index f2e9ce2..c26227f 100644 --- a/crates/ely_browser_core/src/state/sync_apply.rs +++ b/crates/ely_browser_core/src/state/sync_apply.rs @@ -31,6 +31,9 @@ impl BrowserCore { for record in body.site_permissions { self.apply_site_permission_sync_record(record, &mut summary, &context)?; } + for record in body.history { + self.apply_history_sync_record(record, &mut summary, &context)?; + } Ok(summary) } } diff --git a/crates/ely_browser_core/src/state/sync_history.rs b/crates/ely_browser_core/src/state/sync_history.rs new file mode 100644 index 0000000..eb46881 --- /dev/null +++ b/crates/ely_browser_core/src/state/sync_history.rs @@ -0,0 +1,90 @@ +use std::{ + num::NonZeroU32, + time::{Duration, UNIX_EPOCH}, +}; + +use ely_domain::{HistoryEntry, SyncObjectKind, SyncObjectPolicy, TabId, UrlText}; +use ely_sync_client::SyncClientError; + +use super::{BrowserCore, sync::snapshot_schema_error, sync_context::SyncSnapshotApplyContext}; +use crate::{sync_engine::SyncSnapshotApplySummary, sync_records::HistorySyncRecord}; + +impl BrowserCore { + pub(crate) fn visible_history_for_sync(&self) -> Vec<&HistoryEntry> { + if self.sync_object_policy(SyncObjectKind::History) == SyncObjectPolicy::Paused { + return Vec::new(); + } + self.history_entries + .iter() + .filter(|entry| self.profile_allows_cloud_sync(entry.profile_id())) + .collect() + } + + pub(super) fn apply_history_sync_record( + &mut self, + record: HistorySyncRecord, + summary: &mut SyncSnapshotApplySummary, + context: &SyncSnapshotApplyContext, + ) -> Result<(), SyncClientError> { + let profile_id = self.sync_profile_id(&record.profile_id, context)?; + 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 source_tab_id = self.resolve_history_source_tab_id( + &record.source_tab_id, + &profile_id, + &space_id, + &url, + )?; + let visited_at = UNIX_EPOCH + Duration::from_secs(record.visited_at_secs); + let visit_count = NonZeroU32::new(record.visit_count).ok_or_else(|| { + snapshot_schema_error("history visit_count must be greater than zero") + })?; + let existing_index = self.history_entries.iter().position(|entry| { + entry.profile_id() == &profile_id + && entry.space_id() == &space_id + && entry.url() == &url + }); + let entry = HistoryEntry::new( + profile_id, + space_id, + source_tab_id, + record.title, + url, + record.favicon_key, + visited_at, + ); + let entry = HistoryEntry::restore(entry, visit_count); + + match existing_index { + Some(index) if self.history_entries[index] == entry => summary.record_skipped(), + Some(index) => { + self.history_entries[index] = entry; + summary.record_updated(); + } + None => { + self.history_entries.push(entry); + summary.record_imported(); + } + } + Ok(()) + } + + fn resolve_history_source_tab_id( + &self, + raw: &str, + profile_id: &ely_domain::ProfileId, + space_id: &ely_domain::SpaceId, + url: &UrlText, + ) -> Result { + let remote_tab_id = TabId::parse(raw).map_err(snapshot_schema_error)?; + if self.tabs.iter().any(|tab| tab.id() == &remote_tab_id) { + return Ok(remote_tab_id); + } + if let Some(tab) = self.tabs.iter().find(|tab| { + tab.profile_id() == profile_id && tab.space_id() == space_id && tab.url() == url + }) { + return Ok(tab.id().clone()); + } + Ok(remote_tab_id) + } +} diff --git a/crates/ely_browser_core/src/sync_records.rs b/crates/ely_browser_core/src/sync_records.rs index a92d86e..403a4ae 100644 --- a/crates/ely_browser_core/src/sync_records.rs +++ b/crates/ely_browser_core/src/sync_records.rs @@ -1,8 +1,9 @@ use std::time::{SystemTime, UNIX_EPOCH}; use ely_domain::{ - ArchivePolicy, BookmarkEntry, BrowserTab, NoteEntry, NoteTarget, Profile, ProfileKind, - ProfileSyncPolicy, ReadingListEntry, ReadingProgress, SitePermissionEntry, Space, TabFlags, + ArchivePolicy, BookmarkEntry, BrowserTab, HistoryEntry, NoteEntry, NoteTarget, Profile, + ProfileKind, ProfileSyncPolicy, ReadingListEntry, ReadingProgress, SitePermissionEntry, Space, + TabFlags, }; use serde::{Deserialize, Serialize}; @@ -26,6 +27,8 @@ pub(crate) struct SyncSnapshotBody { pub(crate) reading_list: Vec, #[serde(default)] pub(crate) site_permissions: Vec, + #[serde(default)] + pub(crate) history: Vec, } impl SyncSnapshotBody { @@ -81,6 +84,13 @@ impl SyncSnapshotBody { .into_iter() .map(SitePermissionSyncRecord::from_entry) .collect(), + history: core + .visible_history_for_sync() + .into_iter() + .map(|entry| { + HistorySyncRecord::from_entry(entry, core.sync_space_name_for(entry.space_id())) + }) + .collect(), } } } @@ -399,6 +409,37 @@ impl SitePermissionSyncRecord { } } +#[derive(Clone, Debug, Serialize, Deserialize)] +pub(crate) struct HistorySyncRecord { + pub(crate) profile_id: String, + pub(crate) space_id: String, + #[serde(default)] + pub(crate) space_name: Option, + pub(crate) source_tab_id: String, + pub(crate) title: String, + pub(crate) url: String, + #[serde(default)] + pub(crate) favicon_key: Option, + pub(crate) visited_at_secs: u64, + pub(crate) visit_count: u32, +} + +impl HistorySyncRecord { + fn from_entry(entry: &HistoryEntry, space_name: Option) -> Self { + Self { + profile_id: entry.profile_id().as_str().to_string(), + space_id: entry.space_id().as_str().to_string(), + space_name, + source_tab_id: entry.source_tab_id().as_str().to_string(), + title: entry.title().to_string(), + url: entry.url().as_str().to_string(), + favicon_key: entry.favicon_key().map(str::to_string), + visited_at_secs: system_time_secs(entry.visited_at()), + visit_count: entry.visit_count(), + } + } +} + fn default_sync_enabled() -> bool { true } diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs index 5442a4a..867abb1 100644 --- a/crates/ely_browser_core/tests/sync.rs +++ b/crates/ely_browser_core/tests/sync.rs @@ -98,6 +98,7 @@ fn sync_snapshot_imports_remote_bookmarks_into_active_scope() -> Result<(), Box< source.set_bookmark_collection_name(&bookmark_id, "Research")?; source.set_bookmark_tags(&bookmark_id, vec!["rust".to_string(), "gpui".to_string()])?; source.set_bookmark_note(&bookmark_id, "Read later")?; + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -129,6 +130,7 @@ fn sync_snapshot_imports_remote_tabs_into_active_scope() -> Result<(), Box Result<(), Box Result<(), Box> { 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")?; + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -226,6 +230,7 @@ fn sync_snapshot_omits_paused_tabs() -> Result<(), Box> { let mut source = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; source.open_tab(UrlText::parse("https://example.com/research")?); source.set_sync_object_policy(SyncObjectKind::Tabs, SyncObjectPolicy::Paused); + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -250,6 +255,7 @@ fn sync_snapshot_updates_existing_bookmark_metadata() -> Result<(), Box Result<(), Box> { source.set_tab_sync_enabled(&source_tab_id, false)?; source.bookmark_active_tab()?; source.set_sync_object_policy(SyncObjectKind::Bookmarks, SyncObjectPolicy::Paused); + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -301,6 +308,7 @@ fn sync_snapshot_imports_remote_url_notes_into_active_scope() -> Result<(), Box< source.set_tab_sync_enabled(&source_tab_id, false)?; source.set_tab_title(&source_tab_id, "Research Brief")?; source.save_active_url_note(" # Finding\r\n- one ")?; + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -332,6 +340,7 @@ fn sync_snapshot_imports_remote_tab_notes_after_tabs() -> Result<(), Box Result<(), Box> { source.set_tab_sync_enabled(&source_tab_id, false)?; source.set_tab_title(&source_tab_id, "Research Brief")?; source.save_active_url_note("canonical note")?; + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -395,6 +405,7 @@ fn sync_snapshot_omits_paused_notes() -> Result<(), Box> { source.set_tab_sync_enabled(&source_tab_id, false)?; source.save_active_url_note("local only")?; source.set_sync_object_policy(SyncObjectKind::Notes, SyncObjectPolicy::Paused); + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -420,3 +431,7 @@ fn sync_snapshot_rejects_unknown_schema_rev() -> Result<(), Box> { assert!(error.to_string().contains("unsupported schema_rev 999")); Ok(()) } + +fn pause_history_sync(core: &mut BrowserCore) { + core.set_sync_object_policy(SyncObjectKind::History, SyncObjectPolicy::Paused); +} diff --git a/crates/ely_browser_core/tests/sync_history.rs b/crates/ely_browser_core/tests/sync_history.rs new file mode 100644 index 0000000..ed8f1f6 --- /dev/null +++ b/crates/ely_browser_core/tests/sync_history.rs @@ -0,0 +1,95 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{SyncObjectKind, SyncObjectPolicy, UrlText}; + +#[test] +fn sync_snapshot_imports_remote_history_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_sync_enabled(&source_tab_id, false)?; + source.set_tab_title(&source_tab_id, "Research Brief")?; + source.set_tab_favicon_key(&source_tab_id, "favicons/example.ico")?; + 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 [entry] = snapshot.history_entries.as_slice() else { + return Err( + format!("expected 1 history entry, got {}", snapshot.history_entries.len()).into() + ); + }; + + assert_eq!(summary.imported(), 1); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.profile_id(), &target_profile_id); + assert_eq!(entry.space_id(), &target_space_id); + assert_eq!(entry.source_tab_id(), &source_tab_id); + assert_eq!(entry.title(), "Research Brief"); + assert_eq!(entry.url().as_str(), "https://example.com/research"); + assert_eq!(entry.favicon_key(), Some("favicons/example.ico")); + assert_eq!(entry.visit_count(), 1); + Ok(()) +} + +#[test] +fn sync_snapshot_updates_existing_history_entry() -> 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 first_source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&first_source_tab_id, false)?; + let latest_source_tab_id = source.open_tab(UrlText::parse("https://example.com/research")?); + source.set_tab_sync_enabled(&latest_source_tab_id, false)?; + source.set_tab_title(&latest_source_tab_id, "Canonical Research")?; + source.set_tab_favicon_key(&latest_source_tab_id, "favicons/canonical.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 Research")?; + target.set_tab_favicon_key(&target_tab_id, "favicons/old.ico")?; + let summary = target.apply_sync_snapshot_bytes(&bytes)?; + let snapshot = target.snapshot()?; + let [entry] = snapshot.history_entries.as_slice() else { + return Err( + format!("expected 1 history entry, got {}", snapshot.history_entries.len()).into() + ); + }; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 1); + assert_eq!(summary.skipped(), 0); + assert_eq!(entry.source_tab_id(), &target_tab_id); + assert_eq!(entry.title(), "Canonical Research"); + assert_eq!(entry.favicon_key(), Some("favicons/canonical.ico")); + assert_eq!(entry.visit_count(), 2); + Ok(()) +} + +#[test] +fn sync_snapshot_omits_paused_history() -> 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_sync_enabled(&source_tab_id, false)?; + source.set_sync_object_policy(SyncObjectKind::History, SyncObjectPolicy::Paused); + 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()?; + + assert_eq!(summary.imported(), 0); + assert_eq!(summary.updated(), 0); + assert_eq!(summary.skipped(), 0); + assert!(snapshot.history_entries.is_empty()); + Ok(()) +} diff --git a/crates/ely_browser_core/tests/sync_profiles.rs b/crates/ely_browser_core/tests/sync_profiles.rs index ff010cb..14f86bd 100644 --- a/crates/ely_browser_core/tests/sync_profiles.rs +++ b/crates/ely_browser_core/tests/sync_profiles.rs @@ -94,6 +94,13 @@ fn paused_profile_data_is_omitted_from_sync_snapshots() -> Result<(), Box Result<(), Box Result<(), B &entry_id, ReadingProgress::InProgress(ReadingProgressPercent::new(42)?), )?; + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -52,6 +53,7 @@ fn sync_snapshot_updates_existing_reading_list_entry() -> Result<(), Box Result<(), Box> { source.set_tab_sync_enabled(&source_tab_id, false)?; source.save_active_tab_to_reading_list()?; source.set_sync_object_policy(SyncObjectKind::ReadingList, SyncObjectPolicy::Paused); + pause_history_sync(&mut source); let bytes = source.build_sync_snapshot_bytes()?; let mut target = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; @@ -96,3 +99,7 @@ fn sync_snapshot_omits_paused_reading_list() -> Result<(), Box> { assert!(snapshot.reading_list.is_empty()); Ok(()) } + +fn pause_history_sync(core: &mut BrowserCore) { + core.set_sync_object_policy(SyncObjectKind::History, SyncObjectPolicy::Paused); +} diff --git a/crates/ely_domain/src/history.rs b/crates/ely_domain/src/history.rs index 72c568a..d510d3d 100644 --- a/crates/ely_domain/src/history.rs +++ b/crates/ely_domain/src/history.rs @@ -1,4 +1,4 @@ -use std::time::SystemTime; +use std::{num::NonZeroU32, time::SystemTime}; use crate::{ProfileId, SpaceId, TabId, UrlText}; @@ -53,6 +53,12 @@ impl HistoryEntry { self.visit_count = self.visit_count.saturating_add(1); } + #[must_use] + pub fn restore(mut entry: Self, visit_count: NonZeroU32) -> Self { + entry.visit_count = visit_count.get(); + entry + } + #[must_use] pub fn profile_id(&self) -> &ProfileId { &self.profile_id