From b4805a1a824706b28227f63b47de2ee36b1e2fdf Mon Sep 17 00:00:00 2001 From: Tan Le <104194494+tanlethanh@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:20:44 +0700 Subject: [PATCH 1/3] feat(app): add account screen Settings -> Profile now opens a dedicated Account screen, required so a user can reach account deletion in-app (App Store Review 5.1.1(v)). The screen shows the signed-in identity with an avatar, notification state in plain language, and the stack and device identifiers. Every identifier row copies its value on tap. It also lists the stack's registered nodes, marking this device, cached in delta.json so the list paints on a cold start and only refetches past a 5 minute TTL. The node cache merges on stack id alone rather than through apply_if_current: it is derived data, so an unrelated token refresh mid-flight must not discard it. NodeKind gains the agent and external variants the server already sends, since one unknown kind would fail the whole list. Push-token registration moves to delta::acquire_and_register_push_token, shared with Settings instead of duplicated per screen. Co-Authored-By: Claude Opus 5 (1M context) --- crates/zedra/assets/icons/copy.svg | 1 + crates/zedra/src/account_view.rs | 765 ++++++++++++++++++++++++++++ crates/zedra/src/app.rs | 32 +- crates/zedra/src/delta.rs | 211 +++++++- crates/zedra/src/lib.rs | 1 + crates/zedra/src/settings_view.rs | 135 ++--- crates/zedra/src/workspace_state.rs | 49 ++ vendor/zed | 2 +- 8 files changed, 1091 insertions(+), 105 deletions(-) create mode 100644 crates/zedra/assets/icons/copy.svg create mode 100644 crates/zedra/src/account_view.rs 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..dbefdfdb --- /dev/null +++ b/crates/zedra/src/account_view.rs @@ -0,0 +1,765 @@ +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)).await; + let _ = this.update(cx, |this, cx| { + this.nodes_busy = false; + match result { + Ok((stack_id, nodes)) => { + let count = nodes.len(); + let applied = delta_state + .update(cx, |state, cx| state.apply_nodes(stack_id, nodes, 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.apply_if_current(&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) { + Ok(next) => { + self.delta_state + .update(cx, |state, cx| state.apply(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..451d84a9 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,6 +320,8 @@ impl Default for DeltaState { email: None, push_token: None, host_nodes_by_pubkey: HashMap::new(), + nodes: Vec::new(), + nodes_fetched_at: None, } } } @@ -287,6 +352,28 @@ impl DeltaState { /// 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. + /// Merge a fetched node list into the live state. Keyed on the stack alone — + /// this is derived cache data, so an unrelated token refresh must not + /// discard it the way `apply_if_current` would. + pub fn apply_nodes( + &mut self, + stack_id: Uuid, + nodes: Vec, + cx: &mut Context, + ) -> bool { + if self.stack_id != Some(stack_id) { + return false; + } + self.nodes = nodes; + self.nodes_fetched_at = Some(chrono::Utc::now().to_rfc3339()); + if let Err(error) = save_state(self) { + tracing::warn!(error = %error, "delta: persisting the node cache failed"); + } + cx.emit(DeltaStateEvent::DeltaStateChanged); + cx.notify(); + true + } + pub fn apply_if_current( &mut self, expected: &DeltaState, @@ -336,9 +423,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 +496,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 +619,69 @@ 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 stack the list belongs to so +/// the caller can reject it if the account changed mid-flight. +pub async fn fetch_nodes(mut state: DeltaState) -> Result<(Uuid, Vec)> { + 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")?; + let 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(); + Ok((stack_id, nodes)) +} + +/// 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.apply_if_current(&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 +882,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 @@ -951,6 +1156,7 @@ mod tests { "os_version": "26.0", "server_owned": true, }), + joined_at: None, }; assert!(mobile_node_matches( @@ -972,6 +1178,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..63092fe6 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), } @@ -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( @@ -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_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/vendor/zed b/vendor/zed index 8bcfdccc..fbf1db19 160000 --- a/vendor/zed +++ b/vendor/zed @@ -1 +1 @@ -Subproject commit 8bcfdccca639421b5de505e6acf839e2c332755d +Subproject commit fbf1db1954821f057cb187ba1c4ef4335d6f7495 From 534ce3db4cc284a9e1509780b6ad10eb770198e1 Mon Sep 17 00:00:00 2001 From: Tan Le <104194494+tanlethanh@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:42:30 +0700 Subject: [PATCH 2/3] fix(app): keep refreshed tokens when caching nodes `get_bearer` can refresh and persist rotated credentials mid-request, but `fetch_nodes` returned only the node list, so `apply_nodes` saved the live entity back over them with the expired access token. `fetch_nodes` now returns the state as it stands after the call, and `apply_nodes` adopts the refreshed credentials when the live state still matches the one the fetch started from. Deletion no longer routes through `apply_if_current`, which could reject the signed-out state after `DELETE /v1/me` had already succeeded and leave the app signed in to an erased account until relaunch. `apply_deleted` guards on the stack alone, with a sign-out fallback. Adds the Account screen, deletion, copy, node-list, and notification steps to `docs/MANUAL_TEST.md`, replacing the profile-row logout steps the screen replaced. Co-Authored-By: Claude Opus 5 (1M context) --- crates/zedra/src/account_view.rs | 24 ++++++++++++------- crates/zedra/src/delta.rs | 40 +++++++++++++++++++++++++++----- docs/MANUAL_TEST.md | 32 +++++++++++++++++++++++-- 3 files changed, 80 insertions(+), 16 deletions(-) diff --git a/crates/zedra/src/account_view.rs b/crates/zedra/src/account_view.rs index dbefdfdb..0bccb596 100644 --- a/crates/zedra/src/account_view.rs +++ b/crates/zedra/src/account_view.rs @@ -67,14 +67,15 @@ impl AccountView { 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)).await; + 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((stack_id, nodes)) => { + Ok((fetched, nodes)) => { let count = nodes.len(); - let applied = delta_state - .update(cx, |state, cx| state.apply_nodes(stack_id, nodes, cx)); + let applied = delta_state.update(cx, |state, cx| { + state.apply_nodes(&snapshot, fetched, nodes, cx) + }); if applied { tracing::info!(count, "account: cached stack nodes"); } else { @@ -173,15 +174,22 @@ impl AccountView { self.deleting = true; self.message = None; let snapshot = self.delta_state.read(cx).snapshot(); + let deleted_stack = self.delta_state.read(cx).status().stack_id; cx.spawn(async move |this, cx| { - let result = Tokio::spawn_result(cx, delta::delete_account(snapshot.clone())).await; + let result = Tokio::spawn_result(cx, delta::delete_account(snapshot)).await; let _ = this.update(cx, |this, cx| { this.deleting = false; match result { Ok(next) => { - this.delta_state.update(cx, |state, cx| { - state.apply_if_current(&snapshot, next, cx); - }); + let applied = this + .delta_state + .update(cx, |state, cx| state.apply_deleted(deleted_stack, next, cx)); + if !applied { + tracing::warn!( + "account: deleted account state was not adopted; signing out" + ); + this.log_out(cx); + } cx.emit(AccountEvent::Close); } Err(error) => { diff --git a/crates/zedra/src/delta.rs b/crates/zedra/src/delta.rs index 451d84a9..068a52a9 100644 --- a/crates/zedra/src/delta.rs +++ b/crates/zedra/src/delta.rs @@ -355,15 +355,26 @@ impl DeltaState { /// Merge a fetched node list into the live state. Keyed on the stack alone — /// this is derived cache data, so an unrelated token refresh must not /// discard it the way `apply_if_current` would. + /// + /// `expected` is the state the fetch started from: when the live state still + /// matches it, credentials `get_bearer` refreshed mid-fetch are adopted, so + /// persisting the node cache cannot write stale tokens back over them. pub fn apply_nodes( &mut self, - stack_id: Uuid, + expected: &DeltaState, + fetched: DeltaState, nodes: Vec, cx: &mut Context, ) -> bool { - if self.stack_id != Some(stack_id) { + if self.stack_id.is_none() || self.stack_id != fetched.stack_id { return false; } + if self.matches_client_binding_state(expected) { + self.access_token = fetched.access_token; + self.refresh_token = fetched.refresh_token; + self.expires_at = fetched.expires_at; + self.user_id = fetched.user_id; + } self.nodes = nodes; self.nodes_fetched_at = Some(chrono::Utc::now().to_rfc3339()); if let Err(error) = save_state(self) { @@ -374,6 +385,23 @@ impl DeltaState { true } + /// Adopt the signed-out state after the account was erased server-side. + /// Unlike `apply_if_current` this only guards on the stack: the account is + /// already gone, so an unrelated push-token write must not leave the app + /// signed in to it. + pub fn apply_deleted( + &mut self, + deleted_stack: Option, + next: DeltaState, + cx: &mut Context, + ) -> bool { + if deleted_stack.is_none() || self.stack_id != deleted_stack { + return false; + } + self.apply(next, cx); + true + } + pub fn apply_if_current( &mut self, expected: &DeltaState, @@ -622,9 +650,9 @@ async fn sign_in_with_oauth( /// 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 stack the list belongs to so -/// the caller can reject it if the account changed mid-flight. -pub async fn fetch_nodes(mut state: DeltaState) -> Result<(Uuid, Vec)> { +/// 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<(DeltaState, Vec)> { 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) @@ -641,7 +669,7 @@ pub async fn fetch_nodes(mut state: DeltaState) -> Result<(Uuid, Vec joined_at: node.joined_at, }) .collect(); - Ok((stack_id, nodes)) + Ok((state, nodes)) } /// Native permission prompt, then registration of the returned token on Delta, 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 From ed324cb287f4498eb5ad51ddb8dfc1454e877c5d Mon Sep 17 00:00:00 2001 From: Tan Le <104194494+tanlethanh@users.noreply.github.com> Date: Wed, 19 Aug 2026 08:50:09 +0700 Subject: [PATCH 3/3] refactor(app): merge delta state by field group Every async Delta operation returned a whole `DeltaState` that the entity assigned wholesale, so two operations in flight overwrote each other's untouched fields. Four `apply_*` variants had grown to guess which fields each call site was allowed to stomp, and both bugs found in review were that guess being wrong. Operations now hand back a `DeltaPatch` built by diffing what they started from against what they returned, and `DeltaState::merge` applies group by group: session, push token, node cache, host bindings. Independent groups no longer collide, so a token refresh and a node fetch in flight together both land. Network function signatures are unchanged. Sign-out clears every stack-scoped group, so a late result from a previous session cannot repopulate it, and a node list for another stack is dropped. `merge_fields` holds the rules free of entity plumbing, covered by tests for the two review findings and the sign-out and stale-stack cases. Co-Authored-By: Claude Opus 5 (1M context) --- crates/zedra/src/account_view.rs | 28 +-- crates/zedra/src/delta.rs | 322 +++++++++++++++++++++++------- crates/zedra/src/settings_view.rs | 4 +- crates/zedra/src/workspace.rs | 4 +- 4 files changed, 265 insertions(+), 93 deletions(-) diff --git a/crates/zedra/src/account_view.rs b/crates/zedra/src/account_view.rs index 0bccb596..5ff5f4a7 100644 --- a/crates/zedra/src/account_view.rs +++ b/crates/zedra/src/account_view.rs @@ -71,10 +71,10 @@ impl AccountView { let _ = this.update(cx, |this, cx| { this.nodes_busy = false; match result { - Ok((fetched, nodes)) => { - let count = nodes.len(); + Ok(next) => { + let count = next.status().nodes.len(); let applied = delta_state.update(cx, |state, cx| { - state.apply_nodes(&snapshot, fetched, nodes, cx) + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) }); if applied { tracing::info!(count, "account: cached stack nodes"); @@ -174,22 +174,15 @@ impl AccountView { self.deleting = true; self.message = None; let snapshot = self.delta_state.read(cx).snapshot(); - let deleted_stack = self.delta_state.read(cx).status().stack_id; cx.spawn(async move |this, cx| { - let result = Tokio::spawn_result(cx, delta::delete_account(snapshot)).await; + 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) => { - let applied = this - .delta_state - .update(cx, |state, cx| state.apply_deleted(deleted_stack, next, cx)); - if !applied { - tracing::warn!( - "account: deleted account state was not adopted; signing out" - ); - this.log_out(cx); - } + this.delta_state.update(cx, |state, cx| { + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) + }); cx.emit(AccountEvent::Close); } Err(error) => { @@ -205,10 +198,11 @@ impl AccountView { fn log_out(&mut self, cx: &mut Context) { let snapshot = self.delta_state.read(cx).snapshot(); - match delta::sign_out(snapshot) { + match delta::sign_out(snapshot.clone()) { Ok(next) => { - self.delta_state - .update(cx, |state, cx| state.apply(next, cx)); + self.delta_state.update(cx, |state, cx| { + state.merge(delta::DeltaPatch::between(&snapshot, &next), cx) + }); cx.emit(AccountEvent::Close); } Err(error) => { diff --git a/crates/zedra/src/delta.rs b/crates/zedra/src/delta.rs index 068a52a9..c17b4ef6 100644 --- a/crates/zedra/src/delta.rs +++ b/crates/zedra/src/delta.rs @@ -326,6 +326,72 @@ impl Default for DeltaState { } } +/// 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. @@ -341,88 +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. - /// Merge a fetched node list into the live state. Keyed on the stack alone — - /// this is derived cache data, so an unrelated token refresh must not - /// discard it the way `apply_if_current` would. - /// - /// `expected` is the state the fetch started from: when the live state still - /// matches it, credentials `get_bearer` refreshed mid-fetch are adopted, so - /// persisting the node cache cannot write stale tokens back over them. - pub fn apply_nodes( - &mut self, - expected: &DeltaState, - fetched: DeltaState, - nodes: Vec, - cx: &mut Context, - ) -> bool { - if self.stack_id.is_none() || self.stack_id != fetched.stack_id { - return 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 self.matches_client_binding_state(expected) { - self.access_token = fetched.access_token; - self.refresh_token = fetched.refresh_token; - self.expires_at = fetched.expires_at; - self.user_id = fetched.user_id; + 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; } - self.nodes = nodes; - self.nodes_fetched_at = Some(chrono::Utc::now().to_rfc3339()); - if let Err(error) = save_state(self) { - tracing::warn!(error = %error, "delta: persisting the node cache failed"); + // 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; } - cx.emit(DeltaStateEvent::DeltaStateChanged); - cx.notify(); - true - } - - /// Adopt the signed-out state after the account was erased server-side. - /// Unlike `apply_if_current` this only guards on the stack: the account is - /// already gone, so an unrelated push-token write must not leave the app - /// signed in to it. - pub fn apply_deleted( - &mut self, - deleted_stack: Option, - next: DeltaState, - cx: &mut Context, - ) -> bool { - if deleted_stack.is_none() || self.stack_id != deleted_stack { - return false; + 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; } - self.apply(next, cx); true } - 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 - } + #[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 @@ -434,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(), @@ -652,13 +710,13 @@ 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<(DeltaState, Vec)> { +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")?; - let nodes = list + state.nodes = list .nodes .into_iter() .map(|node| StoredNode { @@ -669,7 +727,9 @@ pub async fn fetch_nodes(mut state: DeltaState) -> Result<(DeltaState, Vec 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() { diff --git a/crates/zedra/src/settings_view.rs b/crates/zedra/src/settings_view.rs index 63092fe6..20123de7 100644 --- a/crates/zedra/src/settings_view.rs +++ b/crates/zedra/src/settings_view.rs @@ -37,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"); @@ -283,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; 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;