From 161c066ca8fb73784841139200598bd4ae3dfd73 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 7 Aug 2026 19:22:21 +0700 Subject: [PATCH] update sidebar --- crates/signed_state/src/profile.rs | 140 +++++++++++-------- crates/workspace/src/views/repo_list.rs | 3 +- crates/workspace/src/views/sidebar/mod.rs | 163 ++++++++++++++++++++-- 3 files changed, 235 insertions(+), 71 deletions(-) diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 99eb183..52dc737 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -1,7 +1,9 @@ use std::collections::{HashMap, HashSet}; -use std::time::Duration; +use std::sync::RwLock; +use std::time::{Duration, Instant}; use anyhow::Error; +use flume::{Receiver, RecvTimeoutError, Sender}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use nostr_sdk::prelude::*; @@ -63,15 +65,23 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { format!("{}...{}", &npub[..(len + 5)], &npub[npub.len() - len..]) } +/// Message from the fetch task to the main thread. +enum Dispatch { + /// A batched sync finished; re-read seen profiles from the database. + Synced, +} + +/// How long to wait for more requests before firing a batched sync. +const BATCH_TIMEOUT: Duration = Duration::from_millis(500); + /// Global profile cache. Profiles are fetched in batches and kept as plain /// data; the whole store notifies on change. pub struct ProfileStore { profiles: HashMap, /// Public keys we've already requested this session. - seen: HashSet, - /// Public keys queued for the next batched fetch. - queued: HashSet, - fetching: bool, + seen: RwLock>, + /// Sender for queuing fetch requests, batched by a background task. + sender: Sender, tasks: Vec>>, _subscription: Subscription, } @@ -106,12 +116,31 @@ impl ProfileStore { _ => {} }); + // Fetch requests are queued on a channel and synced in batches by a + // background task. + let client = backend.read(cx).client(); + let (sender, receiver) = flume::unbounded::(); + let (dispatch_tx, dispatch_rx) = flume::unbounded::(); + + let mut tasks = Vec::new(); + + tasks.push(cx.background_spawn(async move { + Self::handle_requests(&client, &dispatch_tx, &receiver).await + })); + + // Re-read seen profiles from the database after each batch sync. + tasks.push(cx.spawn(async move |this, cx| { + while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await { + this.update(cx, |this, cx| this.apply_seen(cx)).ok(); + } + Ok(()) + })); + let mut store = Self { profiles: HashMap::new(), - seen: HashSet::new(), - queued: HashSet::new(), - fetching: false, - tasks: Vec::new(), + seen: RwLock::new(HashSet::new()), + sender, + tasks, _subscription: subscription, }; @@ -121,14 +150,17 @@ impl ProfileStore { /// Get a profile. Returns a placeholder (default metadata) and queues a /// fetch if the profile isn't cached yet. - pub fn get(&mut self, public_key: PublicKey, cx: &mut Context) -> Profile { - if let Some(profile) = self.profiles.get(&public_key) { + pub fn get(&self, public_key: &PublicKey) -> Profile { + if let Some(profile) = self.profiles.get(public_key) { return profile.clone(); } - if self.seen.insert(public_key) { - self.queued.insert(public_key); - self.queue_fetch(cx); + let public_key = *public_key; + + if self.seen.write().unwrap().insert(public_key) + && let Err(e) = self.sender.send(public_key) + { + log::warn!("failed to queue profile fetch: {e}"); } Profile::new(public_key, Metadata::default()) @@ -205,12 +237,12 @@ impl ProfileStore { /// Re-read the latest metadata of every requested author from the local /// database (used after a sync, which produces no NostrUpdate events). fn apply_seen(&mut self, cx: &mut Context) { - if self.seen.is_empty() { + let authors: Vec = self.seen.read().unwrap().iter().copied().collect(); + if authors.is_empty() { return; } let client = Backend::global(cx).read(cx).client(); - let authors: Vec = self.seen.iter().copied().collect(); let work = cx.background_spawn(async move { let filter = Filter::new().kind(Kind::Metadata).authors(authors); @@ -255,51 +287,47 @@ impl ProfileStore { })); } - /// Drain the queue in a batched fetch, debounced to collect requests. - fn queue_fetch(&mut self, cx: &mut Context) { - if self.fetching { - return; - } - self.fetching = true; + /// Sync metadata for requested authors in batches, debounced to collect + /// requests. Runs on a background thread; results are dispatched to the + /// main thread, which re-reads the database. + async fn handle_requests( + client: &Client, + dispatch: &Sender, + receiver: &Receiver, + ) -> Result<(), Error> { + let mut batch: HashSet = HashSet::new(); - let client = Backend::global(cx).read(cx).client(); - - let task = cx.spawn(async move |this, cx| { - loop { - // Collect more requests before firing the batch. - cx.background_executor() - .timer(Duration::from_millis(500)) - .await; - - let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?; - - if batch.is_empty() { - this.update(cx, |this, _cx| { - this.fetching = false; - })?; - break; + loop { + // Wait for the first request of a batch. + match receiver.recv_timeout(BATCH_TIMEOUT) { + Ok(public_key) => { + batch.insert(public_key); } + Err(RecvTimeoutError::Disconnected) => return Ok(()), + Err(RecvTimeoutError::Timeout) => continue, + }; - let filter = Filter::new() - .kind(Kind::Metadata) - .authors(batch.into_iter().collect::>()); - - // Negentropy-sync with the bootstrap relays. Synced events - // are written to the database directly (no NostrUpdate), so - // re-apply from the database afterwards. - match sync_bootstrap_only(&client, filter, SyncOptions::default()).await { - Ok(_) => { - this.update(cx, |this, cx| this.apply_seen(cx))?; - } - Err(e) => { - log::warn!("profile sync failed: {e}"); - } - } + // Collect everything that arrives within the debounce window. + let deadline = Instant::now() + BATCH_TIMEOUT; + while let Ok(public_key) = receiver.recv_deadline(deadline) { + batch.insert(public_key); } - Ok(()) - }); + let filter = Filter::new() + .kind(Kind::Metadata) + .authors(batch.drain().collect::>()); - self.tasks.push(task); + // Negentropy-sync with the bootstrap relays. Synced events are + // written to the database directly (no NostrUpdate), so re-apply + // from the database afterwards. + match sync_bootstrap_only(client, filter, SyncOptions::default()).await { + Ok(_) => { + if dispatch.send(Dispatch::Synced).is_err() { + log::warn!("profile dispatch channel closed, dropping sync result"); + } + } + Err(e) => log::warn!("profile sync failed: {e}"), + } + } } } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 1e3f05e..224258a 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -34,7 +34,8 @@ impl RepoListView { .unwrap_or_else(|| announcement.id.clone()); let owner = ProfileStore::global(cx) - .update(cx, |store, cx| store.get(announcement.owner, cx)) + .read(cx) + .get(&announcement.owner) .name(); let description = announcement.description.clone().unwrap_or_default(); diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 081c1ea..55bc342 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -2,14 +2,15 @@ use std::sync::Arc; use gpui::prelude::*; use gpui::{ - App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, - div, + App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render, + SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, }; +use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::input::InputState; -use gpui_component::{ActiveTheme, v_flex}; -use signed_state::{Backend, BackendEvent}; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; +use signed_state::{Backend, BackendEvent, ProfileStore}; use super::RepoListView; @@ -128,15 +129,8 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if self.logged_in { - v_flex().child( - Button::new("explore") - .label("Explore") - .w_full() - .on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))), - ) - } else { - v_flex() + if !self.logged_in { + return v_flex() .p_4() .size_full() .items_center() @@ -165,7 +159,148 @@ impl Render for SidebarPanel { .on_click( cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), ), - ) + ); } + + let backend = Backend::global(cx); + let profile_store = ProfileStore::global(cx); + + let profile = backend + .read(cx) + .current_user() + .map(|public_key| profile_store.read(cx).get(&public_key)); + + v_flex() + .size_full() + .justify_between() + .bg(cx.theme().sidebar) + .text_color(cx.theme().sidebar_foreground) + .child( + div() + .flex_1() + .when_some(profile.as_ref(), |this, profile| { + let name = profile.name(); + let picture = profile.picture(); + + this.child( + h_flex() + .h_12() + .px_3() + .gap_2() + .child( + Avatar::new() + .name(name.clone()) + .when_some(picture, |this, url| this.src(url)) + .small() + .border_0(), + ) + .child(div().text_sm().child(name)), + ) + }) + .child( + v_flex() + .px_2() + .gap_1() + .items_start() + .justify_start() + .child(NavItem::new("inbox", "Inbox", IconName::Inbox).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child(NavItem::new("explore", "Browse", IconName::Globe).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child( + v_flex().w_full().child( + h_flex() + .h_10() + .w_full() + .justify_between() + .items_center() + .child( + h_flex() + .px_2() + .gap_2() + .text_color(cx.theme().muted_foreground) + .child(Icon::new(IconName::Folder).small()) + .child( + div() + .text_xs() + .font_semibold() + .child("All Repositories"), + ), + ) + .child( + Button::new("add").icon(IconName::Plus).small().ghost(), + ), + ), + ), + ), + ) + .child( + v_flex() + .p_2() + .flex_shrink_0() + .gap_1() + .items_start() + .justify_start() + .child(NavItem::new("guide", "Guide", IconName::Info).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + )) + .child( + NavItem::new("settings", "Settings", IconName::Settings).on_click( + cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), + ), + ), + ) + } +} + +/// A single navigation entry in the sidebar: an icon and label with a hover +/// highlight and an optional click handler. +#[allow(clippy::type_complexity)] +#[derive(IntoElement)] +struct NavItem { + id: ElementId, + style: StyleRefinement, + icon: IconName, + label: SharedString, + on_click: Option>, +} + +impl NavItem { + fn new(id: I, label: L, icon: IconName) -> Self + where + I: Into, + L: Into, + { + Self { + id: id.into(), + icon, + label: label.into(), + style: StyleRefinement::default(), + on_click: None, + } + } + + fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { + self.on_click = Some(Box::new(listener)); + self + } +} + +impl RenderOnce for NavItem { + fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { + h_flex() + .id(self.id) + .refine_style(&self.style) + .px_2() + .py_1() + .w_full() + .gap_2() + .rounded(cx.theme().radius) + .child(Icon::new(self.icon).small()) + .child(div().text_sm().child(self.label)) + .hover(|this| this.bg(cx.theme().list_hover)) + .when_some(self.on_click, |this, listener| this.on_click(listener)) } }