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);