From 30ff255372a9d9976f69d7c3bdef624c2d480580 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 15 Oct 2026 07:46:03 +0000 Subject: [PATCH] chore: bind user metadata to per-view directories (#31) Reviewed-on: https://git.reya.info/reya/signed/pulls/31 --- AGENTS.md | 6 + Cargo.lock | 16 + crates/metadata/Cargo.toml | 16 + crates/metadata/src/lib.rs | 9 + crates/metadata/src/profile.rs | 374 ++++++++++++++++++ crates/signed_state/src/bootstrap.rs | 2 +- crates/signed_state/src/lib.rs | 5 +- crates/signed_state/src/profile.rs | 287 -------------- crates/workspace/Cargo.toml | 1 + crates/workspace/src/views/commit_diff/mod.rs | 13 +- crates/workspace/src/views/discussion.rs | 16 +- crates/workspace/src/views/inbox.rs | 42 +- crates/workspace/src/views/issues/detail.rs | 55 ++- crates/workspace/src/views/issues/mod.rs | 27 +- crates/workspace/src/views/mod.rs | 13 +- .../src/views/pull_requests/detail.rs | 51 ++- .../workspace/src/views/pull_requests/mod.rs | 32 +- .../workspace/src/views/pull_requests/new.rs | 13 +- crates/workspace/src/views/repo/about.rs | 30 +- crates/workspace/src/views/repo/mod.rs | 76 +++- crates/workspace/src/views/repo_list.rs | 25 +- crates/workspace/src/views/send_patch.rs | 20 +- crates/workspace/src/views/sidebar/mod.rs | 43 +- desktop/Cargo.toml | 1 + desktop/src/main.rs | 1 + 25 files changed, 791 insertions(+), 383 deletions(-) create mode 100644 crates/metadata/Cargo.toml create mode 100644 crates/metadata/src/lib.rs create mode 100644 crates/metadata/src/profile.rs delete mode 100644 crates/signed_state/src/profile.rs 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/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/metadata/src/profile.rs b/crates/metadata/src/profile.rs new file mode 100644 index 0000000..94b389a --- /dev/null +++ b/crates/metadata/src/profile.rs @@ -0,0 +1,374 @@ +use std::cell::RefCell; +use std::collections::{HashMap, HashSet}; +use std::time::{Duration, Instant}; + +use flume::Sender; +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; + +const BATCH_TIMEOUT: Duration = Duration::from_millis(500); +const REQUEST_CHUNK: usize = 100; + +#[derive(Debug, Clone)] +pub struct Profile { + public_key: PublicKey, + metadata: Metadata, +} + +impl Profile { + pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { + Self { + public_key, + metadata, + } + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + pub fn metadata(&self) -> &Metadata { + &self.metadata + } + + // Falls back to `name`, then a shortened npub. + pub fn name(&self) -> SharedString { + if let Some(display_name) = self.metadata.display_name.as_ref() + && !display_name.is_empty() + { + return SharedString::from(display_name.trim().to_owned()); + } + + if let Some(name) = self.metadata.name.as_ref() + && !name.is_empty() + { + return SharedString::from(name.trim().to_owned()); + } + + SharedString::from(shorten_pubkey(self.public_key)) + } + + pub fn picture(&self) -> Option { + self.metadata + .picture + .as_ref() + .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 { + in_flight: RefCell>, + sender: Sender, + _task: Task>, + _subscription: Subscription, +} + +struct GlobalProfileStore(Entity); + +impl Global for GlobalProfileStore {} + +impl EventEmitter for ProfileStore {} + +impl ProfileStore { + pub(crate) fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + pub(crate) fn set_global(entity: Entity, cx: &mut App) { + cx.set_global(GlobalProfileStore(entity)); + } + + pub(crate) fn new(cx: &mut Context) -> Self { + let backend = Backend::global(cx); + 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.emit_updated(authors.clone(), cx); + } + }); + + let task = cx.spawn(async move |this, cx| { + 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}"); + } + } + + // Emit even when the sync failed, the database may hold newer data. + this.update(cx, |this, cx| { + this.emit_updated(authors, cx); + })?; + } + }); + + Self { + in_flight: RefCell::new(HashSet::new()), + sender, + _task: task, + _subscription: subscription, + } + } + + /// Queue a batched relay sync for keys without one in flight. + pub fn fetch(&self, keys: impl IntoIterator) { + let mut queue = Vec::new(); + { + let mut in_flight = self.in_flight.borrow_mut(); + + for key in keys { + if in_flight.insert(key) { + queue.push(key); + } + } + } + for public_key in queue { + if let Err(e) = self.sender.send(public_key) { + log::warn!("failed to queue profile fetch: {e}"); + } + } + } + + // 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 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: K, cx: &mut Context) + where + K: IntoIterator, + { + 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.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: 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 fc47a83..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, 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/signed_state/src/profile.rs b/crates/signed_state/src/profile.rs deleted file mode 100644 index 0f59430..0000000 --- a/crates/signed_state/src/profile.rs +++ /dev/null @@ -1,287 +0,0 @@ -use std::cell::RefCell; -use std::collections::{HashMap, HashSet}; -use std::time::{Duration, Instant}; - -use anyhow::Error; -use flume::{Receiver, Sender}; -use gpui::{ - App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity, -}; -use nostr_sdk::prelude::*; -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; -const WARM_LIMIT: usize = 500; - -#[derive(Debug, Clone)] -pub struct Profile { - public_key: PublicKey, - metadata: Metadata, -} - -impl Profile { - pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { - Self { - public_key, - metadata, - } - } - - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn metadata(&self) -> &Metadata { - &self.metadata - } - - // Falls back to `name`, then a shortened npub. - pub fn name(&self) -> SharedString { - if let Some(display_name) = self.metadata.display_name.as_ref() - && !display_name.is_empty() - { - return SharedString::from(display_name.trim().to_owned()); - } - - if let Some(name) = self.metadata.name.as_ref() - && !name.is_empty() - { - return SharedString::from(name.trim().to_owned()); - } - - SharedString::from(shorten_pubkey(self.public_key)) - } - - pub fn picture(&self) -> Option { - self.metadata - .picture - .as_ref() - .filter(|p| !p.is_empty()) - .map(|p| SharedString::from(p.clone())) - } -} - -pub struct ProfileStore { - profiles: HashMap, - seen: RefCell>, - sender: Sender, - _subscription: Subscription, -} - -struct GlobalProfileStore(Entity); - -impl Global for GlobalProfileStore {} - -impl ProfileStore { - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - pub(crate) fn set_global(entity: Entity, cx: &mut App) { - cx.set_global(GlobalProfileStore(entity)); - } - - 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); - } - }); - - 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(); - - cx.defer(move |cx| { - entity.update(cx, |this, cx| this.load(cx)).ok(); - }); - - Self { - profiles: HashMap::new(), - seen: RefCell::new(HashSet::new()), - sender, - _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) - { - log::warn!("failed to queue profile fetch: {e}"); - } - - 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); - } - cx.notify(); - })?; - - Ok::<_, Error>(()) - }) - .detach(); - } - - fn apply_authors(&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(()); - } - } - } -} 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 e91702c..05b796b 100644 --- a/crates/workspace/src/views/commit_diff/mod.rs +++ b/crates/workspace/src/views/commit_diff/mod.rs @@ -17,6 +17,7 @@ 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, }; @@ -305,6 +306,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 +321,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 +349,7 @@ impl CommitDiffView { loading: true, error: None, pane, + directory, tasks: Vec::new(), } } @@ -475,7 +486,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..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::{ProfileStore, RepoStore}; +use signed_state::RepoStore; use signed_ui::Avatar; use utils::relative_time; @@ -32,6 +33,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 +41,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 +65,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 +110,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 +124,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..942ef26 100644 --- a/crates/workspace/src/views/inbox.rs +++ b/crates/workspace/src/views/inbox.rs @@ -5,16 +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 metadata::ProfileDirectory; use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp}; use signed_core::{InboxItem, InboxReadState, RepoAddr}; -use signed_state::{ - Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, -}; +use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore}; use signed_ui::{Avatar, CountBadge}; use utils::relative_time; @@ -54,6 +54,8 @@ pub struct InboxView { refresh: RefreshGate, list: ListState, tasks: Vec>>, + /// Profiles for activity authors. + directory: Entity, _subscriptions: Vec, } @@ -101,6 +103,7 @@ impl InboxView { refresh: RefreshGate::default(), list, tasks: vec![], + directory: ProfileDirectory::new(cx), _subscriptions: subscriptions, } } @@ -272,6 +275,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 +413,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 +478,7 @@ fn thread( item: &InboxItem, first: bool, last: bool, + directory: &Entity, cx: &App, ) -> Stateful
{ let title = SharedString::from(item.title()); @@ -472,7 +490,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 +539,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..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 nostr::prelude::EventId; +use metadata::ProfileDirectory; +use nostr::prelude::{EventId, PublicKey}; use signed_core::GitEvent; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, placeholder, status_badge}; use utils::relative_time; @@ -21,6 +22,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 +37,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 +101,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 +129,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 +188,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 +208,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 +224,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..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 nostr::prelude::EventId; +use metadata::ProfileDirectory; +use nostr::prelude::{EventId, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -59,6 +60,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 +93,7 @@ impl IssuesView { counts: StatusCounts::default(), synced_filter: IssueFilter::Open, scroll_handle: VirtualListScrollHandle::new(), + directory: ProfileDirectory::new(cx), _subscription: subscription, } } @@ -106,6 +110,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 +167,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 +358,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..6c70f35 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -15,15 +15,16 @@ 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; +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::{ProfileStore, RepoStore}; +use signed_state::RepoStore; use signed_ui::Avatar; pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement { @@ -38,7 +39,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 +60,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..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 nostr::prelude::{Event, EventId, Kind, Url}; +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, ProfileStore, RepoStore}; +use signed_state::{Backend, Mirrors, RepoStore}; use signed_ui::{Avatar, CountBadge, placeholder, status_badge}; use utils::{relative_time, relative_time_secs}; @@ -77,6 +78,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 +124,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 +440,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 +493,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 +504,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 +822,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..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 nostr::prelude::{EventId, Kind}; +use metadata::ProfileDirectory; +use nostr::prelude::{EventId, Kind, PublicKey}; use signed_core::{GitEvent, RepoStatus}; -use signed_state::{ProfileStore, RepoStore}; +use signed_state::RepoStore; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use utils::relative_time; @@ -66,6 +67,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 +100,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 +122,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 +189,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 +363,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..9e8b7c7 100644 --- a/crates/workspace/src/views/pull_requests/new.rs +++ b/crates/workspace/src/views/pull_requests/new.rs @@ -20,6 +20,7 @@ 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}; @@ -65,6 +66,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 +225,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 +274,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 +322,7 @@ impl NewPullRequestView { scroll_handle: VirtualListScrollHandle::new(), item_sizes: Rc::new(Vec::new()), tasks: Vec::new(), + directory, _subscriptions: subscriptions, } } @@ -1313,7 +1324,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..0840095 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 metadata::ProfileDirectory; use nostr::prelude::PublicKey; use signed_core::Announcement; -use signed_state::ProfileStore; 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..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::{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, - SyncStatusStore, + Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore, }; use signed_ui::{ Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger, @@ -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..a950bd4 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -14,8 +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::{ProfileStore, RepoListStore, Timestamp}; +use signed_state::{RepoListStore, Timestamp}; use signed_ui::{Avatar, SegmentButton}; use utils::relative_time; @@ -107,6 +109,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 +122,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 +131,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 +150,7 @@ impl RepoListView { repo_len: 0, visible: Vec::new(), search, + directory, _search_subscription: search_subscription, _subscription: subscription, } @@ -158,6 +163,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 +192,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..4bb776d 100644 --- a/crates/workspace/src/views/send_patch.rs +++ b/crates/workspace/src/views/send_patch.rs @@ -9,6 +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 metadata::ProfileDirectory; use signed_state::RepoStore; use crate::views::{repo_tab_avatar, tab_title}; @@ -24,6 +25,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 +37,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 +62,7 @@ impl SendPatchView { patch, submitting: false, error: None, + directory, } } @@ -208,7 +218,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..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 nostr::prelude::RelayUrl; +use metadata::{Profile, ProfileDirectory}; +use nostr::prelude::{PublicKey, RelayUrl}; use signed_core::{Announcement, RepoAddr}; use signed_state::{ - Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, - ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore, + Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore, + ResolvedLocalRepo, SyncStatusStore, }; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; @@ -69,6 +70,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 +138,7 @@ impl SidebarPanel { scanning: false, unpushed: HashMap::new(), unsynced: HashMap::new(), + directory: ProfileDirectory::new(cx), _subscriptions: subscriptions, } } @@ -166,6 +170,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 +591,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 +792,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 +804,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 +857,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); 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");