From 479737a8b2265d8d6c711b5a383a75611a4970b1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Fri, 8 May 2026 00:16:56 -0400 Subject: [PATCH] Add sync settings status page --- crates/ely_app/src/shell/internal_pages.rs | 2 + .../ely_app/src/shell/internal_pages/sync.rs | 172 ++++++++++++++++++ crates/ely_browser_core/src/navigation.rs | 4 + crates/ely_browser_core/src/state.rs | 5 +- crates/ely_browser_core/src/state/commands.rs | 6 +- crates/ely_browser_core/src/state/sync.rs | 35 ++++ crates/ely_browser_core/tests/commands.rs | 15 ++ crates/ely_browser_core/tests/sync.rs | 29 +++ crates/ely_domain/src/lib.rs | 4 + crates/ely_domain/src/sync.rs | 88 +++++++++ 10 files changed, 358 insertions(+), 2 deletions(-) create mode 100644 crates/ely_app/src/shell/internal_pages/sync.rs create mode 100644 crates/ely_browser_core/src/state/sync.rs create mode 100644 crates/ely_browser_core/tests/sync.rs create mode 100644 crates/ely_domain/src/sync.rs diff --git a/crates/ely_app/src/shell/internal_pages.rs b/crates/ely_app/src/shell/internal_pages.rs index 7fbea60..d7bed0a 100644 --- a/crates/ely_app/src/shell/internal_pages.rs +++ b/crates/ely_app/src/shell/internal_pages.rs @@ -3,6 +3,7 @@ mod download_labels; mod downloads; mod plugins; mod profiles; +mod sync; use ely_browser_core::BrowserSnapshot; use ely_design_system::{colors, spacing}; @@ -28,6 +29,7 @@ impl ElyShell { "ely://archive" => self.render_archive_page(snapshot, cx), "ely://settings/plugins" => self.render_plugins_page(snapshot, cx), "ely://settings/profiles" => self.render_profiles_page(snapshot, cx), + "ely://settings/sync" => self.render_sync_page(snapshot), _ => render_default_page(tab), } } diff --git a/crates/ely_app/src/shell/internal_pages/sync.rs b/crates/ely_app/src/shell/internal_pages/sync.rs new file mode 100644 index 0000000..d778fc8 --- /dev/null +++ b/crates/ely_app/src/shell/internal_pages/sync.rs @@ -0,0 +1,172 @@ +use ely_browser_core::BrowserSnapshot; +use ely_design_system::colors; +use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus}; +use gpui::{AnyElement, IntoElement, ParentElement, Styled, div, px, rgb}; +use gpui_component::{IconName, StyledExt, scroll::ScrollableElement}; + +use super::{ElyShell, render_canvas_surface}; + +impl ElyShell { + pub(super) fn render_sync_page(&mut self, snapshot: &BrowserSnapshot) -> AnyElement { + render_canvas_surface( + div() + .size_full() + .p_8() + .flex() + .flex_col() + .gap_5() + .child(render_sync_header(snapshot)) + .child(render_sync_queue(snapshot)) + .child(render_sync_objects(snapshot)), + ) + } +} + +fn render_sync_header(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex() + .items_end() + .justify_between() + .child( + div() + .flex() + .flex_col() + .gap_2() + .child(div().text_size(px(26.0)).text_color(rgb(colors::INK)).child("Sync")) + .child( + div() + .text_sm() + .text_color(rgb(colors::MUTED)) + .child(format!("Profile: {}", snapshot.active_profile_name)), + ), + ) + .child( + div() + .flex() + .items_center() + .gap_2() + .text_xs() + .font_semibold() + .text_color(rgb(colors::MUTED)) + .child(IconName::Globe) + .child(connection_label(snapshot.sync_status.connection())), + ) + .into_any_element() +} + +fn render_sync_queue(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .rounded_md() + .border_1() + .border_color(rgb(colors::HAIRLINE)) + .bg(rgb(colors::CANVAS_SOFT)) + .px_4() + .py_3() + .flex() + .items_center() + .justify_between() + .gap_4() + .child(metric_block("Pending objects", snapshot.sync_status.pending_objects(), colors::INK)) + .child(metric_block("Failed objects", snapshot.sync_status.failed_objects(), colors::ERROR)) + .into_any_element() +} + +fn metric_block(label: &'static str, value: usize, color: u32) -> AnyElement { + div() + .flex() + .flex_col() + .gap_1() + .child(div().text_xs().text_color(rgb(colors::MUTED)).child(label)) + .child( + div() + .text_size(px(18.0)) + .font_semibold() + .text_color(rgb(color)) + .child(value.to_string()), + ) + .into_any_element() +} + +fn render_sync_objects(snapshot: &BrowserSnapshot) -> AnyElement { + div() + .flex_1() + .min_h_0() + .flex() + .flex_col() + .overflow_y_scrollbar() + .border_t_1() + .border_color(rgb(colors::HAIRLINE)) + .children(snapshot.sync_status.objects().iter().map(render_sync_object_row)) + .into_any_element() +} + +fn render_sync_object_row(status: &SyncObjectStatus) -> AnyElement { + div() + .py_3() + .border_b_1() + .border_color(rgb(colors::HAIRLINE)) + .flex() + .items_center() + .justify_between() + .gap_4() + .child( + div() + .min_w_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_semibold() + .truncate() + .text_color(rgb(colors::INK)) + .child(sync_object_kind_label(status.kind())), + ) + .child( + div() + .text_xs() + .truncate() + .text_color(rgb(colors::MUTED)) + .child(format!("{} local objects", status.local_count())), + ), + ) + .child( + div() + .text_xs() + .font_semibold() + .text_color(rgb(sync_object_state_color(status.state()))) + .child(sync_object_state_label(status.state())), + ) + .into_any_element() +} + +fn connection_label(connection: &SyncConnectionState) -> &'static str { + match connection { + SyncConnectionState::SignedOut => "Signed out", + } +} + +fn sync_object_kind_label(kind: &SyncObjectKind) -> &'static str { + match kind { + SyncObjectKind::Spaces => "Spaces", + SyncObjectKind::Tabs => "Tabs", + SyncObjectKind::Profiles => "Profiles", + SyncObjectKind::History => "History", + SyncObjectKind::PluginSettings => "Plugin settings", + } +} + +fn sync_object_state_label(state: &SyncObjectState) -> &'static str { + match state { + SyncObjectState::LocalOnly => "Local only", + SyncObjectState::PrivacyControlled => "Privacy controlled", + } +} + +fn sync_object_state_color(state: &SyncObjectState) -> u32 { + match state { + SyncObjectState::LocalOnly => colors::MUTED, + SyncObjectState::PrivacyControlled => colors::PRIMARY, + } +} diff --git a/crates/ely_browser_core/src/navigation.rs b/crates/ely_browser_core/src/navigation.rs index aebde5e..3cdcc3d 100644 --- a/crates/ely_browser_core/src/navigation.rs +++ b/crates/ely_browser_core/src/navigation.rs @@ -90,6 +90,10 @@ pub(crate) fn settings_url() -> Result { internal_page_url("ely://settings") } +pub(crate) fn sync_url() -> Result { + internal_page_url("ely://settings/sync") +} + pub(crate) fn settings_page_url(query: &str) -> Result, CoreError> { let normalized_query = query.trim().to_ascii_lowercase(); let Some(url) = settings_page_route(&normalized_query) else { diff --git a/crates/ely_browser_core/src/state.rs b/crates/ely_browser_core/src/state.rs index 24b59d0..31f05ca 100644 --- a/crates/ely_browser_core/src/state.rs +++ b/crates/ely_browser_core/src/state.rs @@ -2,7 +2,7 @@ use std::collections::BTreeMap; use ely_domain::{ ArchivedTab, BrowserTab, DomainError, DownloadEntry, DownloadPolicy, HistoryEntry, Profile, - ProfileId, ProfileKind, Space, SpaceId, TabId, UrlText, + ProfileId, ProfileKind, Space, SpaceId, SyncStatus, TabId, UrlText, }; use crate::CoreError; @@ -12,6 +12,7 @@ mod downloads; mod history; mod plugins; mod profiles; +mod sync; mod tabs; pub use plugins::{InstalledPlugin, PluginAuditAction, PluginAuditEvent}; @@ -47,6 +48,7 @@ pub struct BrowserSnapshot { pub plugin_audit_events: Vec, pub spaces: Vec, pub profiles: Vec, + pub sync_status: SyncStatus, pub active_tab_id: TabId, pub active_space_id: SpaceId, pub active_profile_id: ProfileId, @@ -201,6 +203,7 @@ impl BrowserCore { plugin_audit_events: self.plugin_audit_events.clone(), spaces: self.spaces.clone(), profiles: self.profiles.clone(), + sync_status: self.sync_status(), tabs: self.visible_tabs(), active_tab_id: self.active_tab_id.clone(), active_space_id: self.active_space_id.clone(), diff --git a/crates/ely_browser_core/src/state/commands.rs b/crates/ely_browser_core/src/state/commands.rs index 52028d2..906e75c 100644 --- a/crates/ely_browser_core/src/state/commands.rs +++ b/crates/ely_browser_core/src/state/commands.rs @@ -4,7 +4,7 @@ use crate::{ CoreError, navigation::{ downloads_url, history_url, move_tab_space_name, new_profile_name, new_space_name, - search_url, settings_page_url, settings_url, space_icon, switch_profile_name, + search_url, settings_page_url, settings_url, space_icon, switch_profile_name, sync_url, }, }; @@ -109,6 +109,10 @@ impl BrowserCore { self.open_tab(settings_url()?); Ok(true) } + "sync" | "open-sync-status" | "open sync status" => { + self.open_tab(sync_url()?); + Ok(true) + } "close-tab" => { self.close_active_tab()?; Ok(true) diff --git a/crates/ely_browser_core/src/state/sync.rs b/crates/ely_browser_core/src/state/sync.rs new file mode 100644 index 0000000..271433a --- /dev/null +++ b/crates/ely_browser_core/src/state/sync.rs @@ -0,0 +1,35 @@ +use ely_domain::{SyncObjectKind, SyncObjectState, SyncObjectStatus, SyncStatus}; + +use super::BrowserCore; + +impl BrowserCore { + pub(super) fn sync_status(&self) -> SyncStatus { + SyncStatus::signed_out(vec![ + SyncObjectStatus::new( + SyncObjectKind::Spaces, + self.spaces.len(), + SyncObjectState::LocalOnly, + ), + SyncObjectStatus::new( + SyncObjectKind::Tabs, + self.tabs.len(), + SyncObjectState::LocalOnly, + ), + SyncObjectStatus::new( + SyncObjectKind::Profiles, + self.profiles.len(), + SyncObjectState::LocalOnly, + ), + SyncObjectStatus::new( + SyncObjectKind::History, + self.history_entries.len(), + SyncObjectState::PrivacyControlled, + ), + SyncObjectStatus::new( + SyncObjectKind::PluginSettings, + self.installed_plugins.len(), + SyncObjectState::LocalOnly, + ), + ]) + } +} diff --git a/crates/ely_browser_core/tests/commands.rs b/crates/ely_browser_core/tests/commands.rs index 71f44f2..048bded 100644 --- a/crates/ely_browser_core/tests/commands.rs +++ b/crates/ely_browser_core/tests/commands.rs @@ -96,6 +96,21 @@ fn open_settings_command_opens_settings_page() -> Result<(), Box> { Ok(()) } +#[test] +fn open_sync_status_command_opens_sync_settings_page() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + + core.set_command_query(">open-sync-status"); + let intent = core.submit_command()?; + let active_tab = core.active_tab()?; + + assert_eq!(intent, Some(CommandIntent::Command("open-sync-status".to_string()))); + assert_eq!(active_tab.title(), "Sync Settings"); + assert_eq!(active_tab.url().as_str(), "ely://settings/sync"); + assert_eq!(core.snapshot()?.command_query, ""); + Ok(()) +} + #[test] fn settings_scoped_search_opens_matching_settings_page() -> Result<(), Box> { let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; diff --git a/crates/ely_browser_core/tests/sync.rs b/crates/ely_browser_core/tests/sync.rs new file mode 100644 index 0000000..25027eb --- /dev/null +++ b/crates/ely_browser_core/tests/sync.rs @@ -0,0 +1,29 @@ +use std::error::Error; + +use ely_browser_core::{BrowserCore, InitialBrowserConfig}; +use ely_domain::{SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus, UrlText}; + +#[test] +fn default_sync_status_reflects_local_browser_state() -> Result<(), Box> { + let mut core = BrowserCore::new(InitialBrowserConfig::ely_defaults()?)?; + core.create_space("Research", "R", 0xf54e00)?; + core.open_tab(UrlText::parse("https://example.com/research")?); + + let snapshot = core.snapshot()?; + let status = &snapshot.sync_status; + + assert_eq!(status.connection(), &SyncConnectionState::SignedOut); + assert_eq!(status.pending_objects(), 0); + assert_eq!(status.failed_objects(), 0); + assert_eq!( + status.objects(), + &[ + SyncObjectStatus::new(SyncObjectKind::Spaces, 2, SyncObjectState::LocalOnly), + SyncObjectStatus::new(SyncObjectKind::Tabs, 3, SyncObjectState::LocalOnly), + SyncObjectStatus::new(SyncObjectKind::Profiles, 1, SyncObjectState::LocalOnly), + SyncObjectStatus::new(SyncObjectKind::History, 1, SyncObjectState::PrivacyControlled), + SyncObjectStatus::new(SyncObjectKind::PluginSettings, 0, SyncObjectState::LocalOnly), + ], + ); + Ok(()) +} diff --git a/crates/ely_domain/src/lib.rs b/crates/ely_domain/src/lib.rs index 16aaa4b..6d26e0f 100644 --- a/crates/ely_domain/src/lib.rs +++ b/crates/ely_domain/src/lib.rs @@ -8,6 +8,7 @@ mod plugin; mod profile; mod space; mod split; +mod sync; mod tab; mod url_text; @@ -27,5 +28,8 @@ pub use plugin::{ pub use profile::{Profile, ProfileKind}; pub use space::{ArchivePolicy, Space}; pub use split::{SplitAxis, SplitLayout, SplitPane}; +pub use sync::{ + SyncConnectionState, SyncObjectKind, SyncObjectState, SyncObjectStatus, SyncStatus, +}; pub use tab::{BrowserTab, TabFlags, TabState}; pub use url_text::UrlText; diff --git a/crates/ely_domain/src/sync.rs b/crates/ely_domain/src/sync.rs new file mode 100644 index 0000000..7197118 --- /dev/null +++ b/crates/ely_domain/src/sync.rs @@ -0,0 +1,88 @@ +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SyncConnectionState { + SignedOut, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SyncObjectKind { + Spaces, + Tabs, + Profiles, + History, + PluginSettings, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum SyncObjectState { + LocalOnly, + PrivacyControlled, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyncObjectStatus { + kind: SyncObjectKind, + local_count: usize, + state: SyncObjectState, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct SyncStatus { + connection: SyncConnectionState, + pending_objects: usize, + failed_objects: usize, + objects: Vec, +} + +impl SyncObjectStatus { + #[must_use] + pub fn new(kind: SyncObjectKind, local_count: usize, state: SyncObjectState) -> Self { + Self { kind, local_count, state } + } + + #[must_use] + pub fn kind(&self) -> &SyncObjectKind { + &self.kind + } + + #[must_use] + pub fn local_count(&self) -> usize { + self.local_count + } + + #[must_use] + pub fn state(&self) -> &SyncObjectState { + &self.state + } +} + +impl SyncStatus { + #[must_use] + pub fn signed_out(objects: Vec) -> Self { + Self { + connection: SyncConnectionState::SignedOut, + pending_objects: 0, + failed_objects: 0, + objects, + } + } + + #[must_use] + pub fn connection(&self) -> &SyncConnectionState { + &self.connection + } + + #[must_use] + pub fn pending_objects(&self) -> usize { + self.pending_objects + } + + #[must_use] + pub fn failed_objects(&self) -> usize { + self.failed_objects + } + + #[must_use] + pub fn objects(&self) -> &[SyncObjectStatus] { + &self.objects + } +}