From 4d0e2f5050e6ed85106b981a7572f82558c906ff Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 26 Oct 2026 09:50:49 +0700 Subject: [PATCH] feat: add syncing progress --- Cargo.lock | 38 ++--- crates/chat/src/lib.rs | 2 + crates/person/src/wot.rs | 9 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/sidebar/header.rs | 25 ++-- crates/workspace/src/sidebar/mod.rs | 195 +++++++++++++++++++------ 6 files changed, 193 insertions(+), 77 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 987496e7..2d29b192 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2401,15 +2401,6 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[package]] -name = "fsevent-sys" -version = "4.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76ee7a02da4d231650c7cea31349b889be2f45ddb3ef3032d2ec8185f6313fd2" -dependencies = [ - "libc", -] - [[package]] name = "futf" version = "0.1.5" @@ -5225,20 +5216,21 @@ dependencies = [ [[package]] name = "notify" -version = "8.2.0" +version = "8.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" +checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674" dependencies = [ "bitflags 2.13.2", - "fsevent-sys", "inotify", "kqueue", "libc", "log", "mio", "notify-types", + "objc2-core-foundation", + "objc2-core-services", "walkdir", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5565,6 +5557,16 @@ dependencies = [ "objc2-foundation 0.3.2", ] +[[package]] +name = "objc2-core-services" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "583300ad934cba24ff5292aee751ecc070f7ca6b39a574cc21b7b5e588e06a0b" +dependencies = [ + "libc", + "objc2-core-foundation", +] + [[package]] name = "objc2-core-text" version = "0.3.2" @@ -9728,15 +9730,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -10022,6 +10015,7 @@ dependencies = [ "nostr-connect", "nostr-sdk", "person", + "rand 0.10.3", "serde", "settings", "smallvec", diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index ce98b396..b8eb57de 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -47,6 +47,7 @@ impl Global for GlobalChatRegistry {} #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ChatEvent { Ping, + Loaded, InboxRelayNotFound, Error(String), } @@ -402,6 +403,7 @@ impl ChatRegistry { if eose && tracking.swap(false, Ordering::AcqRel) { this.update(cx, |this, cx| { + cx.emit(ChatEvent::Loaded); this.get_rooms(cx); })?; } diff --git a/crates/person/src/wot.rs b/crates/person/src/wot.rs index 2a062a5c..6312a1c9 100644 --- a/crates/person/src/wot.rs +++ b/crates/person/src/wot.rs @@ -232,6 +232,7 @@ impl WebOfTrust { .idle_timeout(Duration::from_secs(SYNC_IDLE_TIMEOUT)); self.syncing = true; + cx.notify(); self.refresh_task = Some(cx.spawn(async move |this, cx| { // Scope the reconcile to the follow set known so far @@ -265,12 +266,13 @@ impl WebOfTrust { this.snapshot = Some(snapshot); cx.emit(WoTEvent); - cx.notify(); if grew { this.sync(cx); } } + + cx.notify(); }) .ok(); })); @@ -314,6 +316,11 @@ impl WebOfTrust { .is_some_and(|snapshot| !snapshot.disabled) } + /// Whether a reconciliation with the relays is in flight + pub fn is_syncing(&self) -> bool { + self.syncing + } + /// Whether the snapshot covers a follow set fn has_data(&self) -> bool { self.snapshot diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 3a84db7d..9fdc7c30 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -27,4 +27,5 @@ nostr-connect.workspace = true anyhow.workspace = true serde.workspace = true log.workspace = true +rand.workspace = true smallvec.workspace = true diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs index 70152a0f..c97875d7 100644 --- a/crates/workspace/src/sidebar/header.rs +++ b/crates/workspace/src/sidebar/header.rs @@ -114,24 +114,25 @@ impl Sidebar { }) } - /// The bottom navigation, pinned below the list like signed's sidebar. - pub(super) fn render_footer(&self, _cx: &mut Context) -> Div { + pub(super) fn render_footer(&self, loading: bool, _cx: &mut Context) -> Div { v_flex() .p_2() .flex_shrink_0() .gap_1() .items_start() .justify_start() - .child( - Nav::new("sidebar-settings") - .prefix(Icon::new(IconName::Settings).small()) - .center_prefix(true) - .label("Settings") - .text_sm() - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(Command::ShowSettings), cx) - }), - ) + .when(!loading, |this| { + this.child( + Nav::new("settings") + .prefix(Icon::new(IconName::Settings).small()) + .center_prefix(true) + .label("Settings") + .text_sm() + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::ShowSettings), cx) + }), + ) + }) } #[cfg(not(target_arch = "wasm32"))] diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 1e21b3d1..640008f7 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -5,20 +5,22 @@ use chat::{ChatEvent, ChatRegistry}; use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, StyledImage, Subscription, - Task, WeakEntity, Window, div, img, retain_all, + App, AppContext, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, + StyledImage, Subscription, Task, WeakEntity, Window, div, img, retain_all, }; -use gpui_kit::base::StyledExt as _; +use gpui_kit::base::{Presence, Transition}; use gpui_kit::component::dock::{DockArea, Panel, PanelEvent}; use gpui_kit::component::input::{InputEvent, InputState}; use gpui_kit::component::notification::Notification; -use gpui_kit::component::spinner::Spinner; -use gpui_kit::component::{ActiveTheme, Sizable, WindowExt, h_flex, v_flex}; +use gpui_kit::component::progress::ProgressCircle; +use gpui_kit::component::{ActiveTheme, WindowExt, h_flex, v_flex}; use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; use person::{Directory, PersonRegistry}; +pub(crate) use rows::open_room; +use search::SearchResult; use smallvec::{SmallVec, smallvec}; use state::{FIND_DELAY, NostrRegistry, StateEvent}; use ui::title_bar_drag_handlers; @@ -33,8 +35,8 @@ mod onboarding; mod rows; mod search; -pub(crate) use rows::open_room; -use search::SearchResult; +const PROGRESS_TICK_MS: u64 = 500; +const PROGRESS_CAP: f32 = 95.0; pub struct Sidebar { focus_handle: FocusHandle, @@ -88,6 +90,11 @@ pub struct Sidebar { /// The async global profile search. global_task: Option>, + /// Simulated gift wrap processing progress, shown while the backlog loads + chat_progress: f32, + /// Simulated web of trust sync progress + sync_progress: f32, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -97,7 +104,6 @@ impl Sidebar { let chat = ChatRegistry::global(cx); let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); - let wot = PersonRegistry::global(cx).read(cx).wot(); let find_input = cx.new(|cx| { InputState::new(window, cx) @@ -105,28 +111,30 @@ impl Sidebar { .clean_on_escape() }); + let mut tasks = smallvec![]; let mut subscriptions = smallvec![]; subscriptions.push( - cx.subscribe_in(&chat, window, move |this, _s, event, _window, cx| { - if event == &ChatEvent::Ping { - this.new_requests = true; - cx.notify(); + cx.subscribe_in(&chat, window, move |this, _, event, _, cx| { + match event { + ChatEvent::Ping => { + this.new_requests = true; + cx.notify(); + } + ChatEvent::Loaded => { + this.chat_progress = 0.0; + cx.notify(); + } + _ => {} }; }), ); - subscriptions.push( - cx.subscribe_in(&wot, window, |_this, _wot, _event, _window, cx| { - cx.notify(); - }), - ); - subscriptions.push( cx.subscribe_in(&groups, window, |_this, _, event, window, cx| { match event { GroupsEvent::Updated => { - // TODO: find a better way? + // TODO: we must not trigger re-render directly like this cx.notify(); } GroupsEvent::Error(error) => { @@ -198,6 +206,16 @@ impl Sidebar { }, )); + tasks.push(cx.spawn(async move |this, cx| { + let timer = Duration::from_millis(PROGRESS_TICK_MS); + loop { + cx.background_executor().timer(timer).await; + this.update(cx, |this, cx| { + this.tick_progress(cx); + })?; + } + })); + Self { focus_handle: cx.focus_handle(), dock, @@ -223,7 +241,9 @@ impl Sidebar { global_finding: false, global_results: None, global_task: None, - tasks: smallvec![], + chat_progress: 0.0, + sync_progress: 0.0, + tasks, _subscriptions: subscriptions, } } @@ -251,6 +271,80 @@ impl Sidebar { self.hide_group(cx); } } + + /// Advance the simulated loading progress of any active background work. + fn tick_progress(&mut self, cx: &mut Context) { + let logged_in = NostrRegistry::global(cx).read(cx).current_user().is_some(); + let chat = ChatRegistry::global(cx); + let wot = PersonRegistry::global(cx).read(cx).wot(); + + let chat_loading = logged_in && chat.read(cx).loading(); + let syncing = logged_in && wot.read(cx).is_syncing(); + + let mut changed = false; + + if chat_loading { + let next = self.advance_progress(self.chat_progress); + if next > self.chat_progress { + self.chat_progress = next; + changed = true; + } + } else if self.chat_progress != 0.0 { + self.chat_progress = 0.0; + changed = true; + } + + if syncing { + let next = self.advance_progress(self.sync_progress); + if next > self.sync_progress { + self.sync_progress = next; + changed = true; + } + } else if self.sync_progress != 0.0 { + self.sync_progress = 0.0; + changed = true; + } + + if changed { + cx.notify(); + } + } + + /// Bump simulated progress by a random step + fn advance_progress(&self, progress: f32) -> f32 { + (progress + rand::random::() * 8.0 + 1.0).min(PROGRESS_CAP) + } + + /// A floating pill showing simulated progress for a background operation. + fn render_loading_pill( + &self, + id: &'static str, + label: &'static str, + progress: f32, + opacity: f32, + cx: &Context, + ) -> Div { + h_flex() + .opacity(opacity) + .h_9() + .w_full() + .px_5() + .gap_2p5() + .border_t_1() + .border_color(cx.theme().border) + .child( + ProgressCircle::new(id) + .value(progress) + .color(cx.theme().primary) + .accessibility_label(label), + ) + .child( + div() + .text_sm() + .text_color(cx.theme().sidebar_foreground) + .child(label), + ) + } } impl gpui_kit::component::dock::BasePanel for Sidebar { @@ -290,6 +384,9 @@ impl Render for Sidebar { let chat = ChatRegistry::global(cx); let loading = chat.read(cx).loading(); + let wot = PersonRegistry::global(cx).read(cx).wot(); + let syncing = wot.read(cx).is_syncing(); + let group = self.group.clone().and_then(|group| group.upgrade()); let searching = self.search_open && !self.find_input.read(cx).value().is_empty(); let picking_contacts = self.contacts_open && !self.requests_open; @@ -343,32 +440,46 @@ impl Render for Sidebar { }), ) .when(logged_in && !picking_contacts, |this| { - this.child(self.render_footer(cx)) + this.child(self.render_footer(loading || syncing, cx)) }) - .when(loading && logged_in, |this| { + .when(logged_in, |this| { + let pill_motion = Transition::new(cx.theme().motion_tokens().duration_normal) + .easing(cx.theme().motion_tokens().easing_move.clone()); + + let chat_presence = Presence::new("chat-presence", loading) + .transition(pill_motion.clone()) + .sample(window, cx); + + let wot_presence = Presence::new("wot-presence", syncing) + .transition(pill_motion) + .sample(window, cx); + this.child( - div() + v_flex() .absolute() - .bottom_4() + .bottom_0() .left_0() - .h_9() .w_full() - .px_8() - .child( - h_flex() - .gap_2() - .w_full() - .h_9() - .justify_center() - .bg(cx.theme().background.opacity(0.85)) - .when(cx.theme().shadow, |this| this.shadow_md()) - .rounded_full() - .text_xs() - .font_semibold() - .text_color(cx.theme().muted_foreground) - .child(Spinner::new().small().color(cx.theme().primary)) - .child("Getting messages..."), - ), + .bg(cx.theme().sidebar) + .text_color(cx.theme().sidebar_foreground) + .children(chat_presence.should_render().then(|| { + self.render_loading_pill( + "chat-sync", + "Syncing messages", + self.chat_progress, + chat_presence.progress, + cx, + ) + })) + .children(wot_presence.should_render().then(|| { + self.render_loading_pill( + "wot-sync", + "Syncing contacts", + self.sync_progress, + wot_presence.progress, + cx, + ) + })), ) }) .into_any_element()