`SyncConnectionState` was a one-variant enum (`SignedOut`), so the
Sync page rendered "Local-only · sign-in coming soon" even after the
bearer token landed on disk and the upload thread completed. The
state machine now mirrors the actual lifecycle.
What lands:
- `SyncConnectionState` gains `SignedIn`, `AwaitingDeviceApproval`,
`SyncReady { last_synced_at_secs }`, `SyncError { message }`.
`SyncObjectState::Synced` joins the per-object enum so individual
rows can advertise "Synced" once a successful upload lands.
- `BrowserCore` stores the current `SyncConnectionState` and exposes
`set_sync_connection_state`. `sync_status` now propagates the live
state into the snapshot the UI reads.
- `ElyShell::probe_initial_sync_state` inspects
`<profile_data>/sync/bearer.token` synchronously at construction
so the first render of the sync page is honest about whether the
user is signed in.
- A `std::sync::mpsc` channel ferries upload outcomes from the
off-thread worker back to the shell; the existing 8 ms tick
drains it and stamps `core.set_sync_connection_state` with the
freshest result. The UI now shows "Signed in · awaiting first
sync", "Synced · last upload Xm ago", "Sync error · …", and the
worker-special "Signed in · waiting for device approval" when the
server returns `device_not_approved`.
377 lines
12 KiB
Rust
377 lines
12 KiB
Rust
use ely_browser_core::BrowserSnapshot;
|
|
use ely_design_system::colors;
|
|
use ely_domain::{
|
|
SyncConnectionState, SyncObjectKind, SyncObjectPolicy, SyncObjectState, SyncObjectStatus,
|
|
};
|
|
use gpui::{
|
|
AnyElement, Context, FontWeight, InteractiveElement, IntoElement, ParentElement, SharedString,
|
|
StatefulInteractiveElement, Styled, div, prelude::FluentBuilder, px, rgb, rgba,
|
|
};
|
|
use gpui_component::{IconName, scroll::ScrollableElement};
|
|
|
|
use crate::brand::SYNC_SERVICE_NAME;
|
|
|
|
use super::{ElyShell, render_canvas_surface};
|
|
use crate::shell::chrome::SERIF_FAMILY;
|
|
|
|
impl ElyShell {
|
|
pub(super) fn render_sync_page(
|
|
&mut self,
|
|
snapshot: &BrowserSnapshot,
|
|
cx: &mut Context<Self>,
|
|
) -> AnyElement {
|
|
render_canvas_surface(
|
|
div().size_full().pt(px(40.0)).px(px(56.0)).pb(px(32.0)).flex().justify_center().child(
|
|
div()
|
|
.max_w(px(960.0))
|
|
.grid()
|
|
.grid_cols(2)
|
|
.gap(px(32.0))
|
|
.child(render_left_column(snapshot, cx))
|
|
.child(render_right_column(snapshot, cx)),
|
|
),
|
|
)
|
|
}
|
|
}
|
|
|
|
fn render_left_column(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.items_start()
|
|
.gap(px(20.0))
|
|
.child(render_status_pill(snapshot))
|
|
.child(render_serif_headline())
|
|
.child(render_intro_paragraph())
|
|
.child(render_metrics_card(snapshot, cx))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_status_pill(snapshot: &BrowserSnapshot) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap(px(8.0))
|
|
.px(px(12.0))
|
|
.py(px(5.0))
|
|
.rounded(px(999.0))
|
|
.bg(rgba(PILL_BG))
|
|
.text_size(px(11.0))
|
|
.text_color(rgb(colors::ink_3()))
|
|
.child(div().text_color(rgb(colors::accent())).child(IconName::Globe))
|
|
.child(format!(
|
|
"{SYNC_SERVICE_NAME} · {}",
|
|
connection_label(snapshot.sync_status.connection())
|
|
))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_serif_headline() -> AnyElement {
|
|
div()
|
|
.font_family(SERIF_FAMILY)
|
|
.text_size(px(46.0))
|
|
.font_weight(FontWeight(400.0))
|
|
.text_color(rgb(colors::ink()))
|
|
.child("Your tabs, on every device.")
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_intro_paragraph() -> AnyElement {
|
|
div()
|
|
.max_w(px(440.0))
|
|
.text_size(px(14.0))
|
|
.text_color(rgb(colors::ink_2()))
|
|
.child(
|
|
"ELY keeps tabs, workspaces, pinned items, and history mirrored across your \
|
|
devices — encrypted in your hands and replayed at the edge.",
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_metrics_card(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
|
|
div()
|
|
.max_w(px(380.0))
|
|
.p(px(20.0))
|
|
.rounded(px(16.0))
|
|
.bg(rgba(CARD_BG))
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(16.0))
|
|
.child(div().text_size(px(12.5)).text_color(rgb(colors::ink_3())).child("Local queue"))
|
|
.child(
|
|
div()
|
|
.grid()
|
|
.grid_cols(2)
|
|
.gap(px(12.0))
|
|
.child(render_metric(
|
|
"Pending",
|
|
snapshot.sync_status.pending_objects(),
|
|
colors::ink(),
|
|
))
|
|
.child(render_metric(
|
|
"Failed",
|
|
snapshot.sync_status.failed_objects(),
|
|
colors::error(),
|
|
)),
|
|
)
|
|
.child(render_reset_button(cx))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_metric(label: &'static str, value: usize, color: u32) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.child(div().text_size(px(10.5)).text_color(rgb(colors::ink_4())).child(label))
|
|
.child(
|
|
div()
|
|
.text_size(px(20.0))
|
|
.font_weight(FontWeight(500.0))
|
|
.text_color(rgb(color))
|
|
.child(value.to_string()),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_reset_button(cx: &mut Context<ElyShell>) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.gap(px(8.0))
|
|
.child(
|
|
div()
|
|
.id(SharedString::from("sync-upload"))
|
|
.px(px(12.0))
|
|
.py(px(7.0))
|
|
.rounded(px(8.0))
|
|
.bg(rgba(colors::accent()))
|
|
.text_size(px(12.0))
|
|
.font_weight(FontWeight(500.0))
|
|
.text_color(rgb(0xfff5e6))
|
|
.cursor_pointer()
|
|
.hover(|style| style.opacity(0.92))
|
|
.active(|style| style.opacity(0.78))
|
|
.on_click(cx.listener(|shell, _, _, cx| shell.trigger_cloud_sync_upload(cx)))
|
|
.child("Sync now"),
|
|
)
|
|
.child(
|
|
div()
|
|
.id(SharedString::from("sync-reset"))
|
|
.px(px(12.0))
|
|
.py(px(7.0))
|
|
.rounded(px(8.0))
|
|
.bg(rgba(BUTTON_BG))
|
|
.text_size(px(12.0))
|
|
.font_weight(FontWeight(500.0))
|
|
.text_color(rgb(colors::ink_2()))
|
|
.cursor_pointer()
|
|
.hover(|style| style.bg(rgba(BUTTON_BG_HOVER)))
|
|
.active(|style| style.opacity(0.85))
|
|
.on_click(cx.listener(|shell, _, _, cx| shell.reset_sync_settings(cx)))
|
|
.child("Reset to defaults"),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_right_column(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(14.0))
|
|
.child(render_what_syncs_card(snapshot, cx))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_what_syncs_card(snapshot: &BrowserSnapshot, cx: &mut Context<ElyShell>) -> AnyElement {
|
|
div()
|
|
.p(px(18.0))
|
|
.rounded(px(16.0))
|
|
.bg(rgba(CARD_BG))
|
|
.flex()
|
|
.flex_col()
|
|
.gap(px(12.0))
|
|
.max_h(px(640.0))
|
|
.overflow_y_scrollbar()
|
|
.child(
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap(px(8.0))
|
|
.child(
|
|
div()
|
|
.text_size(px(13.0))
|
|
.font_weight(FontWeight(500.0))
|
|
.text_color(rgb(colors::ink()))
|
|
.child("What syncs"),
|
|
)
|
|
.child(
|
|
div()
|
|
.text_size(px(11.0))
|
|
.text_color(rgb(colors::ink_3()))
|
|
.child(format!("{} kinds tracked", snapshot.sync_status.objects().len())),
|
|
),
|
|
)
|
|
.child(
|
|
div().flex().flex_col().gap(px(2.0)).children(
|
|
snapshot
|
|
.sync_status
|
|
.objects()
|
|
.iter()
|
|
.enumerate()
|
|
.map(|(index, status)| render_sync_object_row(index, status, cx)),
|
|
),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_sync_object_row(
|
|
index: usize,
|
|
status: &SyncObjectStatus,
|
|
cx: &mut Context<ElyShell>,
|
|
) -> AnyElement {
|
|
div()
|
|
.flex()
|
|
.items_center()
|
|
.gap(px(10.0))
|
|
.py(px(8.0))
|
|
.border_b_1()
|
|
.border_color(rgba(colors::divider()))
|
|
.child(render_state_dot(status.state()))
|
|
.child(
|
|
div()
|
|
.flex_1()
|
|
.min_w_0()
|
|
.flex()
|
|
.flex_col()
|
|
.gap_1()
|
|
.child(
|
|
div()
|
|
.text_size(px(13.0))
|
|
.font_weight(FontWeight(500.0))
|
|
.text_color(rgb(colors::ink()))
|
|
.child(sync_object_kind_label(status.kind())),
|
|
)
|
|
.child(div().text_size(px(11.0)).text_color(rgb(colors::ink_4())).child(format!(
|
|
"{} local · {}",
|
|
status.local_count(),
|
|
sync_object_state_label(status.state())
|
|
))),
|
|
)
|
|
.child(render_policy_toggle(index, status, cx))
|
|
.into_any_element()
|
|
}
|
|
|
|
fn render_state_dot(state: SyncObjectState) -> AnyElement {
|
|
let color = match state {
|
|
SyncObjectState::LocalOnly => colors::ink_4(),
|
|
SyncObjectState::Paused => colors::ink_5(),
|
|
SyncObjectState::PrivacyControlled => colors::accent(),
|
|
SyncObjectState::Synced => colors::success(),
|
|
};
|
|
|
|
div().size(px(8.0)).rounded_full().bg(rgb(color)).into_any_element()
|
|
}
|
|
|
|
fn render_policy_toggle(
|
|
index: usize,
|
|
status: &SyncObjectStatus,
|
|
cx: &mut Context<ElyShell>,
|
|
) -> AnyElement {
|
|
let enabled = status.policy() == SyncObjectPolicy::Enabled;
|
|
let next_policy = if enabled { SyncObjectPolicy::Paused } else { SyncObjectPolicy::Enabled };
|
|
let kind = status.kind();
|
|
let track_color = if enabled { colors::accent() } else { 0x281e1426 };
|
|
|
|
div()
|
|
.id(SharedString::from(format!("sync-policy-{index}")))
|
|
.w(px(34.0))
|
|
.h(px(20.0))
|
|
.rounded_full()
|
|
.bg(rgba(track_color))
|
|
.p(px(2.0))
|
|
.cursor_pointer()
|
|
.hover(|style| style.opacity(0.9))
|
|
.active(|style| style.opacity(0.78))
|
|
.on_click(cx.listener(move |shell, _, _, cx| {
|
|
shell.set_sync_object_policy(kind, next_policy, cx);
|
|
}))
|
|
.child(
|
|
div()
|
|
.size(px(16.0))
|
|
.rounded_full()
|
|
.bg(rgb(0xffffff))
|
|
.when(enabled, |this| this.ml(px(14.0))),
|
|
)
|
|
.into_any_element()
|
|
}
|
|
|
|
fn connection_label(connection: &SyncConnectionState) -> String {
|
|
match connection {
|
|
SyncConnectionState::SignedOut => "Local-only · drop a session token to enable".to_string(),
|
|
SyncConnectionState::SignedIn => "Signed in · awaiting first sync".to_string(),
|
|
SyncConnectionState::AwaitingDeviceApproval => {
|
|
"Signed in · waiting for device approval".to_string()
|
|
}
|
|
SyncConnectionState::SyncReady { last_synced_at_secs } => {
|
|
format!("Synced · last upload {}", relative_time_since(*last_synced_at_secs))
|
|
}
|
|
SyncConnectionState::SyncError { message } => {
|
|
format!("Sync error · {}", short_message(message))
|
|
}
|
|
}
|
|
}
|
|
|
|
fn relative_time_since(secs: u64) -> String {
|
|
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
|
let when = UNIX_EPOCH + Duration::from_secs(secs);
|
|
let elapsed = SystemTime::now().duration_since(when).unwrap_or_default();
|
|
let total_secs = elapsed.as_secs();
|
|
if total_secs < 60 {
|
|
return format!("{total_secs}s ago");
|
|
}
|
|
if total_secs < 3600 {
|
|
return format!("{}m ago", total_secs / 60);
|
|
}
|
|
if total_secs < 86400 {
|
|
return format!("{}h ago", total_secs / 3600);
|
|
}
|
|
format!("{}d ago", total_secs / 86400)
|
|
}
|
|
|
|
fn short_message(message: &str) -> String {
|
|
const MAX_LEN: usize = 72;
|
|
if message.len() <= MAX_LEN {
|
|
return message.to_string();
|
|
}
|
|
let truncated: String = message.chars().take(MAX_LEN - 1).collect();
|
|
format!("{truncated}…")
|
|
}
|
|
|
|
fn sync_object_kind_label(kind: SyncObjectKind) -> &'static str {
|
|
match kind {
|
|
SyncObjectKind::Spaces => "Spaces",
|
|
SyncObjectKind::Tabs => "Tabs & pinned",
|
|
SyncObjectKind::Bookmarks => "Bookmarks",
|
|
SyncObjectKind::Notes => "Notes",
|
|
SyncObjectKind::ReadingList => "Reading list",
|
|
SyncObjectKind::Profiles => "Profiles",
|
|
SyncObjectKind::SitePermissions => "Site permissions",
|
|
SyncObjectKind::History => "History",
|
|
SyncObjectKind::PluginSettings => "Plugins",
|
|
}
|
|
}
|
|
|
|
fn sync_object_state_label(state: SyncObjectState) -> &'static str {
|
|
match state {
|
|
SyncObjectState::LocalOnly => "Local only",
|
|
SyncObjectState::Paused => "Paused",
|
|
SyncObjectState::PrivacyControlled => "Privacy controlled",
|
|
SyncObjectState::Synced => "Synced",
|
|
}
|
|
}
|
|
|
|
const PILL_BG: u32 = 0xffffffb3;
|
|
const CARD_BG: u32 = 0xffffffd9;
|
|
const BUTTON_BG: u32 = 0xffffffd9;
|
|
const BUTTON_BG_HOVER: u32 = 0xffffffeb;
|