update sidebar

This commit is contained in:
2026-08-07 19:22:21 +07:00
parent a734141837
commit 161c066ca8
3 changed files with 235 additions and 71 deletions
+78 -50
View File
@@ -1,7 +1,9 @@
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::sync::RwLock;
use std::time::{Duration, Instant};
use anyhow::Error; use anyhow::Error;
use flume::{Receiver, RecvTimeoutError, Sender};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task}; use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
use nostr_sdk::prelude::*; 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..]) 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 /// Global profile cache. Profiles are fetched in batches and kept as plain
/// data; the whole store notifies on change. /// data; the whole store notifies on change.
pub struct ProfileStore { pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>, profiles: HashMap<PublicKey, Profile>,
/// Public keys we've already requested this session. /// Public keys we've already requested this session.
seen: HashSet<PublicKey>, seen: RwLock<HashSet<PublicKey>>,
/// Public keys queued for the next batched fetch. /// Sender for queuing fetch requests, batched by a background task.
queued: HashSet<PublicKey>, sender: Sender<PublicKey>,
fetching: bool,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription, _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::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
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 { let mut store = Self {
profiles: HashMap::new(), profiles: HashMap::new(),
seen: HashSet::new(), seen: RwLock::new(HashSet::new()),
queued: HashSet::new(), sender,
fetching: false, tasks,
tasks: Vec::new(),
_subscription: subscription, _subscription: subscription,
}; };
@@ -121,14 +150,17 @@ impl ProfileStore {
/// Get a profile. Returns a placeholder (default metadata) and queues a /// Get a profile. Returns a placeholder (default metadata) and queues a
/// fetch if the profile isn't cached yet. /// fetch if the profile isn't cached yet.
pub fn get(&mut self, public_key: PublicKey, cx: &mut Context<Self>) -> Profile { pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(&public_key) { if let Some(profile) = self.profiles.get(public_key) {
return profile.clone(); return profile.clone();
} }
if self.seen.insert(public_key) { let public_key = *public_key;
self.queued.insert(public_key);
self.queue_fetch(cx); 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()) Profile::new(public_key, Metadata::default())
@@ -205,12 +237,12 @@ impl ProfileStore {
/// Re-read the latest metadata of every requested author from the local /// Re-read the latest metadata of every requested author from the local
/// database (used after a sync, which produces no NostrUpdate events). /// database (used after a sync, which produces no NostrUpdate events).
fn apply_seen(&mut self, cx: &mut Context<Self>) { fn apply_seen(&mut self, cx: &mut Context<Self>) {
if self.seen.is_empty() { let authors: Vec<PublicKey> = self.seen.read().unwrap().iter().copied().collect();
if authors.is_empty() {
return; return;
} }
let client = Backend::global(cx).read(cx).client(); let client = Backend::global(cx).read(cx).client();
let authors: Vec<PublicKey> = self.seen.iter().copied().collect();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).authors(authors); 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. /// Sync metadata for requested authors in batches, debounced to collect
fn queue_fetch(&mut self, cx: &mut Context<Self>) { /// requests. Runs on a background thread; results are dispatched to the
if self.fetching { /// main thread, which re-reads the database.
return; async fn handle_requests(
} client: &Client,
self.fetching = true; dispatch: &Sender<Dispatch>,
receiver: &Receiver<PublicKey>,
) -> Result<(), Error> {
let mut batch: HashSet<PublicKey> = HashSet::new();
let client = Backend::global(cx).read(cx).client();
let task = cx.spawn(async move |this, cx| {
loop { loop {
// Collect more requests before firing the batch. // Wait for the first request of a batch.
cx.background_executor() match receiver.recv_timeout(BATCH_TIMEOUT) {
.timer(Duration::from_millis(500)) Ok(public_key) => {
.await; batch.insert(public_key);
}
Err(RecvTimeoutError::Disconnected) => return Ok(()),
Err(RecvTimeoutError::Timeout) => continue,
};
let batch = this.update(cx, |this, _cx| std::mem::take(&mut this.queued))?; // Collect everything that arrives within the debounce window.
let deadline = Instant::now() + BATCH_TIMEOUT;
if batch.is_empty() { while let Ok(public_key) = receiver.recv_deadline(deadline) {
this.update(cx, |this, _cx| { batch.insert(public_key);
this.fetching = false;
})?;
break;
} }
let filter = Filter::new() let filter = Filter::new()
.kind(Kind::Metadata) .kind(Kind::Metadata)
.authors(batch.into_iter().collect::<Vec<PublicKey>>()); .authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays. Synced events // Negentropy-sync with the bootstrap relays. Synced events are
// are written to the database directly (no NostrUpdate), so // written to the database directly (no NostrUpdate), so re-apply
// re-apply from the database afterwards. // from the database afterwards.
match sync_bootstrap_only(&client, filter, SyncOptions::default()).await { match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => { Ok(_) => {
this.update(cx, |this, cx| this.apply_seen(cx))?; if dispatch.send(Dispatch::Synced).is_err() {
log::warn!("profile dispatch channel closed, dropping sync result");
} }
Err(e) => { }
log::warn!("profile sync failed: {e}"); Err(e) => log::warn!("profile sync failed: {e}"),
} }
} }
} }
Ok(())
});
self.tasks.push(task);
}
} }
+2 -1
View File
@@ -34,7 +34,8 @@ impl RepoListView {
.unwrap_or_else(|| announcement.id.clone()); .unwrap_or_else(|| announcement.id.clone());
let owner = ProfileStore::global(cx) let owner = ProfileStore::global(cx)
.update(cx, |store, cx| store.get(announcement.owner, cx)) .read(cx)
.get(&announcement.owner)
.name(); .name();
let description = announcement.description.clone().unwrap_or_default(); let description = announcement.description.clone().unwrap_or_default();
+148 -13
View File
@@ -2,14 +2,15 @@ use std::sync::Arc;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
App, Context, EventEmitter, FocusHandle, Focusable, Render, Subscription, WeakEntity, Window, App, ClickEvent, Context, ElementId, EventEmitter, FocusHandle, Focusable, Render,
div, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div,
}; };
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent}; use gpui_component::dock::{DockArea, DockPlacement, Panel, PanelEvent};
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::{ActiveTheme, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
use signed_state::{Backend, BackendEvent}; use signed_state::{Backend, BackendEvent, ProfileStore};
use super::RepoListView; use super::RepoListView;
@@ -128,15 +129,8 @@ impl Focusable for SidebarPanel {
impl Render for SidebarPanel { impl Render for SidebarPanel {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
if self.logged_in { if !self.logged_in {
v_flex().child( return v_flex()
Button::new("explore")
.label("Explore")
.w_full()
.on_click(cx.listener(|this, _, window, cx| this.open_explore(window, cx))),
)
} else {
v_flex()
.p_4() .p_4()
.size_full() .size_full()
.items_center() .items_center()
@@ -165,7 +159,148 @@ impl Render for SidebarPanel {
.on_click( .on_click(
cx.listener(|this, _ev, window, cx| this.open_import(window, cx)), 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<Box<dyn Fn(&ClickEvent, &mut Window, &mut App) + 'static>>,
}
impl NavItem {
fn new<I, L>(id: I, label: L, icon: IconName) -> Self
where
I: Into<ElementId>,
L: Into<SharedString>,
{
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))
}
} }