diff --git a/crates/zedra/assets/icons/copy.svg b/crates/zedra/assets/icons/copy.svg new file mode 100644 index 00000000..cf8ad9ce --- /dev/null +++ b/crates/zedra/assets/icons/copy.svg @@ -0,0 +1 @@ + diff --git a/crates/zedra/src/account_view.rs b/crates/zedra/src/account_view.rs new file mode 100644 index 00000000..5ff5f4a7 --- /dev/null +++ b/crates/zedra/src/account_view.rs @@ -0,0 +1,767 @@ +use std::time::Duration; + +use futures::channel::oneshot; +use gpui::prelude::FluentBuilder as _; +use gpui::*; +use gpui_tokio::Tokio; + +use crate::delta::{self, DeltaState}; +use crate::fonts; +use crate::platform_bridge::{self, AlertButton, HapticFeedback}; +use crate::theme; + +#[derive(Clone, Debug)] +pub enum AccountEvent { + Close, +} + +impl EventEmitter for AccountView {} + +/// Bigger than the surrounding detail text so it reads as a control. +const COPY_ICON_SIZE: f32 = 16.0; + +#[derive(Clone, Copy, PartialEq, Eq)] +pub enum CopyField { + Stack, + Device, + Node(uuid::Uuid), +} + +pub struct AccountView { + delta_state: Entity, + deleting: bool, + message: Option, + copied: Option, + /// Set while a push-permission round trip is in flight. + push_busy: bool, + /// Dropping this cancels the pending "copied" reset. + copied_reset: Option>, + nodes_busy: bool, +} + +impl AccountView { + pub fn new(delta_state: Entity, _cx: &mut Context) -> Self { + Self { + delta_state, + deleting: false, + message: None, + copied: None, + push_busy: false, + copied_reset: None, + nodes_busy: false, + } + } + + /// Cached nodes paint immediately; this only refetches once they age out. + pub fn refresh_nodes_if_stale(&mut self, cx: &mut Context) { + if self.delta_state.read(cx).status().nodes_stale { + self.refresh_nodes(cx); + } + } + + fn refresh_nodes(&mut self, cx: &mut Context) { + if self.nodes_busy || !self.delta_state.read(cx).status().signed_in { + return; + } + self.nodes_busy = true; + let delta_state = self.delta_state.clone(); + let snapshot = delta_state.read(cx).snapshot(); + cx.spawn(async move |this, cx| { + let result = Tokio::spawn_result(cx, delta::fetch_nodes(snapshot.clone())).await; + let _ = this.update(cx, |this, cx| { + this.nodes_busy = false; + match result { + Ok(next) => { + let count = next.status().nodes.len(); + let applied = delta_state.update(cx, |state, cx| { + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) + }); + if applied { + tracing::info!(count, "account: cached stack nodes"); + } else { + tracing::info!("account: dropped node list for a previous stack"); + } + } + Err(error) => { + tracing::warn!(error = %error, "account: listing stack nodes failed") + } + } + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + /// Shared with Settings: see `delta::acquire_and_register_push_token`. + fn enable_notifications(&mut self, cx: &mut Context) { + if self.push_busy { + return; + } + self.push_busy = true; + self.message = Some("Requesting notification permission".to_string()); + platform_bridge::trigger_haptic(HapticFeedback::ImpactLight); + let delta_state = self.delta_state.clone(); + cx.spawn(async move |this, cx| { + let result = delta::acquire_and_register_push_token(delta_state, cx, |cx| { + let _ = this.update(cx, |this, cx| { + this.message = Some("Registering push token".to_string()); + cx.notify(); + }); + }) + .await; + let _ = this.update(cx, |this, cx| { + this.push_busy = false; + this.message = match result { + Ok(_) => None, + Err(error) => { + tracing::warn!(error = %error, "account: enabling notifications failed"); + Some(format!("{error:#}")) + } + }; + cx.notify(); + }); + }) + .detach(); + cx.notify(); + } + + fn copy_field(&mut self, field: CopyField, value: String, cx: &mut Context) { + if value.is_empty() || value == "\u{2014}" { + return; + } + cx.write_to_clipboard(ClipboardItem::new_string(value)); + platform_bridge::trigger_haptic(HapticFeedback::ImpactLight); + self.copied = Some(field); + self.copied_reset = Some(cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_millis(1400)) + .await; + let _ = this.update(cx, |this, cx| { + this.copied = None; + cx.notify(); + }); + })); + cx.notify(); + } + + fn confirm_delete(&mut self, cx: &mut Context) { + if self.deleting { + return; + } + platform_bridge::trigger_haptic(HapticFeedback::ImpactLight); + let (tx, rx) = oneshot::channel(); + platform_bridge::show_alert( + "Delete account?", + "This permanently erases your Delta account, stack, registered devices, notifications, and synced activity. This cannot be undone.", + vec![ + AlertButton::destructive("Delete Account"), + AlertButton::cancel("Cancel"), + ], + move |index| { + let _ = tx.send(index); + }, + ); + cx.spawn(async move |this, cx| { + if let Ok(0) = rx.await { + let _ = this.update(cx, |this, cx| this.delete(cx)); + } + }) + .detach(); + } + + fn delete(&mut self, cx: &mut Context) { + self.deleting = true; + self.message = None; + let snapshot = self.delta_state.read(cx).snapshot(); + cx.spawn(async move |this, cx| { + let result = Tokio::spawn_result(cx, delta::delete_account(snapshot.clone())).await; + let _ = this.update(cx, |this, cx| { + this.deleting = false; + match result { + Ok(next) => { + this.delta_state.update(cx, |state, cx| { + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) + }); + cx.emit(AccountEvent::Close); + } + Err(error) => { + this.message = Some(format!("{error:#}")); + cx.notify(); + } + } + }); + }) + .detach(); + cx.notify(); + } + + fn log_out(&mut self, cx: &mut Context) { + let snapshot = self.delta_state.read(cx).snapshot(); + match delta::sign_out(snapshot.clone()) { + Ok(next) => { + self.delta_state.update(cx, |state, cx| { + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) + }); + cx.emit(AccountEvent::Close); + } + Err(error) => { + self.message = Some(format!("{error:#}")); + cx.notify(); + } + } + } +} + +impl Render for AccountView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + // Mirrors the Settings screen shell: it is drawer-mounted too, so it owns + // its own status-bar and home-indicator insets. + let top_inset = platform_bridge::status_bar_inset(); + let bottom_inset = platform_bridge::home_indicator_inset(); + let status = self.delta_state.read(cx).status(); + let email = status + .email + .clone() + .unwrap_or_else(|| "Not signed in".to_string()); + let initial = email + .chars() + .next() + .unwrap_or('Z') + .to_ascii_uppercase() + .to_string(); + let stack = status + .stack_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "\u{2014}".to_string()); + let node = status + .node_id + .map(|id| id.to_string()) + .unwrap_or_else(|| "\u{2014}".to_string()); + let summary = format!( + "Stack {} \u{00b7} Node {}", + short_id(status.stack_id), + short_id(status.node_id) + ); + let device = platform_bridge::device_name().unwrap_or_else(|| "This device".to_string()); + let copied = self.copied; + let stack_value = stack.clone(); + let node_value = node.clone(); + + div() + .id("account-view") + .size_full() + .min_h_0() + .min_w_0() + .bg(rgb(theme::bg_primary(cx))) + .flex() + .flex_col() + .child( + div() + .w_full() + .pt(px(top_inset)) + .px(px(theme::SPACING_MD)) + .pb(px(10.0)) + .flex() + .flex_row() + .items_center() + .justify_between() + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(10.0)) + .child( + div() + .id("account-back-button") + .hit_slop(px(10.0)) + .cursor_pointer() + .on_press(cx.listener(|_this, _event, _window, cx| { + cx.emit(AccountEvent::Close); + })) + .child( + svg() + .path("icons/chevron-left.svg") + .size(px(theme::ICON_SM)) + .text_color(rgb(theme::text_muted(cx))) + .into_any_element(), + ), + ) + .child( + div() + .text_color(rgb(theme::text_primary(cx))) + .text_size(px(theme::FONT_TITLE)) + .font_family(fonts::HEADING_FONT_FAMILY) + .font_weight(FontWeight::MEDIUM) + .child("Account"), + ), + ), + ) + .child( + div() + .id("account-scroll") + .overflow_y_scroll() + .flex_1() + .min_h_0() + .px(px(theme::SPACING_LG)) + .pb(px(bottom_inset + 18.0)) + .child( + div() + .w_full() + .max_w(px(theme::CONTENT_MAX_WIDTH)) + .mx_auto() + .min_w_0() + .flex() + .flex_col() + .gap(px(2.0)) + .child(profile_row(&initial, &email, &summary, cx)) + .child(divider(cx)) + .child(if status.push_registered { + info_row(cx, "Notifications", &push_summary(&status)) + .into_any_element() + } else { + enable_row( + cx, + "account-enable-notifications", + "Notifications", + if self.push_busy { + "Enabling\u{2026}" + } else { + "Tap to enable" + }, + self.push_busy, + cx.listener(|this, _, _, cx| this.enable_notifications(cx)), + ) + .into_any_element() + }) + .child(info_row(cx, "Server", &server_host(&status.base_url))) + .child(copy_row( + cx, + "account-copy-stack", + "Stack", + &stack, + copied == Some(CopyField::Stack), + cx.listener(move |this, _, _, cx| { + this.copy_field(CopyField::Stack, stack_value.clone(), cx) + }), + )) + .child(copy_row( + cx, + "account-copy-device", + &device, + &node, + copied == Some(CopyField::Device), + cx.listener(move |this, _, _, cx| { + this.copy_field(CopyField::Device, node_value.clone(), cx) + }), + )) + .child(divider(cx)) + .child(devices_header(cx, status.nodes.len(), self.nodes_busy)) + .children(status.nodes.iter().map(|stored| { + let id = stored.id; + let value = id.to_string(); + node_row( + cx, + stored, + Some(id) == status.node_id, + copied == Some(CopyField::Node(id)), + cx.listener(move |this, _, _, cx| { + this.copy_field(CopyField::Node(id), value.clone(), cx) + }), + ) + })) + .child(divider(cx)) + .child(action_row( + cx, + "account-log-out", + "Log Out", + false, + false, + cx.listener(|this, _, _, cx| this.log_out(cx)), + )) + // Keep the destructive action off the edge of a + // mis-tap on Log Out. + .child(divider(cx)) + .child(action_row( + cx, + "account-delete", + "Delete Account", + true, + self.deleting, + cx.listener(|this, _, _, cx| this.confirm_delete(cx)), + )) + .when_some(self.message.clone(), |this, message| { + this.child( + div() + .min_w_0() + .text_color(rgb(theme::accent_red(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .whitespace_normal() + .child(message), + ) + }), + ), + ) + } +} + +/// Avatar + identity, matching `settings_view::profile_info_row` metrics. No +/// remote image loading on this path, so the avatar is the account initial. +fn profile_row(initial: &str, email: &str, summary: &str, cx: &App) -> impl IntoElement { + div() + .min_w_0() + .py(px(theme::SPACING_SM)) + .flex() + .flex_row() + .items_center() + .gap(px(theme::SPACING_SM)) + .child( + div() + .flex_shrink_0() + .size(px(32.0)) + .rounded_full() + .bg(rgb(theme::bg_card(cx))) + .border_1() + .border_color(rgb(theme::border_subtle(cx))) + .flex() + .items_center() + .justify_center() + .text_color(rgb(theme::text_secondary(cx))) + .text_size(px(theme::FONT_BODY)) + .font_family(fonts::MONO_FONT_FAMILY) + .font_weight(FontWeight::MEDIUM) + .child(initial.to_string()), + ) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + .overflow_hidden() + .child( + div() + .text_color(rgb(theme::text_secondary(cx))) + .text_size(px(theme::FONT_BODY)) + .font_family(fonts::MONO_FONT_FAMILY) + .font_weight(FontWeight::MEDIUM) + .child(email.to_string()), + ) + .child( + div() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .child(summary.to_string()), + ), + ) +} + +fn divider(cx: &App) -> impl IntoElement { + div() + .my(px(theme::SPACING_XS)) + .border_t_1() + .border_color(rgb(theme::border_subtle(cx))) +} + +fn short_id(id: Option) -> String { + id.map(|id| id.to_string().chars().take(8).collect()) + .unwrap_or_else(|| "\u{2014}".to_string()) +} + +/// "Enabled \u{00b7} APNs (production)" — plain language, no raw enum labels. +fn push_summary(status: &delta::DeltaStatus) -> String { + if !status.push_registered { + return "Not enabled on this device".to_string(); + } + let provider = match status.push_provider.as_deref() { + Some("apns") => "APNs", + Some("fcm") => "FCM", + Some(other) => other, + None => return "Enabled".to_string(), + }; + match status.push_environment.as_deref() { + Some(environment) => format!("Enabled \u{00b7} {provider} ({environment})"), + None => format!("Enabled \u{00b7} {provider}"), + } +} + +fn server_host(base_url: &str) -> String { + base_url + .split_once("://") + .map(|(_, rest)| rest) + .unwrap_or(base_url) + .trim_end_matches('/') + .to_string() +} + +/// Label left, value right on one line. Nothing here is an id. +fn info_row(cx: &App, label: &'static str, value: &str) -> impl IntoElement { + div() + .min_w_0() + .py(px(5.0)) + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(theme::SPACING_SM)) + .child( + div() + .flex_shrink_0() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .child(label), + ) + .child( + div() + .min_w_0() + .text_color(rgb(theme::text_secondary(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .truncate() + .child(value.to_string()), + ) +} + +/// Same shape as `info_row`, but tappable, with a chevron to say so. +fn enable_row( + cx: &App, + id: &'static str, + label: &'static str, + value: &'static str, + disabled: bool, + on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, +) -> Stateful
{ + div() + .id(id) + .min_w_0() + .py(px(5.0)) + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(theme::SPACING_SM)) + .when(!disabled, |this| this.cursor_pointer().on_press(on_press)) + .opacity(if disabled { 0.5 } else { 1.0 }) + .child( + div() + .flex_shrink_0() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .child(label), + ) + .child( + div() + .flex() + .flex_row() + .items_center() + .gap(px(theme::SPACING_XS)) + .min_w_0() + .child( + div() + .min_w_0() + .text_color(rgb(theme::accent_blue(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .truncate() + .child(value), + ) + .child( + svg() + .path("icons/chevron-right.svg") + .size(px(12.0)) + .flex_shrink_0() + .text_color(rgb(theme::text_muted(cx))), + ), + ) +} + +/// "Devices 3" with a quiet marker while the list is refreshing. +fn devices_header(cx: &App, count: usize, busy: bool) -> impl IntoElement { + div() + .min_w_0() + .py(px(5.0)) + .flex() + .flex_row() + .items_center() + .justify_between() + .gap(px(theme::SPACING_SM)) + .child( + div() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .child("Devices"), + ) + .child( + div() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .child(if busy { + "\u{2026}".to_string() + } else { + count.to_string() + }), + ) +} + +/// One registered node. The whole row copies the node id, so it needs no +/// separate control at this density. +fn node_row( + cx: &App, + node: &delta::StoredNode, + is_this_device: bool, + copied: bool, + on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + let mut meta = vec![node.kind.clone()]; + if let Some(joined) = node.joined_date() { + meta.push(joined); + } + if is_this_device { + meta.push("this device".to_string()); + } + div() + .id(SharedString::from(format!("account-node-{}", node.id))) + .min_w_0() + .py(px(5.0)) + .flex() + .flex_row() + .items_center() + .gap(px(theme::SPACING_SM)) + .cursor_pointer() + .on_press(on_press) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + .child( + div() + .text_color(rgb(if is_this_device { + theme::text_primary(cx) + } else { + theme::text_secondary(cx) + })) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .font_weight(FontWeight::MEDIUM) + .truncate() + .child(node.name()), + ) + .child( + div() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .truncate() + .child(meta.join(" \u{00b7} ")), + ), + ) + .child(copy_icon(cx, copied)) +} + +/// The one copy affordance on this screen: a bare glyph, no container, that +/// swaps to a check for a moment after copying. +fn copy_icon(cx: &App, copied: bool) -> impl IntoElement { + svg() + .path(if copied { + "icons/check.svg" + } else { + "icons/copy.svg" + }) + .size(px(COPY_ICON_SIZE)) + .flex_shrink_0() + .text_color(rgb(if copied { + theme::accent_green(cx) + } else { + theme::text_muted(cx) + })) +} + +/// Identifier row. The whole row copies the value, matching the device rows. +fn copy_row( + cx: &App, + id: &'static str, + label: &str, + value: &str, + copied: bool, + on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, +) -> impl IntoElement { + div() + .id(id) + .min_w_0() + .py(px(5.0)) + .flex() + .flex_row() + .items_center() + .gap(px(theme::SPACING_SM)) + .cursor_pointer() + .on_press(on_press) + .child( + div() + .flex_1() + .min_w_0() + .flex() + .flex_col() + .gap(px(1.0)) + .child( + div() + .text_color(rgb(theme::text_muted(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .truncate() + .child(label.to_string()), + ) + .child( + div() + .text_color(rgb(theme::text_secondary(cx))) + .text_size(px(theme::FONT_DETAIL)) + .font_family(fonts::MONO_FONT_FAMILY) + .truncate() + .child(value.to_string()), + ), + ) + .child(copy_icon(cx, copied)) +} + +/// Terminal action, one line. Destructive variant carries the red accent. +fn action_row( + cx: &App, + id: &'static str, + title: &'static str, + destructive: bool, + disabled: bool, + on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, +) -> Stateful
{ + let color = if destructive { + theme::accent_red(cx) + } else { + theme::text_secondary(cx) + }; + div() + .id(id) + .min_w_0() + .py(px(theme::SPACING_SM)) + .flex() + .flex_row() + .items_center() + .when(!disabled, |this| this.cursor_pointer().on_press(on_press)) + .opacity(if disabled { 0.5 } else { 1.0 }) + .child( + div() + .text_color(rgb(color)) + .text_size(px(theme::FONT_BODY)) + .font_family(fonts::MONO_FONT_FAMILY) + .font_weight(FontWeight::MEDIUM) + .child(title), + ) +} diff --git a/crates/zedra/src/app.rs b/crates/zedra/src/app.rs index e331c7b4..5082e988 100644 --- a/crates/zedra/src/app.rs +++ b/crates/zedra/src/app.rs @@ -3,6 +3,7 @@ use std::time::Duration; use gpui::*; use zedra_telemetry::*; +use crate::account_view::{AccountEvent, AccountView}; use crate::app_action::SystemBack; use crate::deeplink::{self, DeeplinkAction}; use crate::fonts; @@ -25,6 +26,7 @@ enum AppScreen { OpenProject, Settings, WebTunnel, + Account, Workspace, } @@ -33,7 +35,9 @@ fn app_view_descriptor(screen: AppScreen) -> Option { // Open project is a Home subscreen; report it as Home. AppScreen::Home | AppScreen::OpenProject => Some(view_telemetry::HOME), // Web tunnel is a Settings subscreen; report it as Settings. - AppScreen::Settings | AppScreen::WebTunnel => Some(view_telemetry::SETTINGS), + AppScreen::Settings | AppScreen::WebTunnel | AppScreen::Account => { + Some(view_telemetry::SETTINGS) + } AppScreen::Workspace => None, } } @@ -49,6 +53,7 @@ pub struct ZedraApp { home_view: Entity, settings_view: Entity, web_tunnel_manager: Entity, + account_view: Entity, open_project_view: Entity, workspaces: Entity, quick_action_drawer: Entity, @@ -196,6 +201,9 @@ impl ZedraApp { subscriptions.push(sub); let web_tunnel_manager = cx.new(|cx| WebTunnelManager::new(workspaces.clone(), cx)); + let account_view = cx.new(|cx| AccountView::new(delta_state.clone(), cx)); + let sub = cx.subscribe(&account_view, Self::on_account_event); + subscriptions.push(sub); let open_project_view = cx.new(|cx| OpenProjectView::new(workspaces.clone(), cx)); let sub = cx.subscribe(&open_project_view, Self::on_open_project_event); @@ -247,6 +255,7 @@ impl ZedraApp { home_view, settings_view, web_tunnel_manager, + account_view, open_project_view, workspaces, quick_action_drawer, @@ -363,6 +372,13 @@ impl ZedraApp { SettingsEvent::NavigateHome => { self.set_screen(AppScreen::Home, cx); } + SettingsEvent::OpenAccount => { + // The view is built once at startup, so the node list refreshes + // on open rather than on construction. + self.account_view + .update(cx, |view, cx| view.refresh_nodes_if_stale(cx)); + self.set_screen(AppScreen::Account, cx); + } SettingsEvent::OpenWebTunnel => { self.set_screen(AppScreen::WebTunnel, cx); } @@ -375,6 +391,15 @@ impl ZedraApp { } } + fn on_account_event( + &mut self, + _: Entity, + _: &AccountEvent, + cx: &mut Context, + ) { + self.set_screen(AppScreen::Settings, cx); + } + fn on_theme_changed( &mut self, _: Entity, @@ -602,6 +627,10 @@ impl ZedraApp { self.set_screen(AppScreen::Settings, cx); true } + AppScreen::Account => { + self.set_screen(AppScreen::Settings, cx); + true + } AppScreen::OpenProject => { if self .open_project_view @@ -640,6 +669,7 @@ impl ZedraApp { AppScreen::Home => self.home_view.clone().into(), AppScreen::Settings => self.settings_view.clone().into(), AppScreen::WebTunnel => self.web_tunnel_manager.clone().into(), + AppScreen::Account => self.account_view.clone().into(), AppScreen::OpenProject => self.open_project_view.clone().into(), AppScreen::Workspace => self .workspaces diff --git a/crates/zedra/src/delta.rs b/crates/zedra/src/delta.rs index c7fcd3ab..c17b4ef6 100644 --- a/crates/zedra/src/delta.rs +++ b/crates/zedra/src/delta.rs @@ -4,13 +4,15 @@ use std::time::Duration; use anyhow::{Context as _, Result, bail}; use base64::{Engine as _, engine::general_purpose::STANDARD_NO_PAD}; -use gpui::{Context, EventEmitter}; +use futures::channel::oneshot; +use gpui::{AsyncApp, Context, Entity, EventEmitter}; +use gpui_tokio::Tokio; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use uuid::Uuid; use zedra_session::signer::ClientSigner; -use crate::platform_bridge; +use crate::{platform_bridge, workspace_state::WorkspaceState}; const DEFAULT_BASE_URL: &str = "https://delta.zedra.dev"; const STORE_DIR: &str = "zedra"; @@ -62,6 +64,20 @@ enum NodeKind { Ios, Android, Host, + Agent, + External, +} + +impl NodeKind { + fn as_str(self) -> &'static str { + match self { + Self::Ios => "ios", + Self::Android => "android", + Self::Host => "host", + Self::Agent => "agent", + Self::External => "external", + } + } } #[derive(Deserialize)] @@ -79,6 +95,46 @@ struct NodeSummary { display_name: Option, #[serde(default)] metadata: Value, + #[serde(default)] + joined_at: Option, +} + +#[derive(Deserialize)] +struct NodeListResponse { + nodes: Vec, +} + +/// One registered node in the stack, cached in `delta.json` so the Account +/// screen paints immediately on a cold start. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub struct StoredNode { + pub id: Uuid, + #[serde(default)] + pub alias: Option, + pub kind: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub joined_at: Option, +} + +impl StoredNode { + /// Alias, then display name, then a short id — never empty. + pub fn name(&self) -> String { + self.alias + .as_deref() + .or(self.display_name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + .map(str::to_string) + .unwrap_or_else(|| self.id.to_string().chars().take(8).collect()) + } + + /// `2026-08-18` from the stored RFC 3339 timestamp. + pub fn joined_date(&self) -> Option { + let joined = self.joined_at.as_deref()?; + joined.split('T').next().map(str::to_string) + } } #[derive(Deserialize)] @@ -129,6 +185,9 @@ pub struct DeltaStatus { pub push_provider: Option, pub push_environment: Option, pub push_registered: bool, + pub nodes: Vec, + /// `true` when the cached node list is missing or past its TTL. + pub nodes_stale: bool, } #[derive(Clone)] @@ -232,6 +291,10 @@ pub struct DeltaState { push_token: Option, #[serde(default)] host_nodes_by_pubkey: HashMap, + #[serde(default)] + nodes: Vec, + #[serde(default)] + nodes_fetched_at: Option, } #[derive(Clone, PartialEq, Serialize, Deserialize)] @@ -257,10 +320,78 @@ impl Default for DeltaState { email: None, push_token: None, host_nodes_by_pubkey: HashMap::new(), + nodes: Vec::new(), + nodes_fetched_at: None, } } } +/// The groups of persisted Delta state an operation may change. Built by +/// diffing what an operation started from against what it returned, so network +/// functions keep returning a whole `DeltaState` and only the merge is precise. +#[derive(Default)] +pub struct DeltaPatch { + base_url: Option, + session: Option, + push_token: Option>, + host_nodes: Option>, + nodes: Option<(Option, Vec, Option)>, +} + +struct SessionFields { + access_token: Option, + refresh_token: Option, + expires_at: Option, + user_id: Option, + stack_id: Option, + node_id: Option, + email: Option, +} + +impl DeltaPatch { + /// The groups that differ between the state an operation started from and + /// the state it produced. + pub fn between(before: &DeltaState, after: &DeltaState) -> Self { + let session_changed = before.access_token != after.access_token + || before.refresh_token != after.refresh_token + || before.expires_at != after.expires_at + || before.user_id != after.user_id + || before.stack_id != after.stack_id + || before.node_id != after.node_id + || before.email != after.email; + Self { + base_url: (before.base_url != after.base_url).then(|| after.base_url.clone()), + session: session_changed.then(|| SessionFields { + access_token: after.access_token.clone(), + refresh_token: after.refresh_token.clone(), + expires_at: after.expires_at.clone(), + user_id: after.user_id, + stack_id: after.stack_id, + node_id: after.node_id, + email: after.email.clone(), + }), + push_token: (before.push_token != after.push_token).then(|| after.push_token.clone()), + host_nodes: (before.host_nodes_by_pubkey != after.host_nodes_by_pubkey) + .then(|| after.host_nodes_by_pubkey.clone()), + nodes: (before.nodes != after.nodes).then(|| { + ( + after.stack_id, + after.nodes.clone(), + after.nodes_fetched_at.clone(), + ) + }), + } + } + + fn is_empty(&self) -> bool { + self.base_url.is_none() + && self.session.is_none() + && self.push_token.is_none() + && self.host_nodes.is_none() + && self.nodes.is_none() + } +} + impl DeltaState { /// Load the persisted state from disk, falling back to defaults. Used to /// seed the shared entity at app launch. @@ -276,38 +407,76 @@ impl DeltaState { self.clone() } - /// Apply a snapshot produced by an async operation back onto the shared - /// entity, notifying observers. - pub fn apply(&mut self, next: DeltaState, cx: &mut Context) { - *self = next; + /// Merge the groups an async operation actually changed. This is the only + /// way persisted state changes: whole-state assignment let two operations in + /// flight overwrite each other's untouched fields. + /// + /// Returns `false` when the patch was dropped as stale. + pub fn merge(&mut self, patch: DeltaPatch, cx: &mut Context) -> bool { + if patch.is_empty() { + return true; + } + let applied = self.merge_fields(patch); + self.persist(); cx.emit(DeltaStateEvent::DeltaStateChanged); cx.notify(); + applied } - /// Apply a snapshot only if the entity still matches the state that - /// produced it. This avoids stale async results overwriting newer user - /// changes like sign-out or push settings edits. - pub fn apply_if_current( - &mut self, - expected: &DeltaState, - next: DeltaState, - cx: &mut Context, - ) -> bool { - if self.matches_client_state(expected) { - self.apply(next, cx); - true - } else { - false + /// The merge rules themselves, free of the entity plumbing so they can be + /// tested directly. `false` means the node group was dropped as stale. + fn merge_fields(&mut self, patch: DeltaPatch) -> bool { + if let Some(base_url) = patch.base_url { + self.base_url = base_url; + } + if let Some(session) = patch.session { + self.access_token = session.access_token; + self.refresh_token = session.refresh_token; + self.expires_at = session.expires_at; + self.user_id = session.user_id; + self.stack_id = session.stack_id; + self.node_id = session.node_id; + self.email = session.email; + } + // A signed-out account owns no stack-scoped data, so a late result from + // the previous session must not repopulate it. + if !self.is_signed_in() { + self.push_token = None; + self.nodes.clear(); + self.nodes_fetched_at = None; + self.host_nodes_by_pubkey.clear(); + return true; + } + if let Some(push_token) = patch.push_token { + self.push_token = push_token; } + if let Some(host_nodes) = patch.host_nodes { + self.host_nodes_by_pubkey = host_nodes; + } + if let Some((stack_id, nodes, fetched_at)) = patch.nodes { + // The cache belongs to one stack; a result for another is stale. + if stack_id.is_some() && stack_id != self.stack_id { + return false; + } + self.nodes = nodes; + self.nodes_fetched_at = fetched_at; + } + true + } + + #[cfg(test)] + fn apply_patch_for_test(&mut self, patch: DeltaPatch) -> bool { + self.merge_fields(patch) } - pub(crate) fn matches_client_state(&self, expected: &DeltaState) -> bool { - self.matches_client_binding_state(expected) && self.push_token == expected.push_token + fn persist(&self) { + if let Err(error) = save_state(self) { + tracing::warn!(error = %error, "delta: persisting state failed"); + } } - /// Compare only the fields that affect the host/client handoff. Push-token - /// registration may mutate DeltaState independently and should not cancel a - /// valid host notification replay. + /// Compare only the fields that affect the host/client handoff, so a + /// push-token write cannot cancel a valid host notification replay. pub(crate) fn matches_client_binding_state(&self, expected: &DeltaState) -> bool { self.base_url == expected.base_url && self.access_token == expected.access_token @@ -319,6 +488,10 @@ impl DeltaState { && self.email == expected.email } + pub(crate) fn is_signed_in(&self) -> bool { + self.access_token.is_some() && self.stack_id.is_some() + } + pub fn status(&self) -> DeltaStatus { DeltaStatus { base_url: self.base_url.clone(), @@ -336,9 +509,25 @@ impl DeltaState { .as_ref() .map(|token| token.registered) .unwrap_or(false), + nodes: self.nodes.clone(), + nodes_stale: self.nodes_are_stale(), } } + fn nodes_are_stale(&self) -> bool { + let Some(fetched_at) = self + .nodes_fetched_at + .as_deref() + .and_then(|value| chrono::DateTime::parse_from_rfc3339(value).ok()) + else { + return true; + }; + let age = chrono::Utc::now().signed_duration_since(fetched_at.with_timezone(&chrono::Utc)); + age.to_std() + .map(|age| age >= NODES_CACHE_TTL) + .unwrap_or(true) + } + /// Stack/node identity to hand to a paired host so it can address push /// notifications at this client. `None` until signed in with a node id. pub fn current_client_info(&self) -> Option { @@ -393,6 +582,14 @@ pub fn sign_out(current: DeltaState) -> Result { Ok(state) } +/// Permanently delete the signed-in Delta account and its server-side data. +/// Paired-host transport identities stay on-device, but Delta host bindings do not. +pub async fn delete_account(mut state: DeltaState) -> Result { + delete_bearer(&mut state, "/v1/me").await?; + WorkspaceState::clear_delta_bindings().map_err(anyhow::Error::msg)?; + sign_out(state) +} + #[derive(Debug, PartialEq, Eq)] pub enum MobileNodeReconcileResult { Skipped, @@ -508,6 +705,73 @@ async fn sign_in_with_oauth( Ok(state) } +/// How long the cached node list stays fresh before the Account screen refetches. +const NODES_CACHE_TTL: Duration = Duration::from_secs(300); + +/// Fetch the stack's registered nodes. Returns the state as it stands after the +/// call so a token refresh triggered by `get_bearer` is not lost. +pub async fn fetch_nodes(mut state: DeltaState) -> Result { + let stack_id = state.stack_id.context("Delta stack is missing")?; + let path = format!("/v1/stacks/{stack_id}/nodes"); + let list = get_bearer::(&mut state, &path) + .await? + .context("Delta stack was not found")?; + state.nodes = list + .nodes + .into_iter() + .map(|node| StoredNode { + id: node.id, + alias: node.alias, + kind: node.kind.as_str().to_string(), + display_name: node.display_name, + joined_at: node.joined_at, + }) + .collect(); + state.nodes_fetched_at = Some(chrono::Utc::now().to_rfc3339()); + save_state(&state)?; + Ok(state) +} + +/// Native permission prompt, then registration of the returned token on Delta, +/// applied onto `delta_state`. Shared by every screen that offers to enable +/// notifications. `Ok(false)` means the request was abandoned before a token +/// arrived, which is silent — the user dismissed it or the screen went away. +pub async fn acquire_and_register_push_token( + delta_state: Entity, + cx: &mut AsyncApp, + on_registering: impl FnOnce(&mut AsyncApp), +) -> Result { + let (tx, rx) = oneshot::channel(); + platform_bridge::request_delta_push_token(move |result| { + let _ = tx.send(result); + }); + let token = match rx.await { + Ok(Ok(token)) => token, + Ok(Err(message)) => bail!(message), + Err(_) => return Ok(false), + }; + on_registering(cx); + let snapshot = delta_state.read_with(cx, |state, _| state.snapshot()); + let next = Tokio::spawn_result( + cx, + register_push_token( + snapshot.clone(), + token.provider, + token.token, + token.environment, + ), + ) + .await?; + // Keep a newer state change from being overwritten by this stale result. + let applied = delta_state.update(cx, |state, cx| { + state.merge(DeltaPatch::between(&snapshot, &next), cx) + }); + if !applied { + tracing::info!("delta: push registration finished after state changed; skipped"); + } + Ok(true) +} + pub async fn register_push_token( mut state: DeltaState, provider: String, @@ -708,6 +972,37 @@ where } } +async fn delete_bearer(state: &mut DeltaState, path: &str) -> Result<()> { + let mut did_refresh = false; + loop { + let access_token = state + .access_token + .as_deref() + .context("Delta auth token is missing")? + .to_string(); + let response = http() + .delete(delta_url(state, path)) + .bearer_auth(access_token) + .send() + .await + .with_context(|| format!("send Delta request {path}"))?; + if response.status() == reqwest::StatusCode::UNAUTHORIZED + && !did_refresh + && state.refresh_token.is_some() + { + did_refresh = true; + refresh_access_token(state).await?; + continue; + } + if response.status().is_success() || response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok(()); + } + let status = response.status(); + let text = response.text().await.unwrap_or_default(); + bail!("Delta request failed: {path} returned HTTP {status}: {text}"); + } +} + async fn refresh_access_token(state: &mut DeltaState) -> Result<()> { let refresh_token = state .refresh_token @@ -928,7 +1223,123 @@ mod tests { use serde_json::json; use uuid::Uuid; - use super::{NodeKind, NodeSummary, mobile_node_matches, normalize_alias_candidate}; + use super::{ + DeltaPatch, DeltaState, NodeKind, NodeSummary, StoredNode, StoredPushToken, + mobile_node_matches, normalize_alias_candidate, + }; + + fn signed_in(stack: Uuid) -> DeltaState { + DeltaState { + access_token: Some("access".into()), + refresh_token: Some("refresh".into()), + stack_id: Some(stack), + user_id: Some(Uuid::from_u128(1)), + ..DeltaState::default() + } + } + + fn node(id: Uuid) -> StoredNode { + StoredNode { + id, + alias: Some("macbook".into()), + kind: "host".into(), + display_name: None, + joined_at: None, + } + } + + #[test] + fn patch_carries_only_the_groups_that_changed() { + let stack = Uuid::from_u128(7); + let before = signed_in(stack); + let mut after = before.clone(); + after.nodes = vec![node(Uuid::from_u128(2))]; + + let patch = DeltaPatch::between(&before, &after); + assert!(patch.nodes.is_some()); + assert!( + patch.session.is_none(), + "untouched session must not be sent" + ); + assert!(patch.push_token.is_none()); + } + + #[test] + fn node_fetch_keeps_credentials_refreshed_mid_flight() { + let stack = Uuid::from_u128(7); + let before = signed_in(stack); + // `get_bearer` refreshed the token while listing nodes. + let mut after = before.clone(); + after.access_token = Some("rotated".into()); + after.refresh_token = Some("rotated-refresh".into()); + after.nodes = vec![node(Uuid::from_u128(2))]; + + let mut live = before.clone(); + let patch = DeltaPatch::between(&before, &after); + // Merge without a Context: exercise the field rules directly. + live.apply_patch_for_test(patch); + assert_eq!(live.access_token.as_deref(), Some("rotated")); + assert_eq!(live.nodes.len(), 1); + } + + #[test] + fn push_registration_does_not_discard_a_concurrent_node_fetch() { + let stack = Uuid::from_u128(7); + let before = signed_in(stack); + let mut fetched = before.clone(); + fetched.nodes = vec![node(Uuid::from_u128(2))]; + + // A push registration landed first, touching only its own group. + let mut live = before.clone(); + live.push_token = Some(StoredPushToken { + provider: "apns".into(), + token: "token".into(), + environment: None, + registered: true, + }); + + live.apply_patch_for_test(DeltaPatch::between(&before, &fetched)); + assert_eq!(live.nodes.len(), 1, "node list must still land"); + assert!(live.push_token.is_some(), "push token must survive"); + } + + #[test] + fn signing_out_drops_stack_scoped_data() { + let stack = Uuid::from_u128(7); + let before = signed_in(stack); + let mut live = before.clone(); + live.nodes = vec![node(Uuid::from_u128(2))]; + live.push_token = Some(StoredPushToken { + provider: "apns".into(), + token: "token".into(), + environment: None, + registered: true, + }); + + let after = DeltaState::default(); + live.apply_patch_for_test(DeltaPatch::between(&before, &after)); + assert!(live.access_token.is_none()); + assert!( + live.nodes.is_empty(), + "cached nodes must not outlive sign-out" + ); + assert!(live.push_token.is_none()); + } + + #[test] + fn a_node_list_for_another_stack_is_rejected() { + let before = signed_in(Uuid::from_u128(7)); + let mut after = before.clone(); + after.stack_id = Some(Uuid::from_u128(9)); + after.nodes = vec![node(Uuid::from_u128(2))]; + + // Only the node group survives the diff onto a live state on stack 7. + let mut live = before.clone(); + let mut patch = DeltaPatch::between(&before, &after); + patch.session = None; + assert!(!live.apply_patch_for_test(patch)); + assert!(live.nodes.is_empty()); + } #[test] fn normalizes_alias_candidates_for_mobile_names() { @@ -951,6 +1362,7 @@ mod tests { "os_version": "26.0", "server_owned": true, }), + joined_at: None, }; assert!(mobile_node_matches( @@ -972,6 +1384,7 @@ mod tests { kind: NodeKind::Android, display_name: Some("Phone".into()), metadata: json!({ "app_version": "1.0(1)" }), + joined_at: None, }; assert!(!mobile_node_matches( diff --git a/crates/zedra/src/lib.rs b/crates/zedra/src/lib.rs index 3c8ecc98..70f26783 100644 --- a/crates/zedra/src/lib.rs +++ b/crates/zedra/src/lib.rs @@ -27,6 +27,7 @@ pub mod ui; pub mod vfx; // Sceens +pub mod account_view; pub mod home_view; pub mod open_project; pub mod settings_view; diff --git a/crates/zedra/src/settings_view.rs b/crates/zedra/src/settings_view.rs index d652c393..20123de7 100644 --- a/crates/zedra/src/settings_view.rs +++ b/crates/zedra/src/settings_view.rs @@ -19,6 +19,7 @@ const PRIVACY_POLICY_URL: &str = "https://zedra.dev/privacy"; #[derive(Clone, Debug)] pub enum SettingsEvent { NavigateHome, + OpenAccount, OpenWebTunnel, DropletToggled(bool), } @@ -36,7 +37,7 @@ pub fn reconcile_delta_on_launch(delta_state: Entity, cx Ok((outcome, next)) => { let applied = delta_state.update(cx, |state, cx| { // Skip launch-time reconciliation if Delta state changed while it was in flight. - state.apply_if_current(&snapshot, next, cx) + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) }); if applied { tracing::info!(?outcome, "Delta mobile node reconciliation completed"); @@ -231,6 +232,7 @@ impl SettingsView { cx.notify(); } + /// Shared with the Account screen: see `delta::acquire_and_register_push_token`. fn request_push_token(&mut self, cx: &mut Context) { if self.delta_busy { return; @@ -239,63 +241,34 @@ impl SettingsView { self.delta_message_target = DeltaMessageTarget::Notifications; self.delta_message = Some("Requesting notification permission".to_string()); platform_bridge::trigger_haptic(HapticFeedback::ImpactLight); - let (tx, rx) = oneshot::channel(); - platform_bridge::request_delta_push_token(move |result| { - let _ = tx.send(result); - }); let delta_state = self.delta_state.clone(); cx.spawn(async move |this, cx| { - let token = match rx.await { - Ok(Ok(token)) => token, - Ok(Err(message)) => { - tracing::error!(error = %message, "Push token acquisition failed before Delta registration"); - return Self::report_error(&this, cx, message); - } - Err(_) => return, - }; - let _ = this.update(cx, |this, cx| { - this.delta_message = Some("Registering push token".to_string()); - cx.notify(); - }); - let snapshot = delta_state.read_with(cx, |state, _| state.snapshot()); - let result = Tokio::spawn_result( - cx, - delta::register_push_token( - snapshot.clone(), - token.provider, - token.token, - token.environment, - ), - ) + let result = delta::acquire_and_register_push_token(delta_state, cx, |cx| { + let _ = this.update(cx, |this, cx| { + this.delta_message = Some("Registering push token".to_string()); + cx.notify(); + }); + }) .await; - Self::apply_delta_result( - &this, - &delta_state, - cx, - snapshot, - result, - DeltaMessageTarget::Notifications, - ); + match result { + Ok(_) => { + let _ = this.update(cx, |this, cx| { + this.delta_busy = false; + this.delta_message_target = DeltaMessageTarget::Notifications; + this.delta_message = None; + cx.notify(); + }); + } + Err(error) => { + tracing::error!(error = %error, "Delta push token registration failed"); + Self::report_error(&this, cx, format!("{error:#}")); + } + } }) .detach(); cx.notify(); } - fn confirm_logout(&mut self, cx: &mut Context) { - self.delta_message_target = DeltaMessageTarget::Profile; - let snapshot = self.delta_state.read(cx).snapshot(); - match delta::sign_out(snapshot) { - Ok(next) => { - self.delta_state - .update(cx, |state, cx| state.apply(next, cx)); - self.delta_busy = false; - self.delta_message = Some("Signed out of Delta".to_string()); - } - Err(error) => self.finish_delta_error(format!("{error:#}")), - } - cx.notify(); - } - /// Apply a completed network result onto the shared entity, clearing the /// busy state, or surface the error. fn apply_delta_result( @@ -310,7 +283,7 @@ impl SettingsView { Ok(next) => { let applied = delta_state.update(cx, |state, cx| { // Keep newer Delta state changes from being overwritten by a stale async result. - state.apply_if_current(&snapshot, next, cx) + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) }); let _ = this.update(cx, |this, cx| { this.delta_busy = false; @@ -385,28 +358,6 @@ impl SettingsView { } } - fn show_logout_confirmation(&self, cx: &mut Context) { - platform_bridge::trigger_haptic(HapticFeedback::ImpactLight); - let (tx, rx) = oneshot::channel(); - platform_bridge::show_alert( - "Log out of Delta?", - "", - vec![ - AlertButton::destructive("Log Out"), - AlertButton::cancel("Cancel"), - ], - move |button_index| { - let _ = tx.send(button_index); - }, - ); - cx.spawn(async move |this, cx| { - if let Ok(0) = rx.await { - let _ = this.update(cx, |this, cx| this.confirm_logout(cx)); - } - }) - .detach(); - } - fn set_theme_preference(&self, preference: ThemePreference, cx: &mut Context) { platform_bridge::trigger_haptic(HapticFeedback::SelectionChanged); self.theme_state.update(cx, |state, cx| { @@ -695,9 +646,9 @@ impl Render for SettingsView { profile_initial, profile_title, profile_summary, - Some(cx.listener(|this, _event, _window, cx| { - this.show_logout_confirmation(cx); - })), + cx.listener(|_this, _event, _window, cx| { + cx.emit(SettingsEvent::OpenAccount); + }), )) }) .when(!signed_in, |this| { @@ -1252,7 +1203,7 @@ fn profile_info_row( initials: impl Into, title: impl Into, description: impl Into, - on_logout: Option, + on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, ) -> Stateful
{ let initials = initials.into(); let title = title.into(); @@ -1307,33 +1258,15 @@ fn profile_info_row( ), ); - if let Some(on_logout) = on_logout { - row = row.child(logout_button(cx, on_logout)); - } - - row -} - -fn logout_button( - cx: &App, - on_press: impl Fn(&PressEvent, &mut Window, &mut App) + 'static, -) -> Stateful
{ - div() - .id("settings-delta-logout") - .flex_none() - .size(px(16.0)) - .flex() - .items_center() - .justify_center() - .cursor_pointer() - .hit_slop(px(14.0)) - .on_press(on_press) - .child( + row = row.cursor_pointer().on_press(on_press).child( + div().pl(px(8.0)).child( svg() - .path("icons/log-out.svg") - .size(px(12.0)) - .text_color(rgb(theme::accent_red(cx))), - ) + .path("icons/chevron-right.svg") + .size(px(theme::ICON_SM)) + .text_color(rgb(theme::text_muted(cx))), + ), + ); + row } fn short_id(id: uuid::Uuid) -> String { diff --git a/crates/zedra/src/workspace.rs b/crates/zedra/src/workspace.rs index dc0467d7..cb1d9b48 100644 --- a/crates/zedra/src/workspace.rs +++ b/crates/zedra/src/workspace.rs @@ -20,7 +20,7 @@ use crate::agent_detail::AgentDetail; use crate::agent_manage::AgentManage; use crate::agent_picker::AgentPicker; use crate::agent_sessions::AgentSessions; -use crate::delta::{ClientDeltaInfo, DeltaState}; +use crate::delta::{ClientDeltaInfo, DeltaPatch, DeltaState}; use crate::editor::git_sidebar::GitFileSection; use crate::file_search::{FileSearchEvent, FileSearchPanel}; use crate::pending::{SharedPendingSlot, shared_pending_slot, spawn_periodic_task}; @@ -1442,7 +1442,7 @@ impl Workspace { Ok((Some(result), next)) => { let applied = delta_state.update(cx, |state, cx| { // Skip stale host registration results if Delta auth state changed mid-flight. - state.apply_if_current(&snapshot, next, cx) + state.merge(DeltaPatch::between(&snapshot, &next), cx) }); let _ = workspace.update(cx, |ws, cx| { ws.delta_host_reconciling = false; diff --git a/crates/zedra/src/workspace_state.rs b/crates/zedra/src/workspace_state.rs index b09fd393..2380e542 100644 --- a/crates/zedra/src/workspace_state.rs +++ b/crates/zedra/src/workspace_state.rs @@ -622,6 +622,18 @@ impl WorkspaceState { Ok(()) } + /// Forget Delta's server-side host mappings without removing paired hosts. + pub fn clear_delta_bindings() -> Result<(), String> { + let _guard = workspace_store_lock() + .lock() + .map_err(|e| format!("Failed to lock workspace store: {e}"))?; + let mut store = WorkspaceStore::load()?; + if store.clear_delta_bindings() { + store.save()?; + } + Ok(()) + } + pub fn now_u64() -> u64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -709,6 +721,19 @@ impl WorkspaceStore { self.workspaces.retain(|w| w.endpoint_addr != endpoint_addr); self.workspaces.len() != before } + + fn clear_delta_bindings(&mut self) -> bool { + let mut changed = false; + for workspace in &mut self.workspaces { + if workspace.delta_host_pubkey.take().is_some() { + changed = true; + } + if workspace.delta_host_node_id.take().is_some() { + changed = true; + } + } + changed + } } impl EventEmitter for WorkspaceState {} @@ -1158,6 +1183,30 @@ mod tests { assert_eq!(loaded[0].delta_host_node_id, Some(delta_host_node_id)); } + #[test] + fn clear_delta_bindings_preserves_paired_workspace() { + let _guard = set_test_data_directory("clear-delta-bindings"); + WorkspaceState::upsert(WorkspaceState { + endpoint_addr: "endpoint-a".into(), + custom_name: Some("Paired Mac".into()), + delta_host_pubkey: Some([7u8; 32]), + delta_host_node_id: Some( + Uuid::parse_str("33333333-3333-3333-3333-333333333333").unwrap(), + ), + ..Default::default() + }) + .unwrap(); + + WorkspaceState::clear_delta_bindings().unwrap(); + + let loaded = WorkspaceState::load().unwrap(); + assert_eq!(loaded.len(), 1); + assert_eq!(loaded[0].endpoint_addr, "endpoint-a"); + assert_eq!(loaded[0].custom_name.as_deref(), Some("Paired Mac")); + assert_eq!(loaded[0].delta_host_pubkey, None); + assert_eq!(loaded[0].delta_host_node_id, None); + } + #[test] fn remove_by_endpoint_addr_persists_removal() { let _guard = set_test_data_directory("remove-persists-removal"); diff --git a/docs/MANUAL_TEST.md b/docs/MANUAL_TEST.md index 5e8a05df..f2fde1ce 100644 --- a/docs/MANUAL_TEST.md +++ b/docs/MANUAL_TEST.md @@ -96,8 +96,36 @@ Verifies a `zedra/rpc/3` host still serves a pre-bump app. 2. Expected: `Profile` and `Notifications` appear above `Appearance` 3. Start sign-in or notification registration 4. Expected: progress or error status replaces the relevant row description, with no separate `Status` row and no combined status/description text -5. Sign in to Delta, then tap the logout icon in the profile row -6. Expected: a native confirmation alert appears; cancelling keeps the profile signed in, and confirming returns the profile row to `Sign In` +5. Sign in to Delta, then tap the profile row +6. Expected: the Account screen opens, showing the signed-in email, notification state, and the stack and device identifiers + +### Account screen + +1. Sign in to Delta, then open Settings and tap the profile row +2. Expected: the header title matches the Settings title size, and no content sits under the status bar +3. Tap the `Stack`, device, and any `Devices` row +4. Expected: each copies its full identifier, the icon turns into a green check for about a second, and pasting elsewhere yields the whole id with no trailing characters +5. Compare the `Devices` list against `zedra stack list` on the paired host +6. Expected: the same nodes appear, this device is marked, and each row shows its kind and joined date +7. Leave the screen and reopen it within five minutes +8. Expected: the list paints immediately with no refetch; relaunching the app also shows it before any network call +9. With notifications not yet enabled, tap the `Notifications` row +10. Expected: the native permission prompt appears, and the row settles on `Enabled` with the provider once registration finishes +11. Tap `Log Out` +12. Expected: the app signs out immediately and returns to Settings, with the profile row back to `Sign In` + +### Account deletion + +1. Sign in with a throwaway account, then open Account and tap `Delete Account` +2. Expected: a native destructive alert appears; cancelling leaves the account signed in and unchanged +3. Confirm the deletion +4. Expected: the app returns to Settings signed out, and `delta.json` no longer holds tokens, stack, node, or cached nodes +5. Run `zedra stack list` against the deleted stack +6. Expected: the stack is gone and the CLI reports the account no longer exists +7. Confirm saved workspaces still appear on Home +8. Expected: pairings survive — only the Delta host binding fields were cleared +9. Sign in again with the same provider +10. Expected: a fresh stack and node are created, and the email still populates the profile row ### Mobile node metadata reconciliation diff --git a/vendor/zed b/vendor/zed index 8bcfdccc..fbf1db19 160000 --- a/vendor/zed +++ b/vendor/zed @@ -1 +1 @@ -Subproject commit 8bcfdccca639421b5de505e6acf839e2c332755d +Subproject commit fbf1db1954821f057cb187ba1c4ef4335d6f7495