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.