From 60d8eef340d37f7d9a4f3858e5ee7064cf067610 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 13:12:44 +0700 Subject: [PATCH 1/4] update plan --- AGENTS.md | 6 + docs/PROFILE_DIRECTORIES.md | 230 ++++++++++++++++++++++++++++++++++++ 2 files changed, 236 insertions(+) create mode 100644 docs/PROFILE_DIRECTORIES.md diff --git a/AGENTS.md b/AGENTS.md index bddfca0..74c175e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -129,6 +129,12 @@ Action handlers can be registered on an element via the event handler `.on_actio When a view's state has changed in a way that may affect its rendering, it should call `cx.notify()`. This will cause the view to be rerendered. It will also cause any observe callbacks registered for the entity with `cx.observe` to be called. +GPUI also tracks the entities a window reads while rendering. When an entity notifies, every view that read that entity during the previous frame is rerendered automatically. + +* A view that reads an entity in render (e.g. `state.read(cx).profile(...)`) does not need `cx.observe(&entity, ...)` to pick up its changes - the read itself is the dependency. Reserve `cx.observe` and `cx.subscribe` for when the view must run logic in response to a change (rebuild derived state, re-query, dedupe), not to trigger a re-render. +* Notify the narrowest entity holding the change. Notifying a global store rerenders every view that ever read it; notifying a per-panel entity rerenders only the panels reading it. +* This works with cached panels (`panel.cached(...)`): a cached subtree repaints when the view it belongs to is invalidated. + ## Entity events While updating an entity (`cx: Context`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter for EntityType {}`. diff --git a/docs/PROFILE_DIRECTORIES.md b/docs/PROFILE_DIRECTORIES.md new file mode 100644 index 0000000..8ad5cf8 --- /dev/null +++ b/docs/PROFILE_DIRECTORIES.md @@ -0,0 +1,230 @@ +# Per-view profile directories + +Status: proposal +Reference: coop `a365a8f` "bind user metadata to per-view directories" + +## Background + +`ProfileStore` (`crates/signed_state/src/profile.rs`) is a single global entity: + +- Warm-loads the 500 most recent metadata events at startup (`WARM_LIMIT`). +- `get(pk)` returns a cached clone or a placeholder and queues a network fetch + (flume channel, 500 ms batching, chunks of 100, `sync_bootstrap_only`). +- Subscribes to `BackendEvent::ProfileUpdates` to re-query the local database. +- Every view reads through `ProfileStore::global(cx)`. + +Problems: + +- Invalidation is global: every `cx.notify()` on the store invalidates every + panel that read it, so any profile update repaints all profile-consuming + surfaces (including `panel.cached` ones), not just the panel whose data + changed. +- Startup work and memory scale with the local database, not with what is on + screen, and the global map never shrinks. +- Every screen shares one data set, so nothing is cancellable or reclaimable. + +## Goals + +- Profile data is owned per panel/screen. A directory dies with its panel, + cancelling outstanding work and dropping its cache. +- An update re-renders only the panel whose data changed: reads during render + are tracked by the window, and `panel.cached` repaints a panel as soon as + its directory notifies. +- Local database first: query the local cache, fetch from relays only for keys + the directory cannot resolve. +- One shared network pipeline (batching, dedupe) so N panels still produce one + relay sync per key. + +Non-goals: changing `Profile` accessors, changing `Backend`, persistence. + +## Design + +Two pieces replace the fat global: + +1. `ProfileStore` (global, thin): network fetch service + event broadcaster. + Holds no profiles and does no warm load. + - `fetch(keys)`: dedupe via an in-flight set, batch via the existing flume + channel + 500 ms timeout, chunks of 100 through `sync_bootstrap_only`. + - Emits `ProfileEvent::Updated(Vec)` (a) after each synced batch + and (b) when `BackendEvent::ProfileUpdates` arrives from the pump. +2. `ProfileDirectory` (per panel/screen entity): the metadata one surface + renders. + - `track(keys, cx)`: queries the local database for missing keys, inserts + what it finds, queues the rest for a network fetch. + - `profile(key, cx)`: cached value or placeholder; a miss marks the key + pending and queues a fetch (same call shape as today's `get`). + - Subscribes to `ProfileEvent::Updated`, refreshes only keys it tracks or + has pending, notifies itself on change. + - Stores its own tasks and subscription; both drop with the panel. + +``` +panel view ──track(keys)──▶ ProfileDirectory ──DB query──▶ local database + ▲ │ keys not found + │ cx.notify() ▼ + └────refresh(authors)── ProfileEvent::Updated ──── ProfileStore (global) + ▲ │ batch + + │ │ sync_bootstrap_only + BackendEvent::ProfileUpdates ▼ + relay pump ─────────────▶ relays +``` + +Panels own `Entity` and read it directly in render; no +`cx.observe` is needed. gpui-pre records which entities a window reads while +drawing (`App::record_entities_accessed`) and, on `cx.notify`, invalidates +exactly the views that read them (`App::notify` → `Window::invalidate_view`). +This is the mechanism coop relies on — its `Directory` only calls +`cx.notify()` — and it composes with `panel.cached(...)`, which repaints a +panel when the panel view itself is invalidated. Scoping data per directory is +what scopes invalidation: notifying the old global store would invalidate +every panel that ever read it, while notifying one directory invalidates only +the panels reading it. + +## API sketch + +```rust +// signed_state/src/profile.rs + +#[derive(Debug, Clone)] +pub enum ProfileEvent { + /// Metadata for these authors may have changed in the local database. + Updated(Vec), +} + +impl EventEmitter for ProfileStore {} + +/// Thin global: batches metadata requests from directories and syncs them +/// into the local database. Holds no profile data. +pub struct ProfileStore { + in_flight: RefCell>, + sender: Sender, + _subscription: Subscription, +} + +impl ProfileStore { + pub fn global(cx: &App) -> Entity; + pub(crate) fn set_global(entity: Entity, cx: &mut App); + /// Queue a batched metadata request; no-op for keys already in flight. + pub fn fetch(&self, keys: impl IntoIterator); +} + +/// Metadata cache for one panel or screen. +pub struct ProfileDirectory { + profiles: HashMap, + pending: RefCell>, + tasks: SmallVec<[Task<()>; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl ProfileDirectory { + /// Create a directory, following the profile store's events. + pub fn new(cx: &mut App) -> Entity; + /// Resolve keys from the local database, queue the rest for fetch. + pub fn track(&mut self, keys: impl IntoIterator, cx: &mut Context); + /// The profile for a key, or a placeholder that queues a fetch. + pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile; + /// Re-query tracked/pending keys named by a `ProfileEvent`. + fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context); +} +``` + +Panel wiring: + +```rust +pub struct IssuesView { + // ... + directory: Entity, +} + +// constructor — no observe wiring; render-time reads of the directory are +// tracked automatically and `cx.notify()` on it invalidates the panel. +let directory = ProfileDirectory::new(cx); +``` + +Behavior details carried over from coop's `Directory`: + +- The `pending` set guards the window between `track`/`profile` and the + database task finishing, so events arriving mid-query are still accepted. +- Inserts never overwrite data that already arrived from an event + (insert-if-absent on bulk load). +- `refresh` removes a pending key only when the database produced a profile, + so keys that exist on no relay stay pending and never loop into refetches; + `in_flight` in the store is released on every `Updated` emission. + +## Where directories live + +| Surface | File | Keys tracked | When | +| --- | --- | --- | --- | +| `SidebarPanel` | `views/sidebar/mod.rs` | current user, local repo owners | on `SignerChanged`, on local repos update | +| `RepoListView` | `views/repo_list.rs` | announcement owners | when cards rebuild | +| `RepoDetailView` | `views/repo/mod.rs`, `repo/about.rs` | owner, maintainers, binding owners | on announcement/sync; about dialog captures the handle | +| `IssuesView` | `views/issues/mod.rs` | issue authors, repo owner (tab title) | in `rebuild` | +| `IssueDetailView` | `views/issues/detail.rs` | author, discussion participants | on store events; passed to `discussion::` helpers | +| `PullRequestsView` | `views/pull_requests/mod.rs` | PR authors, repo owner | in `rebuild` | +| `PullRequestDetailView` | `views/pull_requests/detail.rs` | author, participants | on store events | +| `NewPullRequestView`, `SendPatchView`, `CommitDiffView` | respective files | repo owner (tab title only) | on construction | +| `InboxView` | `views/inbox.rs` | activity authors | in rows rebuild | + +Helpers that stop reading the global and take `&Entity` +instead — every caller has `&mut self` or captures the handle: + +- `repo_tab_avatar(store, cx)` in `views/mod.rs` (all `Panel::title` impls) +- `discussion::sidebar_section`, `discussion::comments_section` +- `about::maintainers` via `open_about_dialog` (dialog closure captures the + entity handle, same as coop's device-registry notification) +- `bound_repo_label` in `views/repo/mod.rs` +- `sub_activity` in `views/inbox.rs`, `local_avatar` in `views/sidebar/mod.rs` + +## Migration steps + +1. `signed_state`: introduce `ProfileEvent` + `EventEmitter`, extract the + "latest metadata per author" reduction from `apply_authors` into a pure + `fn latest_profiles(events: Vec) -> Vec`, and strip + `ProfileStore` down to the fetch service (drop `profiles`, `seen`, `load`, + `apply_seen`, `apply_authors`, `get`, `WARM_LIMIT`). The batch loop emits + `ProfileEvent::Updated` instead of querying; the `BackendEvent::ProfileUpdates` + subscription re-emits and releases in-flight keys. +2. `signed_state`: add `ProfileDirectory` in the same `profile.rs` and export + it from `lib.rs`. `init` keeps creating the global store. +3. `workspace`: add a `directory` field to each surface in the table, replace + `ProfileStore::global(cx).read(cx).get(...)` with + `self.directory.read(cx).profile(...)`, thread the handle through the free + helpers, and call `track` from each surface's data-load/rebuild path. +4. Delete `ProfileStore::get` last; the compiler walks the remaining call + sites. The workspace crate will not compile between steps 1 and 3, so this + lands as one change. + +## Edge cases + +- **Relay event during a database query**: covered by the pending set. +- **Keys with no profile on relays**: stay pending/in-flight, no retry storm + (strictly no worse than today's permanent `seen` dedupe). +- **Panel closed**: directory drops → subscription, tasks, and cache drop; no + cross-panel notifies. +- **Same person on many panels**: each directory keeps its own `Profile` + clone. Metadata JSON is small and queries are local index lookups; if this + ever shows up, a directory can attach to `RepoStore` later. +- **Own profile edits**: publishing flows through `Backend` → pump → + `ProfileUpdates` → store event → directories refresh, so no extra channel is + needed. +- **Dock tab titles**: `Panel::title` receives `&mut self`, so panels pass + their own directory to `repo_tab_avatar`; no global needed for chrome. + +## Testing + +- Unit: `latest_profiles` keeps the newest event per author (pure, no GPUI). +- Unit: `ProfileDirectory` pending-set dedupe and insert-if-absent merge via + `TestAppContext` (the dock crate already uses GPUI test contexts), seeding + the directory through a `#[cfg(test)]` helper rather than the network. +- Manual: cold start with an empty database, open explore / repo / issues / + inbox, confirm placeholders resolve without interacting with each panel, and + that `panel.cached` tabs still repaint on profile arrival. + +## Alternatives considered + +- **Attach the directory to `RepoStore`** instead of panels: repo surfaces + sharing one store would share one directory, but `RepoStore` instances are + created per open and sidebar/inbox/explore still need their own; revisit + only if duplicated queries ever matter. +- **Keep the global store as-is**: read tracking already propagates updates, + but the warm load, unbounded cache, and all-panels invalidation remain; does + not meet the goals. -- 2.54.0 From 89836a0394bc9fc62f0b32bff3aa416d9134c7c3 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 13:49:59 +0700 Subject: [PATCH 2/4] Add per-panel ProfileDirectory over batched ProfileStore --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/profile.rs | 428 +++++++++++++++++------------ 2 files changed, 256 insertions(+), 174 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index fc47a83..3feebf2 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -23,7 +23,7 @@ use gpui::{App, AppContext}; pub use inbox::Inbox; pub use local_repos::{LocalReposStore, ResolvedLocalRepo}; pub use nostr_sdk::prelude::Timestamp; -pub use profile::{Profile, ProfileStore}; +pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore}; pub use push::{GraspServer, PushOutcome}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index 0f59430..c609877 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -2,10 +2,9 @@ use std::cell::RefCell; use std::collections::{HashMap, HashSet}; use std::time::{Duration, Instant}; -use anyhow::Error; -use flume::{Receiver, Sender}; +use flume::Sender; use gpui::{ - App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity, + App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task, }; use nostr_sdk::prelude::*; use utils::shorten_pubkey; @@ -15,7 +14,6 @@ use crate::bootstrap::sync_bootstrap_only; const BATCH_TIMEOUT: Duration = Duration::from_millis(500); const REQUEST_CHUNK: usize = 100; -const WARM_LIMIT: usize = 500; #[derive(Debug, Clone)] pub struct Profile { @@ -63,12 +61,39 @@ impl Profile { .filter(|p| !p.is_empty()) .map(|p| SharedString::from(p.clone())) } + + /// The newest profile per author from metadata events. + fn latest_profiles(events: impl IntoIterator) -> Vec { + let mut latest: HashMap = HashMap::new(); + + for event in events { + match latest.get(&event.pubkey) { + Some((created_at, _)) if *created_at >= event.created_at => {} + _ => { + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + latest.insert(event.pubkey, (event.created_at, metadata)); + } + } + } + + latest + .into_iter() + .map(|(public_key, (_, metadata))| Profile::new(public_key, metadata)) + .collect() + } } +/// Metadata for these authors may have changed in the local database. +#[derive(Debug, Clone)] +pub enum ProfileEvent { + Updated(Vec), +} + +/// Batches metadata requests into relay syncs feeding the local database. pub struct ProfileStore { - profiles: HashMap, - seen: RefCell>, + in_flight: RefCell>, sender: Sender, + _task: Task>, _subscription: Subscription, } @@ -76,6 +101,8 @@ struct GlobalProfileStore(Entity); impl Global for GlobalProfileStore {} +impl EventEmitter for ProfileStore {} + impl ProfileStore { pub fn global(cx: &App) -> Entity { cx.global::().0.clone() @@ -87,201 +114,256 @@ impl ProfileStore { pub(crate) fn new(cx: &mut Context) -> Self { let backend = Backend::global(cx); - let entity = cx.entity().downgrade(); let client = backend.read(cx).client(); let (sender, receiver) = flume::unbounded::(); let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { if let BackendEvent::ProfileUpdates(authors) = event { - this.apply_authors(authors.clone(), cx); + this.emit_updated(authors.clone(), cx); } }); - cx.spawn(async move |this, cx| { - if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await { - log::error!("Failed to handle requests: {e}"); - } - }) - .detach(); + let task = cx.spawn(async move |this, cx| { + let mut batch: HashSet = HashSet::new(); - cx.defer(move |cx| { - entity.update(cx, |this, cx| this.load(cx)).ok(); + loop { + match receiver.recv_async().await { + Ok(public_key) => { + batch.insert(public_key); + } + Err(_) => return Ok(()), + } + + // The channel has no async timeout, race the receive against a timer. + let deadline = Instant::now() + BATCH_TIMEOUT; + + loop { + let now = Instant::now(); + + if now >= deadline { + break; + } + + let timer = cx.background_executor().timer(deadline - now); + futures::pin_mut!(timer); + + let recv = receiver.recv_async(); + futures::pin_mut!(recv); + + match futures::future::select(recv, timer).await { + futures::future::Either::Left((Ok(public_key), _)) => { + batch.insert(public_key); + } + futures::future::Either::Left((Err(_), _)) => return Ok(()), + futures::future::Either::Right(_) => break, + } + } + + let authors: Vec = batch.drain().collect(); + + for chunk in authors.chunks(REQUEST_CHUNK) { + let opts = SyncOptions::default(); + let filter = Filter::new() + .kind(Kind::Metadata) + .authors(chunk.iter().copied()); + + sync_bootstrap_only(&client, filter, opts).await.ok(); + } + + // Emit even when the sync failed, the database may hold newer data. + this.update(cx, |this, cx| { + this.emit_updated(authors, cx); + })?; + } }); Self { - profiles: HashMap::new(), - seen: RefCell::new(HashSet::new()), + in_flight: RefCell::new(HashSet::new()), sender, + _task: task, _subscription: subscription, } } - // Returns a placeholder until fetched; queues a fetch when uncached. - pub fn get(&self, public_key: &PublicKey) -> Profile { - if let Some(profile) = self.profiles.get(public_key) { - return profile.clone(); - } - - let public_key = *public_key; - - if self.seen.borrow_mut().insert(public_key) - && let Err(e) = self.sender.send(public_key) + /// Queue a batched relay sync for keys without one in flight. + pub fn fetch(&self, keys: impl IntoIterator) { + let mut queue = Vec::new(); { - log::warn!("failed to queue profile fetch: {e}"); - } + let mut in_flight = self.in_flight.borrow_mut(); - Profile::new(public_key, Metadata::default()) - } - - fn load(&mut self, cx: &mut Context) { - let backend = Backend::global(cx); - let client = backend.read(cx).client(); - - let work = cx.background_spawn(async move { - let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT); - let events = client.database().query(filter).await?; - - let profiles: Vec = events - .into_iter() - .map(|event| { - let metadata = Metadata::from_json(&event.content).unwrap_or_default(); - Profile::new(event.pubkey, metadata) - }) - .collect(); - - Ok::<_, Error>(profiles) - }); - - cx.spawn(async move |this, cx| { - let profiles = work.await?; - - this.update(cx, |this, cx| { - for profile in profiles { - this.profiles.insert(profile.public_key(), profile); + for key in keys { + if in_flight.insert(key) { + queue.push(key); } - cx.notify(); - })?; - - Ok::<_, Error>(()) - }) - .detach(); + } + } + for public_key in queue { + if let Err(e) = self.sender.send(public_key) { + log::warn!("failed to queue profile fetch: {e}"); + } + } } - fn apply_authors(&mut self, authors: Vec, cx: &mut Context) { + // Releases the in-flight keys so future fetches can be queued again. + fn emit_updated(&mut self, authors: Vec, cx: &mut Context) { if authors.is_empty() { return; } - let backend = Backend::global(cx); - let client = backend.read(cx).client(); - - let work = cx.background_spawn(async move { - let filter = Filter::new().kind(Kind::Metadata).authors(authors); - let events = client.database().query(filter).await?; - // Pick the latest metadata per author off the main thread. - let mut latest: HashMap = HashMap::new(); - - for event in events { - match latest.get(&event.pubkey) { - Some((ts, _)) if *ts >= event.created_at => {} - _ => { - latest.insert( - event.pubkey, - ( - event.created_at, - Metadata::from_json(&event.content).unwrap_or_default(), - ), - ); - } - } - } - - let profiles: Vec = latest - .into_iter() - .map(|(public_key, (_, metadata))| Profile::new(public_key, metadata)) - .collect(); - - Ok::<_, Error>(profiles) - }); - - cx.spawn(async move |this, cx| { - let profiles = work.await?; - - this.update(cx, |this, cx| { - for profile in profiles { - this.profiles.insert(profile.public_key(), profile); - } - cx.notify(); - })?; - - Ok::<_, Error>(()) - }) - .detach(); - } - - fn apply_seen(&mut self, cx: &mut Context) { - let authors: Vec = self.seen.borrow().iter().copied().collect(); - self.apply_authors(authors, cx); - } - - async fn handle_requests( - this: WeakEntity, - client: &Client, - receiver: &Receiver, - cx: &mut AsyncApp, - ) -> Result<(), Error> { - let mut batch: HashSet = HashSet::new(); - - loop { - match receiver.recv_async().await { - Ok(public_key) => { - batch.insert(public_key); - } - Err(_) => return Ok(()), - } - - // The channel has no async timeout, race the receive against a timer. - let deadline = Instant::now() + BATCH_TIMEOUT; - - loop { - let now = Instant::now(); - - if now >= deadline { - break; - } - - let timer = cx.background_executor().timer(deadline - now); - futures::pin_mut!(timer); - - let recv = receiver.recv_async(); - futures::pin_mut!(recv); - - match futures::future::select(recv, timer).await { - futures::future::Either::Left((Ok(public_key), _)) => { - batch.insert(public_key); - } - futures::future::Either::Left((Err(_), _)) => return Ok(()), - futures::future::Either::Right(_) => break, - } - } - - let authors: Vec = batch.drain().collect(); - - for chunk in authors.chunks(REQUEST_CHUNK) { - let opts = SyncOptions::default(); - let filter = Filter::new() - .kind(Kind::Metadata) - .authors(chunk.iter().copied()); - - if let Err(e) = sync_bootstrap_only(client, filter, opts).await { - log::warn!("profile fetch failed: {e}"); - } - } - - if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() { - return Ok(()); + { + let mut in_flight = self.in_flight.borrow_mut(); + for author in &authors { + in_flight.remove(author); } } + + cx.emit(ProfileEvent::Updated(authors)); + } +} + +/// Profile cache bound to one panel or screen. +pub struct ProfileDirectory { + profiles: HashMap, + pending: RefCell>, + tasks: Vec>, + _subscriptions: Vec, +} + +impl ProfileDirectory { + pub fn new(cx: &mut App) -> Entity { + let store = ProfileStore::global(cx); + + cx.new(|cx| { + let subscription = + cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event { + ProfileEvent::Updated(authors) => this.refresh(authors, cx), + }); + + Self { + profiles: HashMap::new(), + pending: RefCell::new(HashSet::new()), + tasks: Vec::new(), + _subscriptions: vec![subscription], + } + }) + } + + /// Resolve keys from the local database, queue the rest for a relay sync. + pub fn track(&mut self, keys: impl IntoIterator, cx: &mut Context) { + let missing: Vec = keys + .into_iter() + .filter(|key| { + !self.profiles.contains_key(key) + && !self.with_pending(|pending| pending.contains(key)) + }) + .collect(); + + if missing.is_empty() { + return; + } + + self.load(missing, true, cx); + } + + /// The profile for a key, or a placeholder that queues a relay sync. + pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile { + if let Some(profile) = self.profiles.get(public_key) { + return profile.clone(); + } + + if self.with_pending(|pending| pending.insert(*public_key)) { + let store = ProfileStore::global(cx); + store.read(cx).fetch([*public_key]); + } + + Profile::new(*public_key, Metadata::default()) + } + + /// Refreshes profiles for keys that are already tracked. + fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context) { + let tracked: Vec = authors + .iter() + .copied() + .filter(|key| { + self.profiles.contains_key(key) + || self.with_pending(|pending| pending.contains(key)) + }) + .collect(); + + if tracked.is_empty() { + return; + } + + self.load(tracked, false, cx); + } + + /// Loads profiles from the local database, queueing a relay sync for any missing keys. + fn load(&mut self, keys: Vec, queue_sync: bool, cx: &mut Context) { + // Accept events that arrive while the database query is running. + self.with_pending(|pending| { + pending.extend(keys.iter().copied()); + }); + + let backend = Backend::global(cx); + let client = backend.read(cx).client(); + let query_keys = keys.clone(); + + let work = cx.background_spawn(async move { + let authors = query_keys.iter().copied(); + let filter = Filter::new().kind(Kind::Metadata).authors(authors); + let events = client.database().query(filter).await?; + + Ok::<_, anyhow::Error>(Profile::latest_profiles(events)) + }); + + self.tasks.retain(|task| !task.is_ready()); + self.tasks.push(cx.spawn(async move |this, cx| { + let loaded = work.await; + + this.update(cx, |this, cx| { + let remaining = match loaded { + Ok(profiles) => { + let found: HashSet = + profiles.iter().map(Profile::public_key).collect(); + this.insert_loaded(profiles, cx); + keys.into_iter() + .filter(|key| !found.contains(key)) + .collect() + } + Err(e) => { + log::warn!("failed to load profiles from the database: {e}"); + keys + } + }; + + if queue_sync && !remaining.is_empty() { + let store = ProfileStore::global(cx); + store.read(cx).fetch(remaining); + } + }) + .ok(); + })); + } + + // Inserts database results, replacing anything loaded earlier. + fn insert_loaded(&mut self, profiles: Vec, cx: &mut Context) { + let mut inserted = false; + + for profile in profiles { + let public_key = profile.public_key(); + self.profiles.insert(public_key, profile); + self.with_pending(|pending| pending.remove(&public_key)); + inserted = true; + } + + if inserted { + cx.notify(); + } + } + + fn with_pending(&self, then: impl FnOnce(&mut HashSet) -> R) -> R { + let mut pending = self.pending.borrow_mut(); + then(&mut pending) } } -- 2.54.0 From 05221b5af900230e3f02967cb5195c9c4661e854 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 14:19:18 +0700 Subject: [PATCH 3/4] Replace global ProfileStore with per-view ProfileDirectory instances --- crates/signed_state/src/profile.rs | 5 +- crates/workspace/src/views/commit_diff/mod.rs | 14 +++- crates/workspace/src/views/discussion.rs | 15 ++-- crates/workspace/src/views/inbox.rs | 39 ++++++++-- crates/workspace/src/views/issues/detail.rs | 54 ++++++++++++-- crates/workspace/src/views/issues/mod.rs | 26 ++++++- crates/workspace/src/views/mod.rs | 12 ++- .../src/views/pull_requests/detail.rs | 50 ++++++++++--- .../workspace/src/views/pull_requests/mod.rs | 31 +++++++- .../workspace/src/views/pull_requests/new.rs | 14 +++- crates/workspace/src/views/repo/about.rs | 30 +++++--- crates/workspace/src/views/repo/mod.rs | 74 +++++++++++++++---- crates/workspace/src/views/repo_list.rs | 24 ++++-- crates/workspace/src/views/send_patch.rs | 21 ++++-- crates/workspace/src/views/sidebar/mod.rs | 40 ++++++++-- 15 files changed, 358 insertions(+), 91 deletions(-) diff --git a/crates/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs index c609877..d185f12 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/signed_state/src/profile.rs @@ -250,7 +250,10 @@ impl ProfileDirectory { } /// Resolve keys from the local database, queue the rest for a relay sync. - pub fn track(&mut self, keys: impl IntoIterator, cx: &mut Context) { + pub fn track(&mut self, keys: K, cx: &mut Context) + where + K: IntoIterator, + { let missing: Vec = keys .into_iter() .filter(|key| { diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index e91702c..55ec2c3 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -20,7 +20,7 @@ use gpui_component::{ use signed_git::{ CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, }; -use signed_state::RepoStore; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; @@ -305,6 +305,8 @@ pub struct CommitDiffView { loading: bool, error: Option, pane: Entity, + /// Profile for the repository owner in the tab title. + directory: Entity, tasks: Vec>>, } @@ -318,6 +320,13 @@ impl CommitDiffView { cx: &mut Context, ) -> Self { let pane = cx.new(DiffPane::new); + let directory = ProfileDirectory::new(cx); + + if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { + directory.update(cx, |directory, cx| { + directory.track([owner], cx); + }); + } // Defer until the window is ready, like the repository detail view. cx.defer_in(window, |this, window, cx| { @@ -339,6 +348,7 @@ impl CommitDiffView { loading: true, error: None, pane, + directory, tasks: Vec::new(), } } @@ -475,7 +485,7 @@ impl BasePanel for CommitDiffView { impl Panel for CommitDiffView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id)); tab_title(avatar, label) diff --git a/crates/workspace/src/views/discussion.rs b/crates/workspace/src/views/discussion.rs index db1945d..175662c 100644 --- a/crates/workspace/src/views/discussion.rs +++ b/crates/workspace/src/views/discussion.rs @@ -6,7 +6,7 @@ use gpui_component::input::{Textarea, TextareaState}; use gpui_component::tag::Tag; use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, PublicKey}; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::Avatar; use utils::relative_time; @@ -32,6 +32,7 @@ pub(crate) fn sidebar_section( id: EventId, roots: fn(&RepoStore) -> &[Event], top_gap: bool, + directory: &Entity, cx: &App, ) -> AnyElement { let store = store.read(cx); @@ -39,7 +40,6 @@ pub(crate) fn sidebar_section( // The caller bails out when the root is missing. return div().into_any_element(); }; - let profile_store = ProfileStore::global(cx); // Participants, the root author plus everyone who commented. let mut participants: Vec = vec![root.pubkey]; @@ -64,7 +64,7 @@ pub(crate) fn sidebar_section( .gap_2() .child(sidebar_title("Participants", cx)) .children(participants.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); + let profile = directory.read(cx).profile(pubkey, cx); let name = profile.name(); let picture = profile.picture(); @@ -109,7 +109,12 @@ pub(crate) fn sidebar_section( .into_any_element() } -pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &App) -> AnyElement { +pub(crate) fn comments_section( + store: &Entity, + root: EventId, + directory: &Entity, + cx: &App, +) -> AnyElement { let store = store.read(cx); let comments: Vec<&Event> = store.comments_of(&root).collect(); let title = SharedString::from(format!("Discussions {}", comments.len())); @@ -118,7 +123,7 @@ pub(crate) fn comments_section(store: &Entity, root: EventId, cx: &Ap .gap_4() .child(div().text_xs().font_semibold().child(title)) .children(comments.iter().map(|comment| { - let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey); + let profile = directory.read(cx).profile(&comment.pubkey, cx); let author = profile.name(); let picture = profile.picture(); let age = relative_time(comment.created_at); diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index dfd69ce..b96eacf 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -5,15 +5,16 @@ use anyhow::Error; use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, - Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, + AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment, + ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, + list, px, }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; use signed_core::{InboxItem, InboxReadState, RepoAddr}; use signed_state::{ - Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, + Backend, BackendEvent, Inbox, ProfileDirectory, RefreshGate, RefreshRequest, RepoListStore, }; use signed_ui::{Avatar, CountBadge}; use utils::relative_time; @@ -54,6 +55,8 @@ pub struct InboxView { refresh: RefreshGate, list: ListState, tasks: Vec>>, + /// Profiles for activity authors. + directory: Entity, _subscriptions: Vec, } @@ -101,6 +104,7 @@ impl InboxView { refresh: RefreshGate::default(), list, tasks: vec![], + directory: ProfileDirectory::new(cx), _subscriptions: subscriptions, } } @@ -272,6 +276,19 @@ impl InboxView { let rows = self.flatten_rows(§ions); self.sections = Arc::new(sections); self.rows = Arc::new(rows); + + let mut keys: Vec = self + .threads + .iter() + .flat_map(|item| item.events.iter().map(|event| event.pubkey)) + .collect(); + + keys.sort_by_key(PublicKey::to_hex); + keys.dedup(); + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); } fn group_sections(&self) -> Vec { @@ -397,8 +414,9 @@ impl InboxView { let address = section.address.clone(); let first = entry_ix == 0; let last = entry_ix + 1 == section.entries.len(); + let directory = self.directory.clone(); - thread("inbox-row", ix, item, first, last, cx) + thread("inbox-row", ix, item, first, last, &directory, cx) .on_click(cx.listener(move |this, _ev, window, cx| { this.open(root, kind, address.clone(), window, cx) })) @@ -461,6 +479,7 @@ fn thread( item: &InboxItem, first: bool, last: bool, + directory: &Entity, cx: &App, ) -> Stateful
{ let title = SharedString::from(item.title()); @@ -472,7 +491,7 @@ fn thread( let mut timeline = v_flex().gap_2().w_full(); for event in item.timeline(MAX_SUB_ACTIVITIES) { - timeline = timeline.child(sub_activity(&event, me, cx)); + timeline = timeline.child(sub_activity(&event, me, directory, cx)); } v_flex() @@ -521,9 +540,13 @@ fn thread( .child(timeline) } -fn sub_activity(event: &Event, me: Option, cx: &App) -> AnyElement { - let profile_store = ProfileStore::global(cx).read(cx); - let profile = profile_store.get(&event.pubkey); +fn sub_activity( + event: &Event, + me: Option, + directory: &Entity, + cx: &App, +) -> AnyElement { + let profile = directory.read(cx).profile(&event.pubkey, cx); let name = if Some(event.pubkey) == me { SharedString::from("You") diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index e4e5e13..fefa344 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -7,9 +7,9 @@ use gpui::{ use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; -use nostr::prelude::EventId; +use nostr::prelude::{EventId, PublicKey}; use signed_core::GitEvent; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; @@ -21,6 +21,8 @@ pub struct IssueDetailView { store: Entity, issue_id: EventId, comment_input: Entity, + /// Profiles for the author, commenters and the repository owner. + directory: Entity, _subscription: Subscription, } @@ -34,16 +36,48 @@ impl IssueDetailView { let comment_input = cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); - let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + let subscription = cx.observe(&store, |this, _store, cx| { + this.track_participants(cx); + cx.notify(); + }); + + let directory = ProfileDirectory::new(cx); Self { focus_handle: cx.focus_handle(), store, issue_id, comment_input, + directory, _subscription: subscription, } } + + /// Track the issue author, everyone who commented and the repository owner. + fn track_participants(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + let mut keys: Vec = store + .issues + .iter() + .find(|issue| issue.id == self.issue_id) + .map(|issue| issue.pubkey) + .into_iter() + .collect(); + + keys.extend( + store + .comments_of(&self.issue_id) + .map(|comment| comment.pubkey), + ); + + if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + } } impl BasePanel for IssueDetailView { @@ -66,7 +100,7 @@ impl Panel for IssueDetailView { }) .unwrap_or_else(|| SharedString::from("Issue")); - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); tab_title(avatar, short_id) } @@ -94,8 +128,7 @@ impl Render for IssueDetailView { }; let (title, author, picture, status, age, issue_id, content, pubkey) = { - let profile_store = ProfileStore::global(cx); - let profile = profile_store.read(cx).get(&issue.pubkey); + let profile = self.directory.read(cx).profile(&issue.pubkey, cx); let content = if issue.content.is_empty() { SharedString::from("No description provided.") } else { @@ -154,7 +187,11 @@ impl Render for IssueDetailView { .child( h_flex() .gap_1() - .child(Avatar::new(pubkey).name(author.clone()).picture(picture)) + .child( + Avatar::new(pubkey) + .name(author.clone()) + .picture(picture), + ) .child(author), ) .child( @@ -170,7 +207,7 @@ impl Render for IssueDetailView { ) .child(div().text_sm().child(content)), ) - .child(comments_section(&self.store, issue_id, cx)) + .child(comments_section(&self.store, issue_id, &self.directory, cx)) .child(comment_form( &self.store, issue_id, @@ -186,6 +223,7 @@ impl Render for IssueDetailView { issue_id, issue_roots, false, + &self.directory, cx, )) .into_any_element() diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index db8c869..b476188 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -15,9 +15,9 @@ use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::EventId; +use nostr::prelude::{EventId, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -59,6 +59,8 @@ pub struct IssuesView { // e.g. switching between two empty filters. synced_filter: IssueFilter, scroll_handle: VirtualListScrollHandle, + /// Profiles for issue authors and the repository owner. + directory: Entity, _subscription: Subscription, } @@ -90,6 +92,7 @@ impl IssuesView { counts: StatusCounts::default(), synced_filter: IssueFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + directory: ProfileDirectory::new(cx), _subscription: subscription, } } @@ -106,6 +109,21 @@ impl IssuesView { ) }; + let store = self.store.read(cx); + let mut keys: Vec = store.issues.iter().map(|issue| issue.pubkey).collect(); + + if let Some(owner) = store + .announcement + .as_ref() + .map(|announcement| announcement.owner) + { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + let filter_changed = self.synced_filter != filter; let visible_issues_changed = self.visible_issues != visible_issues; let counts_changed = self.counts != counts; @@ -148,7 +166,7 @@ impl IssuesView { let issue = &self.store.read(cx).issues[issue_ix]; let title = issue.activity_subject(); let id_hex = issue.id.to_hex(); - let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); + let profile = self.directory.read(cx).profile(&issue.pubkey, cx); let author = profile.name(); let picture = profile.picture(); let age = relative_time(issue.created_at); @@ -339,7 +357,7 @@ impl BasePanel for IssuesView { impl Panel for IssuesView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let label = SharedString::from(format!("{}/issues", self.repo_name)); tab_title(avatar, label) diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index e85aed5..b0d1e29 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -15,7 +15,7 @@ pub(crate) mod tree; use std::fmt::Display; use gpui::prelude::*; -use gpui::{AnyElement, App, div}; +use gpui::{AnyElement, App, Entity, div}; use gpui_component::{Sizable as _, h_flex}; pub use inbox::InboxView; pub(crate) use placeholder_panel::PlaceholderPanel; @@ -23,7 +23,7 @@ pub use repo::RepoDetailView; pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::Avatar; pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement { @@ -38,7 +38,11 @@ pub(crate) fn panel_avatar(seed: impl Display) -> AnyElement { Avatar::new(seed).xsmall().into_any_element() } -pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement { +pub(crate) fn repo_tab_avatar( + store: &RepoStore, + directory: &Entity, + cx: &App, +) -> AnyElement { let seed = store .announcement .as_ref() @@ -55,7 +59,7 @@ pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement { let owner = store .announcement .as_ref() - .map(|announcement| ProfileStore::global(cx).read(cx).get(&announcement.owner)); + .map(|announcement| directory.read(cx).profile(&announcement.owner, cx)); let (name, picture) = owner .map(|profile| (Some(profile.name()), profile.picture())) diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index 6f78b4f..afe84a7 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -20,10 +20,10 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{Event, EventId, Kind, Url}; +use nostr::prelude::{Event, EventId, Kind, PublicKey, Url}; use signed_core::{GitEvent, PullRequest, RepoAddr}; use signed_git::{FileCommit, PatchParser}; -use signed_state::{Backend, Mirrors, ProfileStore, RepoStore}; +use signed_state::{Backend, Mirrors, ProfileDirectory, RepoStore}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -77,6 +77,8 @@ pub struct PullRequestDetailView { commit_item_sizes: Rc>>, commit_scroll_handle: VirtualListScrollHandle, tasks: Vec>>, + /// Profiles for the author, commenters and the repository owner. + directory: Entity, _subscription: Subscription, } @@ -121,16 +123,37 @@ impl PullRequestDetailView { commit_item_sizes: Rc::new(Vec::new()), commit_scroll_handle: VirtualListScrollHandle::new(), tasks: Vec::new(), + directory: ProfileDirectory::new(cx), _subscription: subscription, } } + /// Track the PR author, everyone who commented and the repository owner. + fn track_participants(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + + let mut keys: Vec = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| vec![root.pubkey]) + .unwrap_or_default(); + + keys.extend(store.comments_of(&self.pr_id).map(|comment| comment.pubkey)); + + if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + } + /// Snapshot the root PR from the store and reload the diff when it changed. - /// - /// Re-runs on construction and on every store refresh. Item panels are - /// cached by the dock, so this is the only way a panel opened before the - /// store's first pass learns about its PR. fn sync(&mut self, cx: &mut Context) { + self.track_participants(cx); + let loaded = self.store.read(cx).loaded; let binding = { @@ -416,7 +439,7 @@ impl PullRequestDetailView { else { return placeholder("Pull request not found", cx); }; - let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey); + let profile = self.directory.read(cx).profile(&root.pubkey, cx); ( profile.name(), profile.picture(), @@ -469,7 +492,7 @@ impl PullRequestDetailView { this.child(div().text_sm().child(self.description.clone())) }), ) - .child(comments_section(&self.store, root_id, cx)) + .child(comments_section(&self.store, root_id, &self.directory, cx)) .child(comment_form( &self.store, root_id, @@ -480,7 +503,14 @@ impl PullRequestDetailView { )), ), ) - .child(sidebar_section(&self.store, root_id, pr_roots, true, cx)) + .child(sidebar_section( + &self.store, + root_id, + pr_roots, + true, + &self.directory, + cx, + )) .into_any_element() } @@ -791,7 +821,7 @@ impl BasePanel for PullRequestDetailView { impl Panel for PullRequestDetailView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let hex = self.pr_id.to_hex(); let id = SharedString::from(&hex[..8]); let label = if self.repo_name.is_empty() { diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index a31b1b6..1b8a921 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -13,9 +13,9 @@ use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; -use nostr::prelude::{EventId, Kind}; +use nostr::prelude::{EventId, Kind, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::{ProfileDirectory, RepoStore}; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -66,6 +66,8 @@ pub struct PullRequestsView { // e.g. switching between two empty filters. synced_filter: PullRequestFilter, scroll_handle: VirtualListScrollHandle, + /// Profiles for PR authors and the repository owner. + directory: Entity, _subscription: Subscription, } @@ -97,11 +99,13 @@ impl PullRequestsView { counts: StatusCounts::default(), synced_filter: PullRequestFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + directory: ProfileDirectory::new(cx), _subscription: subscription, } } fn rebuild(&mut self, cx: &mut Context) { + let store = self.store.read(cx); let filter = self.filter; let (visible_prs, counts) = { @@ -117,6 +121,25 @@ impl PullRequestsView { ) }; + let mut keys: Vec = store + .pull_requests + .iter() + .filter(|pr| pr.kind == Kind::GitPullRequest) + .map(|pr| pr.pubkey) + .collect(); + + if let Some(owner) = store + .announcement + .as_ref() + .map(|announcement| announcement.owner) + { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + let filter_changed = self.synced_filter != filter; if !filter_changed && self.visible_prs == visible_prs && self.counts == counts { @@ -165,7 +188,7 @@ impl PullRequestsView { let age = relative_time(pr.created_at); let status = self.store.read(cx).status_of(pr); - let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey); + let profile = self.directory.read(cx).profile(&pr.pubkey, cx); let author = profile.name(); let picture = profile.picture(); @@ -339,7 +362,7 @@ impl BasePanel for PullRequestsView { impl Panel for PullRequestsView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let label = SharedString::from(format!("{}/pulls", self.repo_name)); tab_title(avatar, label) diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index d3cb0df..f20a4df 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -23,7 +23,7 @@ use gpui_component::{ use nostr::prelude::*; use signed_core::{Announcement, RepoAddr}; use signed_git::{GitCache, Repo}; -use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; +use signed_state::{Backend, CheckoutsStore, Mirrors, ProfileDirectory, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; use utils::middle_truncate; @@ -65,6 +65,8 @@ pub struct NewPullRequestView { scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, tasks: Vec>>, + /// Profile for the repository owner in the tab title. + directory: Entity, _subscriptions: Vec, } @@ -222,6 +224,7 @@ impl NewPullRequestView { window: &mut Window, cx: &mut Context, ) -> Self { + let directory = ProfileDirectory::new(cx); let repo_name = store.read(cx).name(); let pane = cx.new(DiffPane::new); let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); @@ -270,6 +273,12 @@ impl NewPullRequestView { ), ]; + if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { + directory.update(cx, |directory, cx| { + directory.track([owner], cx); + }); + } + cx.defer_in(window, |this, window, cx| { let Some(addr) = this.store.read(cx).addr().cloned() else { return; @@ -312,6 +321,7 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), tasks: Vec::new(), + directory, _subscriptions: subscriptions, } } @@ -1313,7 +1323,7 @@ impl BasePanel for NewPullRequestView { impl Panel for NewPullRequestView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let label = SharedString::from(format!("{}/new-pull-request", self.repo_name)); tab_title(avatar, label) diff --git a/crates/workspace/src/views/repo/about.rs b/crates/workspace/src/views/repo/about.rs index b73495f..9f673d5 100644 --- a/crates/workspace/src/views/repo/about.rs +++ b/crates/workspace/src/views/repo/about.rs @@ -1,14 +1,19 @@ use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, Window, div, px}; +use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; use gpui_component::clipboard::Clipboard; use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use nostr::prelude::PublicKey; use signed_core::Announcement; -use signed_state::ProfileStore; +use signed_state::ProfileDirectory; use signed_ui::Avatar; use utils::middle_truncate; -pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, cx: &mut App) { +pub(super) fn open_about_dialog( + announcement: Announcement, + directory: Entity, + window: &mut Window, + cx: &mut App, +) { window.open_dialog(cx, move |dialog, _window, cx| { let announcement = announcement.clone(); dialog @@ -17,11 +22,15 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window, .keyboard(true) .close_button(true) .title("About") - .child(announcement_rows(&announcement, cx)) + .child(announcement_rows(&announcement, &directory, cx)) }); } -fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { +fn announcement_rows( + announcement: &Announcement, + directory: &Entity, + cx: &App, +) -> AnyElement { let mut rows: Vec = Vec::new(); rows.push(row( @@ -107,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement { if !announcement.maintainers.is_empty() { rows.push(row( "Maintainers", - maintainers(&announcement.maintainers, cx), + maintainers(&announcement.maintainers, directory, cx), cx, )); } @@ -160,13 +169,16 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement { .into_any_element() } -fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { - let profile_store = ProfileStore::global(cx); +fn maintainers( + maintainers: &[PublicKey], + directory: &Entity, + cx: &App, +) -> AnyElement { v_flex() .gap_2() .min_w_0() .children(maintainers.iter().map(|pubkey| { - let profile = profile_store.read(cx).get(pubkey); + let profile = directory.read(cx).profile(pubkey, cx); let name = profile.name(); let picture = profile.picture(); diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index c0f29b1..1f27600 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -21,12 +21,12 @@ use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, }; use nostr::nips::nip19::Nip19Coordinate; -use nostr::prelude::{RelayUrl, ToBech32, Url}; +use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::{FileCommit, GitCache, RefSync, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, ProfileStore, PushStepStatus, RepoListStore, RepoStore, + Nip34Binding, Nip34Kind, ProfileDirectory, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore, }; use signed_ui::{ @@ -94,6 +94,8 @@ pub struct RepoDetailView { refs: RefSwitcher, banners: Banners, tasks: Vec>>, + /// Profiles rendered by the header, maintainers and tab title. + directory: Entity, _subscriptions: Vec, } @@ -216,6 +218,7 @@ impl RepoDetailView { tasks: Vec::new(), banners: Banners::default(), focus_handle: cx.focus_handle(), + directory: ProfileDirectory::new(cx), _subscriptions: subscriptions, }; @@ -251,6 +254,7 @@ impl RepoDetailView { self._subscriptions .push(cx.observe_in(store, window, |this, store, window, cx| { this.refresh_ready_statuses(cx); + this.track_profiles(cx); if !this.repo_started && store.read(cx).announcement.is_some() { this.load_repo(window, cx); } @@ -259,6 +263,35 @@ impl RepoDetailView { self.refresh_ready_statuses(cx); } + /// Track the repository owner, maintainers and the local binding owner. + fn track_profiles(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + + let mut keys: Vec = store + .announcement + .as_ref() + .map(|announcement| { + let mut keys = vec![announcement.owner]; + keys.extend( + announcement + .maintainers + .iter() + .copied() + .filter(|key| *key != announcement.owner), + ); + keys + }) + .unwrap_or_default(); + + if let Some(owner) = store.nip34.as_ref().and_then(|binding| binding.owner) { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + } + fn refresh_ready_statuses(&mut self, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; @@ -315,6 +348,7 @@ impl RepoDetailView { pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context) { self.loading = true; self.error = None; + self.track_profiles(cx); cx.notify(); let (announcement, local_path) = { @@ -878,9 +912,10 @@ impl RepoDetailView { return div().into_any_element(); }; - let nip05 = ProfileStore::global(cx) + let nip05 = self + .directory .read(cx) - .get(&source.owner) + .profile(&source.owner, cx) .metadata() .nip05 .clone() @@ -917,7 +952,12 @@ impl RepoDetailView { } RepoAction::About => { if let Some(announcement) = this.announcement(cx) { - open_about_dialog(announcement.clone(), window, cx); + open_about_dialog( + announcement.clone(), + this.directory.clone(), + window, + cx, + ); } } RepoAction::Push => this.push_repository(window, cx), @@ -1261,7 +1301,7 @@ impl RepoDetailView { .as_ref() .filter(|binding| binding.kind == Nip34Kind::Initialized) { - Some(binding) => bound_repo_label(binding, cx), + Some(binding) => bound_repo_label(binding, &self.directory, cx), None => Button::new("init") .icon(CustomIconName::Init) .label("Initialize on Nostr") @@ -1457,7 +1497,6 @@ impl RepoDetailView { let Some(announcement) = self.announcement(cx) else { return div().into_any_element(); }; - let profile_store = ProfileStore::global(cx); let mut seen = HashSet::new(); let rest: Vec<_> = announcement @@ -1467,7 +1506,7 @@ impl RepoDetailView { .filter(|key| key != &announcement.owner && seen.insert(*key)) .collect(); - let owner = profile_store.read(cx).get(&announcement.owner); + let owner = self.directory.read(cx).profile(&announcement.owner, cx); let owner_name = owner.name(); let owner_picture = owner.picture(); @@ -1481,9 +1520,11 @@ impl RepoDetailView { .child( h_flex() .gap_1() - .child(Avatar::new(announcement.owner) - .name(owner_name.clone()) - .picture(owner_picture)) + .child( + Avatar::new(announcement.owner) + .name(owner_name.clone()) + .picture(owner_picture), + ) .child(div().text_xs().whitespace_nowrap().child(owner_name)), ) .when(!rest.is_empty(), |this| { @@ -2171,9 +2212,12 @@ pub(super) fn repo_display_name(store: &RepoStore) -> SharedString { } /// The owner and identifier a local repository is already bound to. -fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement { - let store = ProfileStore::global(cx); - let profile = binding.owner.map(|pk| store.read(cx).get(&pk)); +fn bound_repo_label( + binding: &Nip34Binding, + directory: &Entity, + cx: &App, +) -> AnyElement { + let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx)); h_flex() .flex_shrink_0() @@ -2216,7 +2260,7 @@ impl Panel for RepoDetailView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let store = self.store.read(cx); let name = repo_display_name(store); - let avatar = repo_tab_avatar(store, cx); + let avatar = repo_tab_avatar(store, &self.directory, cx); tab_title(avatar, name) } diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 17bfc46..3fc94e7 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -14,8 +14,9 @@ use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use nostr::prelude::PublicKey; use signed_core::Announcement; -use signed_state::{ProfileStore, RepoListStore, Timestamp}; +use signed_state::{ProfileDirectory, RepoListStore, Timestamp}; use signed_ui::{Avatar, SegmentButton}; use utils::relative_time; @@ -107,6 +108,8 @@ pub struct RepoListView { /// Indices matching [`Self::filter`] into the store's `announcements`. visible: Vec, search: Entity, + /// Profiles for the card owners. + directory: Entity, _search_subscription: Subscription, _subscription: Subscription, } @@ -118,6 +121,7 @@ impl RepoListView { cx: &mut Context, ) -> Self { let store = RepoListStore::global(cx); + let directory = ProfileDirectory::new(cx); let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { @@ -126,8 +130,7 @@ impl RepoListView { } }); - // Keep the visible slice and row sizes in sync with the store. - // Newly announced repositories appear without waiting for a click. + // Rebuild the rows when the store changes. let subscription = cx.observe(&store, |this, _store, cx| { this.rebuild_rows(cx); }); @@ -146,6 +149,7 @@ impl RepoListView { repo_len: 0, visible: Vec::new(), search, + directory, _search_subscription: search_subscription, _subscription: subscription, } @@ -158,6 +162,17 @@ impl RepoListView { self.visible = filter.visible(store, &query); + let owners: Vec = self + .visible + .iter() + .filter_map(|ix| store.announcements.get(*ix)) + .map(|announcement| announcement.owner) + .collect(); + + self.directory.update(cx, |directory, cx| { + directory.track(owners, cx); + }); + // Each virtual list row holds `COLUMNS` repo cards. let rows = self.visible.len().div_ceil(COLUMNS); @@ -176,8 +191,7 @@ impl RepoListView { last_activity: Option, cx: &mut Context, ) -> AnyElement { - let profile_store = ProfileStore::global(cx); - let owner = profile_store.read(cx).get(&announcement.owner); + let owner = self.directory.read(cx).profile(&announcement.owner, cx); let name = announcement .name diff --git a/crates/workspace/src/views/send_patch.rs b/crates/workspace/src/views/send_patch.rs index e174458..8961d0a 100644 --- a/crates/workspace/src/views/send_patch.rs +++ b/crates/workspace/src/views/send_patch.rs @@ -9,7 +9,7 @@ use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::ScrollableElement; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; -use signed_state::RepoStore; +use signed_state::{ProfileDirectory, RepoStore}; use crate::views::{repo_tab_avatar, tab_title}; @@ -24,6 +24,8 @@ pub struct SendPatchView { submitting: bool, /// Error of the last submit attempt, it keeps the panel open. error: Option, + /// Profile for the repository owner in the tab title. + directory: Entity, } impl SendPatchView { @@ -34,11 +36,17 @@ impl SendPatchView { cx: &mut Context, ) -> Self { let repo_name = store.read(cx).name(); + let directory = ProfileDirectory::new(cx); + + if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { + directory.update(cx, |directory, cx| { + directory.track([owner], cx); + }); + } + let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); - - let description = cx - .new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)")); - + let description = + cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change")); let patch = cx.new(|cx| { TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt") }); @@ -53,6 +61,7 @@ impl SendPatchView { patch, submitting: false, error: None, + directory, } } @@ -208,7 +217,7 @@ impl BasePanel for SendPatchView { impl Panel for SendPatchView { fn title(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let avatar = repo_tab_avatar(self.store.read(cx), cx); + let avatar = repo_tab_avatar(self.store.read(cx), &self.directory, cx); let label = SharedString::from(format!("{}/send-patch", self.repo_name)); tab_title(avatar, label) diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index ea71ee4..6bf8256 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -20,11 +20,11 @@ use gpui_component::dialog::DialogButtonProps; use gpui_component::input::InputState; use gpui_component::menu::{DropdownMenu, PopupMenuItem}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex}; -use nostr::prelude::RelayUrl; +use nostr::prelude::{PublicKey, RelayUrl}; use signed_core::{Announcement, RepoAddr}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, - ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore, + ProfileDirectory, RepoListStore, ResolvedLocalRepo, SyncStatusStore, }; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; @@ -69,6 +69,8 @@ pub struct SidebarPanel { unpushed: HashMap, /// Commits not on Nostr per announced repository. unsynced: HashMap, + /// Profiles rendered by the sidebar. + directory: Entity, _subscriptions: Vec, } @@ -135,6 +137,7 @@ impl SidebarPanel { scanning: false, unpushed: HashMap::new(), unsynced: HashMap::new(), + directory: ProfileDirectory::new(cx), _subscriptions: subscriptions, } } @@ -166,6 +169,25 @@ impl SidebarPanel { self.local_repos = Arc::new(local_repos); self.scanning = scanning; + let mut profiles: Vec = user.into_iter().collect(); + let owners: Vec = self + .local_repos + .iter() + .filter_map(|entry| { + entry + .nip34 + .as_ref() + .and_then(|binding| binding.owner) + .filter(|owner| !profiles.contains(owner)) + }) + .collect(); + + profiles.extend(owners); + + self.directory.update(cx, |directory, cx| { + directory.track(profiles, cx); + }); + if announcements_changed { self.request_push_watches(cx); self.unpushed.clear(); @@ -568,7 +590,7 @@ impl SidebarPanel { cx: &mut Context, ) -> impl IntoElement { let name = entry.name(); - let avatar = local_avatar(entry, cx); + let avatar = local_avatar(entry, &self.directory, cx); let id = format!("local-repo:{}", entry.path.display()); let entry = entry.clone(); @@ -769,7 +791,11 @@ pub(super) fn server_host(relay: &RelayUrl) -> SharedString { } /// The repository's pixel avatar, with the bound owner's avatar at its bottom right. -fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement { +fn local_avatar( + entry: &ResolvedLocalRepo, + directory: &Entity, + cx: &App, +) -> AnyElement { // Matches the other sidebar rows, and the badge below sits at its corner. let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall(); @@ -777,8 +803,7 @@ fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement { return avatar.into_any_element(); }; - let store = ProfileStore::global(cx); - let profile = store.read(cx).get(&owner); + let profile = directory.read(cx).profile(&owner, cx); div() .relative() @@ -831,12 +856,11 @@ impl Focusable for SidebarPanel { impl Render for SidebarPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { 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)); + .map(|public_key| self.directory.read(cx).profile(&public_key, cx)); if profile.is_none() { return self.render_sign_in(window, cx); -- 2.54.0 From 1a62dc3d6c2fc17eb2b333f4186c333e8840cf90 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 14:42:19 +0700 Subject: [PATCH 4/4] Extract profile metadata into new metadata crate --- Cargo.lock | 16 ++ crates/metadata/Cargo.toml | 16 ++ crates/metadata/src/lib.rs | 9 + .../{signed_state => metadata}/src/profile.rs | 16 +- crates/signed_state/src/bootstrap.rs | 2 +- crates/signed_state/src/lib.rs | 5 +- crates/workspace/Cargo.toml | 1 + crates/workspace/src/views/commit_diff/mod.rs | 3 +- crates/workspace/src/views/discussion.rs | 3 +- crates/workspace/src/views/inbox.rs | 5 +- crates/workspace/src/views/issues/detail.rs | 3 +- crates/workspace/src/views/issues/mod.rs | 3 +- crates/workspace/src/views/mod.rs | 3 +- .../src/views/pull_requests/detail.rs | 3 +- .../workspace/src/views/pull_requests/mod.rs | 3 +- .../workspace/src/views/pull_requests/new.rs | 3 +- crates/workspace/src/views/repo/about.rs | 2 +- crates/workspace/src/views/repo/mod.rs | 4 +- crates/workspace/src/views/repo_list.rs | 3 +- crates/workspace/src/views/send_patch.rs | 3 +- crates/workspace/src/views/sidebar/mod.rs | 5 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 1 + docs/PROFILE_DIRECTORIES.md | 230 ------------------ 24 files changed, 83 insertions(+), 260 deletions(-) create mode 100644 crates/metadata/Cargo.toml create mode 100644 crates/metadata/src/lib.rs rename crates/{signed_state => metadata}/src/profile.rs (96%) delete mode 100644 docs/PROFILE_DIRECTORIES.md diff --git a/Cargo.lock b/Cargo.lock index 6ad8a94..e0d0ab3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5457,6 +5457,20 @@ dependencies = [ "autocfg", ] +[[package]] +name = "metadata" +version = "0.2.0-alpha" +dependencies = [ + "anyhow", + "flume 0.11.1", + "futures", + "gpui-pre", + "log", + "nostr-sdk", + "signed_state", + "utils", +] + [[package]] name = "metal" version = "0.33.0" @@ -8116,6 +8130,7 @@ dependencies = [ "gpui-pre", "gpui-pre-platform", "gpui-pre-reqwest-client", + "metadata", "paths", "settings", "signed_state", @@ -11031,6 +11046,7 @@ dependencies = [ "gpui-component", "gpui-pre", "log", + "metadata", "nostr", "paths", "settings", diff --git a/crates/metadata/Cargo.toml b/crates/metadata/Cargo.toml new file mode 100644 index 0000000..a2d3c6d --- /dev/null +++ b/crates/metadata/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "metadata" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +signed_state = { path = "../signed_state" } +utils = { path = "../utils" } + +nostr-sdk.workspace = true +flume.workspace = true +futures.workspace = true +anyhow.workspace = true +gpui.workspace = true +log.workspace = true diff --git a/crates/metadata/src/lib.rs b/crates/metadata/src/lib.rs new file mode 100644 index 0000000..3d72102 --- /dev/null +++ b/crates/metadata/src/lib.rs @@ -0,0 +1,9 @@ +mod profile; + +use gpui::{App, AppContext}; +pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore}; + +/// Initialize the global profile store. +pub fn init(cx: &mut App) { + ProfileStore::set_global(cx.new(ProfileStore::new), cx); +} diff --git a/crates/signed_state/src/profile.rs b/crates/metadata/src/profile.rs similarity index 96% rename from crates/signed_state/src/profile.rs rename to crates/metadata/src/profile.rs index d185f12..94b389a 100644 --- a/crates/signed_state/src/profile.rs +++ b/crates/metadata/src/profile.rs @@ -7,11 +7,9 @@ use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task, }; use nostr_sdk::prelude::*; +use signed_state::{Backend, BackendEvent, sync_bootstrap_only}; use utils::shorten_pubkey; -use crate::backend::{Backend, BackendEvent}; -use crate::bootstrap::sync_bootstrap_only; - const BATCH_TIMEOUT: Duration = Duration::from_millis(500); const REQUEST_CHUNK: usize = 100; @@ -104,7 +102,7 @@ impl Global for GlobalProfileStore {} impl EventEmitter for ProfileStore {} impl ProfileStore { - pub fn global(cx: &App) -> Entity { + pub(crate) fn global(cx: &App) -> Entity { cx.global::().0.clone() } @@ -168,7 +166,9 @@ impl ProfileStore { .kind(Kind::Metadata) .authors(chunk.iter().copied()); - sync_bootstrap_only(&client, filter, opts).await.ok(); + if let Err(e) = sync_bootstrap_only(&client, filter, opts).await { + log::warn!("profile fetch failed: {e}"); + } } // Emit even when the sync failed, the database may hold newer data. @@ -320,7 +320,6 @@ impl ProfileDirectory { Ok::<_, anyhow::Error>(Profile::latest_profiles(events)) }); - self.tasks.retain(|task| !task.is_ready()); self.tasks.push(cx.spawn(async move |this, cx| { let loaded = work.await; @@ -365,7 +364,10 @@ impl ProfileDirectory { } } - fn with_pending(&self, then: impl FnOnce(&mut HashSet) -> R) -> R { + fn with_pending(&self, then: T) -> R + where + T: FnOnce(&mut HashSet) -> R, + { let mut pending = self.pending.borrow_mut(); then(&mut pending) } diff --git a/crates/signed_state/src/bootstrap.rs b/crates/signed_state/src/bootstrap.rs index 0fe0fa7..a9c718e 100644 --- a/crates/signed_state/src/bootstrap.rs +++ b/crates/signed_state/src/bootstrap.rs @@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only( Ok(()) } -pub(crate) async fn sync_bootstrap_only( +pub async fn sync_bootstrap_only( client: &Client, filter: Filter, opts: SyncOptions, diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index 3feebf2..2f9e15e 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -5,7 +5,6 @@ mod git_store; mod inbox; mod local_repos; mod patch_series; -mod profile; mod push; mod refresh; mod repo; @@ -16,14 +15,13 @@ mod sync_status; use std::path::{Path, PathBuf}; pub use backend::{Backend, BackendEvent}; -pub use bootstrap::user_grasp_list_servers; +pub use bootstrap::{sync_bootstrap_only, user_grasp_list_servers}; pub use checkouts::{CheckoutStatus, CheckoutsStore}; pub use git_store::Mirrors; use gpui::{App, AppContext}; pub use inbox::Inbox; pub use local_repos::{LocalReposStore, ResolvedLocalRepo}; pub use nostr_sdk::prelude::Timestamp; -pub use profile::{Profile, ProfileDirectory, ProfileEvent, ProfileStore}; pub use push::{GraspServer, PushOutcome}; pub use refresh::{RefreshGate, RefreshRequest}; pub use repo::RepoStore; @@ -51,7 +49,6 @@ pub fn init( Mirrors::install(repos_root); Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); - ProfileStore::set_global(cx.new(ProfileStore::new), cx); RepoListStore::set_global(cx.new(RepoListStore::new), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index f093cde..fba3669 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -7,6 +7,7 @@ publish.workspace = true [dependencies] assets = { path = "../assets" } dock = { workspace = true } +metadata = { path = "../metadata" } paths = { path = "../paths" } settings = { path = "../settings" } signed_core = { path = "../signed_core" } diff --git a/crates/workspace/src/views/commit_diff/mod.rs b/crates/workspace/src/views/commit_diff/mod.rs index 55ec2c3..05b796b 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -17,10 +17,11 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use signed_git::{ CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, }; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::{placeholder, tree_row}; use utils::relative_time_secs; diff --git a/crates/workspace/src/views/discussion.rs b/crates/workspace/src/views/discussion.rs index 175662c..371271a 100644 --- a/crates/workspace/src/views/discussion.rs +++ b/crates/workspace/src/views/discussion.rs @@ -5,8 +5,9 @@ use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::{Textarea, TextareaState}; use gpui_component::tag::Tag; use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; +use metadata::ProfileDirectory; use nostr::prelude::{Event, EventId, PublicKey}; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::Avatar; use utils::relative_time; diff --git a/crates/workspace/src/views/inbox.rs b/crates/workspace/src/views/inbox.rs index b96eacf..942ef26 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -11,11 +11,10 @@ use gpui::{ }; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; +use metadata::ProfileDirectory; use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; use signed_core::{InboxItem, InboxReadState, RepoAddr}; -use signed_state::{ - Backend, BackendEvent, Inbox, ProfileDirectory, RefreshGate, RefreshRequest, RepoListStore, -}; +use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore}; use signed_ui::{Avatar, CountBadge}; use utils::relative_time; diff --git a/crates/workspace/src/views/issues/detail.rs b/crates/workspace/src/views/issues/detail.rs index fefa344..53374c2 100644 --- a/crates/workspace/src/views/issues/detail.rs +++ b/crates/workspace/src/views/issues/detail.rs @@ -7,9 +7,10 @@ use gpui::{ use gpui_component::input::TextareaState; use gpui_component::scroll::ScrollableElement; use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; +use metadata::ProfileDirectory; use nostr::prelude::{EventId, PublicKey}; use signed_core::GitEvent; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; diff --git a/crates/workspace/src/views/issues/mod.rs b/crates/workspace/src/views/issues/mod.rs index b476188..7ba8f78 100644 --- a/crates/workspace/src/views/issues/mod.rs +++ b/crates/workspace/src/views/issues/mod.rs @@ -15,9 +15,10 @@ use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use nostr::prelude::{EventId, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index b0d1e29..6c70f35 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -18,12 +18,13 @@ use gpui::prelude::*; use gpui::{AnyElement, App, Entity, div}; use gpui_component::{Sizable as _, h_flex}; pub use inbox::InboxView; +use metadata::ProfileDirectory; pub(crate) use placeholder_panel::PlaceholderPanel; pub use repo::RepoDetailView; pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub use repo_list::RepoListView; pub use sidebar::SidebarPanel; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::Avatar; pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement { diff --git a/crates/workspace/src/views/pull_requests/detail.rs b/crates/workspace/src/views/pull_requests/detail.rs index afe84a7..77380d5 100644 --- a/crates/workspace/src/views/pull_requests/detail.rs +++ b/crates/workspace/src/views/pull_requests/detail.rs @@ -20,10 +20,11 @@ use gpui_component::{ ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use nostr::prelude::{Event, EventId, Kind, PublicKey, Url}; use signed_core::{GitEvent, PullRequest, RepoAddr}; use signed_git::{FileCommit, PatchParser}; -use signed_state::{Backend, Mirrors, ProfileDirectory, RepoStore}; +use signed_state::{Backend, Mirrors, RepoStore}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; diff --git a/crates/workspace/src/views/pull_requests/mod.rs b/crates/workspace/src/views/pull_requests/mod.rs index 1b8a921..b961030 100644 --- a/crates/workspace/src/views/pull_requests/mod.rs +++ b/crates/workspace/src/views/pull_requests/mod.rs @@ -13,9 +13,10 @@ use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use nostr::prelude::{EventId, Kind, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileDirectory, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; diff --git a/crates/workspace/src/views/pull_requests/new.rs b/crates/workspace/src/views/pull_requests/new.rs index f20a4df..9e8b7c7 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -20,10 +20,11 @@ use gpui_component::{ ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use nostr::prelude::*; use signed_core::{Announcement, RepoAddr}; use signed_git::{GitCache, Repo}; -use signed_state::{Backend, CheckoutsStore, Mirrors, ProfileDirectory, RepoListStore, RepoStore}; +use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; use signed_ui::{CountBadge, placeholder, ref_selector_trigger}; use utils::middle_truncate; diff --git a/crates/workspace/src/views/repo/about.rs b/crates/workspace/src/views/repo/about.rs index 9f673d5..0840095 100644 --- a/crates/workspace/src/views/repo/about.rs +++ b/crates/workspace/src/views/repo/about.rs @@ -2,9 +2,9 @@ use gpui::prelude::*; use gpui::{AnyElement, App, Entity, SharedString, Window, div, px}; use gpui_component::clipboard::Clipboard; use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; +use metadata::ProfileDirectory; use nostr::prelude::PublicKey; use signed_core::Announcement; -use signed_state::ProfileDirectory; use signed_ui::Avatar; use utils::middle_truncate; diff --git a/crates/workspace/src/views/repo/mod.rs b/crates/workspace/src/views/repo/mod.rs index 1f27600..7546211 100644 --- a/crates/workspace/src/views/repo/mod.rs +++ b/crates/workspace/src/views/repo/mod.rs @@ -20,14 +20,14 @@ use gpui_component::spinner::Spinner; use gpui_component::{ ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, }; +use metadata::ProfileDirectory; use nostr::nips::nip19::Nip19Coordinate; use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url}; use signed_core::{Announcement, RepoAddr, RepoStatus}; use signed_git::{FileCommit, GitCache, RefSync, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, ProfileDirectory, PushStepStatus, RepoListStore, RepoStore, - SyncStatusStore, + Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore, }; use signed_ui::{ Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger, diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 3fc94e7..a950bd4 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -14,9 +14,10 @@ use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; +use metadata::ProfileDirectory; use nostr::prelude::PublicKey; use signed_core::Announcement; -use signed_state::{ProfileDirectory, RepoListStore, Timestamp}; +use signed_state::{RepoListStore, Timestamp}; use signed_ui::{Avatar, SegmentButton}; use utils::relative_time; diff --git a/crates/workspace/src/views/send_patch.rs b/crates/workspace/src/views/send_patch.rs index 8961d0a..4bb776d 100644 --- a/crates/workspace/src/views/send_patch.rs +++ b/crates/workspace/src/views/send_patch.rs @@ -9,7 +9,8 @@ use gpui_component::input::{Input, InputState, Textarea, TextareaState}; use gpui_component::scroll::ScrollableElement; use gpui_component::spinner::Spinner; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; -use signed_state::{ProfileDirectory, RepoStore}; +use metadata::ProfileDirectory; +use signed_state::RepoStore; use crate::views::{repo_tab_avatar, tab_title}; diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index 6bf8256..0c12d01 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -20,11 +20,12 @@ use gpui_component::dialog::DialogButtonProps; use gpui_component::input::InputState; use gpui_component::menu::{DropdownMenu, PopupMenuItem}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex}; +use metadata::{Profile, ProfileDirectory}; use nostr::prelude::{PublicKey, RelayUrl}; use signed_core::{Announcement, RepoAddr}; use signed_state::{ - Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, - ProfileDirectory, RepoListStore, ResolvedLocalRepo, SyncStatusStore, + Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore, + ResolvedLocalRepo, SyncStatusStore, }; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; diff --git a/desktop/Cargo.toml b/desktop/Cargo.toml index 2c1e9cd..92623b9 100644 --- a/desktop/Cargo.toml +++ b/desktop/Cargo.toml @@ -28,6 +28,7 @@ icons = [ [dependencies] assets = { path = "../crates/assets" } +metadata = { path = "../crates/metadata" } paths = { path = "../crates/paths" } settings = { path = "../crates/settings" } signed_state = { path = "../crates/signed_state" } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index d60f470..c732e4a 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -82,6 +82,7 @@ fn main() { settings.local_repos.scan_paths, cx, ); + metadata::init(cx); cx.set_app_identity("info.reya.signed", "Signed"); diff --git a/docs/PROFILE_DIRECTORIES.md b/docs/PROFILE_DIRECTORIES.md deleted file mode 100644 index 8ad5cf8..0000000 --- a/docs/PROFILE_DIRECTORIES.md +++ /dev/null @@ -1,230 +0,0 @@ -# Per-view profile directories - -Status: proposal -Reference: coop `a365a8f` "bind user metadata to per-view directories" - -## Background - -`ProfileStore` (`crates/signed_state/src/profile.rs`) is a single global entity: - -- Warm-loads the 500 most recent metadata events at startup (`WARM_LIMIT`). -- `get(pk)` returns a cached clone or a placeholder and queues a network fetch - (flume channel, 500 ms batching, chunks of 100, `sync_bootstrap_only`). -- Subscribes to `BackendEvent::ProfileUpdates` to re-query the local database. -- Every view reads through `ProfileStore::global(cx)`. - -Problems: - -- Invalidation is global: every `cx.notify()` on the store invalidates every - panel that read it, so any profile update repaints all profile-consuming - surfaces (including `panel.cached` ones), not just the panel whose data - changed. -- Startup work and memory scale with the local database, not with what is on - screen, and the global map never shrinks. -- Every screen shares one data set, so nothing is cancellable or reclaimable. - -## Goals - -- Profile data is owned per panel/screen. A directory dies with its panel, - cancelling outstanding work and dropping its cache. -- An update re-renders only the panel whose data changed: reads during render - are tracked by the window, and `panel.cached` repaints a panel as soon as - its directory notifies. -- Local database first: query the local cache, fetch from relays only for keys - the directory cannot resolve. -- One shared network pipeline (batching, dedupe) so N panels still produce one - relay sync per key. - -Non-goals: changing `Profile` accessors, changing `Backend`, persistence. - -## Design - -Two pieces replace the fat global: - -1. `ProfileStore` (global, thin): network fetch service + event broadcaster. - Holds no profiles and does no warm load. - - `fetch(keys)`: dedupe via an in-flight set, batch via the existing flume - channel + 500 ms timeout, chunks of 100 through `sync_bootstrap_only`. - - Emits `ProfileEvent::Updated(Vec)` (a) after each synced batch - and (b) when `BackendEvent::ProfileUpdates` arrives from the pump. -2. `ProfileDirectory` (per panel/screen entity): the metadata one surface - renders. - - `track(keys, cx)`: queries the local database for missing keys, inserts - what it finds, queues the rest for a network fetch. - - `profile(key, cx)`: cached value or placeholder; a miss marks the key - pending and queues a fetch (same call shape as today's `get`). - - Subscribes to `ProfileEvent::Updated`, refreshes only keys it tracks or - has pending, notifies itself on change. - - Stores its own tasks and subscription; both drop with the panel. - -``` -panel view ──track(keys)──▶ ProfileDirectory ──DB query──▶ local database - ▲ │ keys not found - │ cx.notify() ▼ - └────refresh(authors)── ProfileEvent::Updated ──── ProfileStore (global) - ▲ │ batch + - │ │ sync_bootstrap_only - BackendEvent::ProfileUpdates ▼ - relay pump ─────────────▶ relays -``` - -Panels own `Entity` and read it directly in render; no -`cx.observe` is needed. gpui-pre records which entities a window reads while -drawing (`App::record_entities_accessed`) and, on `cx.notify`, invalidates -exactly the views that read them (`App::notify` → `Window::invalidate_view`). -This is the mechanism coop relies on — its `Directory` only calls -`cx.notify()` — and it composes with `panel.cached(...)`, which repaints a -panel when the panel view itself is invalidated. Scoping data per directory is -what scopes invalidation: notifying the old global store would invalidate -every panel that ever read it, while notifying one directory invalidates only -the panels reading it. - -## API sketch - -```rust -// signed_state/src/profile.rs - -#[derive(Debug, Clone)] -pub enum ProfileEvent { - /// Metadata for these authors may have changed in the local database. - Updated(Vec), -} - -impl EventEmitter for ProfileStore {} - -/// Thin global: batches metadata requests from directories and syncs them -/// into the local database. Holds no profile data. -pub struct ProfileStore { - in_flight: RefCell>, - sender: Sender, - _subscription: Subscription, -} - -impl ProfileStore { - pub fn global(cx: &App) -> Entity; - pub(crate) fn set_global(entity: Entity, cx: &mut App); - /// Queue a batched metadata request; no-op for keys already in flight. - pub fn fetch(&self, keys: impl IntoIterator); -} - -/// Metadata cache for one panel or screen. -pub struct ProfileDirectory { - profiles: HashMap, - pending: RefCell>, - tasks: SmallVec<[Task<()>; 2]>, - _subscriptions: SmallVec<[Subscription; 1]>, -} - -impl ProfileDirectory { - /// Create a directory, following the profile store's events. - pub fn new(cx: &mut App) -> Entity; - /// Resolve keys from the local database, queue the rest for fetch. - pub fn track(&mut self, keys: impl IntoIterator, cx: &mut Context); - /// The profile for a key, or a placeholder that queues a fetch. - pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile; - /// Re-query tracked/pending keys named by a `ProfileEvent`. - fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context); -} -``` - -Panel wiring: - -```rust -pub struct IssuesView { - // ... - directory: Entity, -} - -// constructor — no observe wiring; render-time reads of the directory are -// tracked automatically and `cx.notify()` on it invalidates the panel. -let directory = ProfileDirectory::new(cx); -``` - -Behavior details carried over from coop's `Directory`: - -- The `pending` set guards the window between `track`/`profile` and the - database task finishing, so events arriving mid-query are still accepted. -- Inserts never overwrite data that already arrived from an event - (insert-if-absent on bulk load). -- `refresh` removes a pending key only when the database produced a profile, - so keys that exist on no relay stay pending and never loop into refetches; - `in_flight` in the store is released on every `Updated` emission. - -## Where directories live - -| Surface | File | Keys tracked | When | -| --- | --- | --- | --- | -| `SidebarPanel` | `views/sidebar/mod.rs` | current user, local repo owners | on `SignerChanged`, on local repos update | -| `RepoListView` | `views/repo_list.rs` | announcement owners | when cards rebuild | -| `RepoDetailView` | `views/repo/mod.rs`, `repo/about.rs` | owner, maintainers, binding owners | on announcement/sync; about dialog captures the handle | -| `IssuesView` | `views/issues/mod.rs` | issue authors, repo owner (tab title) | in `rebuild` | -| `IssueDetailView` | `views/issues/detail.rs` | author, discussion participants | on store events; passed to `discussion::` helpers | -| `PullRequestsView` | `views/pull_requests/mod.rs` | PR authors, repo owner | in `rebuild` | -| `PullRequestDetailView` | `views/pull_requests/detail.rs` | author, participants | on store events | -| `NewPullRequestView`, `SendPatchView`, `CommitDiffView` | respective files | repo owner (tab title only) | on construction | -| `InboxView` | `views/inbox.rs` | activity authors | in rows rebuild | - -Helpers that stop reading the global and take `&Entity` -instead — every caller has `&mut self` or captures the handle: - -- `repo_tab_avatar(store, cx)` in `views/mod.rs` (all `Panel::title` impls) -- `discussion::sidebar_section`, `discussion::comments_section` -- `about::maintainers` via `open_about_dialog` (dialog closure captures the - entity handle, same as coop's device-registry notification) -- `bound_repo_label` in `views/repo/mod.rs` -- `sub_activity` in `views/inbox.rs`, `local_avatar` in `views/sidebar/mod.rs` - -## Migration steps - -1. `signed_state`: introduce `ProfileEvent` + `EventEmitter`, extract the - "latest metadata per author" reduction from `apply_authors` into a pure - `fn latest_profiles(events: Vec) -> Vec`, and strip - `ProfileStore` down to the fetch service (drop `profiles`, `seen`, `load`, - `apply_seen`, `apply_authors`, `get`, `WARM_LIMIT`). The batch loop emits - `ProfileEvent::Updated` instead of querying; the `BackendEvent::ProfileUpdates` - subscription re-emits and releases in-flight keys. -2. `signed_state`: add `ProfileDirectory` in the same `profile.rs` and export - it from `lib.rs`. `init` keeps creating the global store. -3. `workspace`: add a `directory` field to each surface in the table, replace - `ProfileStore::global(cx).read(cx).get(...)` with - `self.directory.read(cx).profile(...)`, thread the handle through the free - helpers, and call `track` from each surface's data-load/rebuild path. -4. Delete `ProfileStore::get` last; the compiler walks the remaining call - sites. The workspace crate will not compile between steps 1 and 3, so this - lands as one change. - -## Edge cases - -- **Relay event during a database query**: covered by the pending set. -- **Keys with no profile on relays**: stay pending/in-flight, no retry storm - (strictly no worse than today's permanent `seen` dedupe). -- **Panel closed**: directory drops → subscription, tasks, and cache drop; no - cross-panel notifies. -- **Same person on many panels**: each directory keeps its own `Profile` - clone. Metadata JSON is small and queries are local index lookups; if this - ever shows up, a directory can attach to `RepoStore` later. -- **Own profile edits**: publishing flows through `Backend` → pump → - `ProfileUpdates` → store event → directories refresh, so no extra channel is - needed. -- **Dock tab titles**: `Panel::title` receives `&mut self`, so panels pass - their own directory to `repo_tab_avatar`; no global needed for chrome. - -## Testing - -- Unit: `latest_profiles` keeps the newest event per author (pure, no GPUI). -- Unit: `ProfileDirectory` pending-set dedupe and insert-if-absent merge via - `TestAppContext` (the dock crate already uses GPUI test contexts), seeding - the directory through a `#[cfg(test)]` helper rather than the network. -- Manual: cold start with an empty database, open explore / repo / issues / - inbox, confirm placeholders resolve without interacting with each panel, and - that `panel.cached` tabs still repaint on profile arrival. - -## Alternatives considered - -- **Attach the directory to `RepoStore`** instead of panels: repo surfaces - sharing one store would share one directory, but `RepoStore` instances are - created per open and sidebar/inbox/explore still need their own; revisit - only if duplicated queries ever matter. -- **Keep the global store as-is**: read tracking already propagates updates, - but the warm load, unbounded cache, and all-panels invalidation remain; does - not meet the goals. -- 2.54.0