chore: bind user metadata to per-view directories (#31)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s

Reviewed-on: #31
This commit was merged in pull request #31.
This commit is contained in:
2026-10-15 07:46:03 +00:00
parent cd61a09320
commit 30ff255372
25 changed files with 791 additions and 383 deletions
+6
View File
@@ -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. 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 ## Entity events
While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`. While updating an entity (`cx: Context<T>`), it can emit an event using `cx.emit(event)`. Entities register which events they can emit by declaring `impl EventEmitter<EventType> for EntityType {}`.
Generated
+16
View File
@@ -5457,6 +5457,20 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "metal" name = "metal"
version = "0.33.0" version = "0.33.0"
@@ -8116,6 +8130,7 @@ dependencies = [
"gpui-pre", "gpui-pre",
"gpui-pre-platform", "gpui-pre-platform",
"gpui-pre-reqwest-client", "gpui-pre-reqwest-client",
"metadata",
"paths", "paths",
"settings", "settings",
"signed_state", "signed_state",
@@ -11031,6 +11046,7 @@ dependencies = [
"gpui-component", "gpui-component",
"gpui-pre", "gpui-pre",
"log", "log",
"metadata",
"nostr", "nostr",
"paths", "paths",
"settings", "settings",
+16
View File
@@ -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
+9
View File
@@ -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);
}
+374
View File
@@ -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<SharedString> {
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<Item = Event>) -> Vec<Self> {
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = 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<PublicKey>),
}
/// Batches metadata requests into relay syncs feeding the local database.
pub struct ProfileStore {
in_flight: RefCell<HashSet<PublicKey>>,
sender: Sender<PublicKey>,
_task: Task<Result<(), anyhow::Error>>,
_subscription: Subscription,
}
struct GlobalProfileStore(Entity<ProfileStore>);
impl Global for GlobalProfileStore {}
impl EventEmitter<ProfileEvent> for ProfileStore {}
impl ProfileStore {
pub(crate) fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalProfileStore(entity));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>();
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<PublicKey> = 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<PublicKey> = 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<Item = PublicKey>) {
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<PublicKey>, cx: &mut Context<Self>) {
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<PublicKey, Profile>,
pending: RefCell<HashSet<PublicKey>>,
tasks: Vec<Task<()>>,
_subscriptions: Vec<Subscription>,
}
impl ProfileDirectory {
pub fn new(cx: &mut App) -> Entity<Self> {
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<K>(&mut self, keys: K, cx: &mut Context<Self>)
where
K: IntoIterator<Item = PublicKey>,
{
let missing: Vec<PublicKey> = 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<Self>) {
let tracked: Vec<PublicKey> = 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<PublicKey>, queue_sync: bool, cx: &mut Context<Self>) {
// 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<PublicKey> =
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<Profile>, cx: &mut Context<Self>) {
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<R, T>(&self, then: T) -> R
where
T: FnOnce(&mut HashSet<PublicKey>) -> R,
{
let mut pending = self.pending.borrow_mut();
then(&mut pending)
}
}
+1 -1
View File
@@ -46,7 +46,7 @@ pub(crate) async fn subscribe_bootstrap_only(
Ok(()) Ok(())
} }
pub(crate) async fn sync_bootstrap_only( pub async fn sync_bootstrap_only(
client: &Client, client: &Client,
filter: Filter, filter: Filter,
opts: SyncOptions, opts: SyncOptions,
+1 -4
View File
@@ -5,7 +5,6 @@ mod git_store;
mod inbox; mod inbox;
mod local_repos; mod local_repos;
mod patch_series; mod patch_series;
mod profile;
mod push; mod push;
mod refresh; mod refresh;
mod repo; mod repo;
@@ -16,14 +15,13 @@ mod sync_status;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent}; 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 checkouts::{CheckoutStatus, CheckoutsStore};
pub use git_store::Mirrors; pub use git_store::Mirrors;
use gpui::{App, AppContext}; use gpui::{App, AppContext};
pub use inbox::Inbox; pub use inbox::Inbox;
pub use local_repos::{LocalReposStore, ResolvedLocalRepo}; pub use local_repos::{LocalReposStore, ResolvedLocalRepo};
pub use nostr_sdk::prelude::Timestamp; pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use push::{GraspServer, PushOutcome}; pub use push::{GraspServer, PushOutcome};
pub use refresh::{RefreshGate, RefreshRequest}; pub use refresh::{RefreshGate, RefreshRequest};
pub use repo::RepoStore; pub use repo::RepoStore;
@@ -51,7 +49,6 @@ pub fn init(
Mirrors::install(repos_root); Mirrors::install(repos_root);
Backend::set_global(cx.new(|cx| Backend::new(client, signer, cx)), cx); 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); RepoListStore::set_global(cx.new(RepoListStore::new), cx);
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx); LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx); CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
-287
View File
@@ -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<SharedString> {
self.metadata
.picture
.as_ref()
.filter(|p| !p.is_empty())
.map(|p| SharedString::from(p.clone()))
}
}
pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>,
seen: RefCell<HashSet<PublicKey>>,
sender: Sender<PublicKey>,
_subscription: Subscription,
}
struct GlobalProfileStore(Entity<ProfileStore>);
impl Global for GlobalProfileStore {}
impl ProfileStore {
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalProfileStore(entity));
}
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let entity = cx.entity().downgrade();
let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>();
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<Self>) {
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<Profile> = 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<PublicKey>, cx: &mut Context<Self>) {
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<PublicKey, (Timestamp, Metadata)> = 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<Profile> = 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<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
self.apply_authors(authors, cx);
}
async fn handle_requests(
this: WeakEntity<ProfileStore>,
client: &Client,
receiver: &Receiver<PublicKey>,
cx: &mut AsyncApp,
) -> Result<(), Error> {
let mut batch: HashSet<PublicKey> = 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<PublicKey> = 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(());
}
}
}
}
+1
View File
@@ -7,6 +7,7 @@ publish.workspace = true
[dependencies] [dependencies]
assets = { path = "../assets" } assets = { path = "../assets" }
dock = { workspace = true } dock = { workspace = true }
metadata = { path = "../metadata" }
paths = { path = "../paths" } paths = { path = "../paths" }
settings = { path = "../settings" } settings = { path = "../settings" }
signed_core = { path = "../signed_core" } signed_core = { path = "../signed_core" }
+12 -1
View File
@@ -17,6 +17,7 @@ use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree};
use gpui_component::{ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
}; };
use metadata::ProfileDirectory;
use signed_git::{ use signed_git::{
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
}; };
@@ -305,6 +306,8 @@ pub struct CommitDiffView {
loading: bool, loading: bool,
error: Option<SharedString>, error: Option<SharedString>,
pane: Entity<DiffPane>, pane: Entity<DiffPane>,
/// Profile for the repository owner in the tab title.
directory: Entity<ProfileDirectory>,
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>, tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
} }
@@ -318,6 +321,13 @@ impl CommitDiffView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let pane = cx.new(DiffPane::new); 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. // Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
@@ -339,6 +349,7 @@ impl CommitDiffView {
loading: true, loading: true,
error: None, error: None,
pane, pane,
directory,
tasks: Vec::new(), tasks: Vec::new(),
} }
} }
@@ -475,7 +486,7 @@ impl BasePanel for CommitDiffView {
impl Panel for CommitDiffView { impl Panel for CommitDiffView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)); let label = SharedString::from(format!("{}/{}", self.repo_name, self.commit.id));
tab_title(avatar, label) tab_title(avatar, label)
+11 -5
View File
@@ -5,8 +5,9 @@ use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Textarea, TextareaState}; use gpui_component::input::{Textarea, TextareaState};
use gpui_component::tag::Tag; use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, Sizable, StyledExt, h_flex, v_flex};
use metadata::ProfileDirectory;
use nostr::prelude::{Event, EventId, PublicKey}; use nostr::prelude::{Event, EventId, PublicKey};
use signed_state::{ProfileStore, RepoStore}; use signed_state::RepoStore;
use signed_ui::Avatar; use signed_ui::Avatar;
use utils::relative_time; use utils::relative_time;
@@ -32,6 +33,7 @@ pub(crate) fn sidebar_section(
id: EventId, id: EventId,
roots: fn(&RepoStore) -> &[Event], roots: fn(&RepoStore) -> &[Event],
top_gap: bool, top_gap: bool,
directory: &Entity<ProfileDirectory>,
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
let store = store.read(cx); let store = store.read(cx);
@@ -39,7 +41,6 @@ pub(crate) fn sidebar_section(
// The caller bails out when the root is missing. // The caller bails out when the root is missing.
return div().into_any_element(); return div().into_any_element();
}; };
let profile_store = ProfileStore::global(cx);
// Participants, the root author plus everyone who commented. // Participants, the root author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![root.pubkey]; let mut participants: Vec<PublicKey> = vec![root.pubkey];
@@ -64,7 +65,7 @@ pub(crate) fn sidebar_section(
.gap_2() .gap_2()
.child(sidebar_title("Participants", cx)) .child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| { .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 name = profile.name();
let picture = profile.picture(); let picture = profile.picture();
@@ -109,7 +110,12 @@ pub(crate) fn sidebar_section(
.into_any_element() .into_any_element()
} }
pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &App) -> AnyElement { pub(crate) fn comments_section(
store: &Entity<RepoStore>,
root: EventId,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let store = store.read(cx); let store = store.read(cx);
let comments: Vec<&Event> = store.comments_of(&root).collect(); let comments: Vec<&Event> = store.comments_of(&root).collect();
let title = SharedString::from(format!("Discussions {}", comments.len())); let title = SharedString::from(format!("Discussions {}", comments.len()));
@@ -118,7 +124,7 @@ pub(crate) fn comments_section(store: &Entity<RepoStore>, root: EventId, cx: &Ap
.gap_4() .gap_4()
.child(div().text_xs().font_semibold().child(title)) .child(div().text_xs().font_semibold().child(title))
.children(comments.iter().map(|comment| { .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 author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
+32 -10
View File
@@ -5,16 +5,16 @@ use anyhow::Error;
use dock::{BasePanel, DockArea, Panel, PanelEvent}; use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ListAlignment, ListState, AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ListAlignment,
Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div, list, px, ListState, Pixels, Render, SharedString, Stateful, Subscription, Task, WeakEntity, Window, div,
list, px,
}; };
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex}; 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 nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
use signed_core::{InboxItem, InboxReadState, RepoAddr}; use signed_core::{InboxItem, InboxReadState, RepoAddr};
use signed_state::{ use signed_state::{Backend, BackendEvent, Inbox, RefreshGate, RefreshRequest, RepoListStore};
Backend, BackendEvent, Inbox, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
};
use signed_ui::{Avatar, CountBadge}; use signed_ui::{Avatar, CountBadge};
use utils::relative_time; use utils::relative_time;
@@ -54,6 +54,8 @@ pub struct InboxView {
refresh: RefreshGate, refresh: RefreshGate,
list: ListState, list: ListState,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Profiles for activity authors.
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -101,6 +103,7 @@ impl InboxView {
refresh: RefreshGate::default(), refresh: RefreshGate::default(),
list, list,
tasks: vec![], tasks: vec![],
directory: ProfileDirectory::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -272,6 +275,19 @@ impl InboxView {
let rows = self.flatten_rows(&sections); let rows = self.flatten_rows(&sections);
self.sections = Arc::new(sections); self.sections = Arc::new(sections);
self.rows = Arc::new(rows); self.rows = Arc::new(rows);
let mut keys: Vec<PublicKey> = 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<InboxSection> { fn group_sections(&self) -> Vec<InboxSection> {
@@ -397,8 +413,9 @@ impl InboxView {
let address = section.address.clone(); let address = section.address.clone();
let first = entry_ix == 0; let first = entry_ix == 0;
let last = entry_ix + 1 == section.entries.len(); 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| { .on_click(cx.listener(move |this, _ev, window, cx| {
this.open(root, kind, address.clone(), window, cx) this.open(root, kind, address.clone(), window, cx)
})) }))
@@ -461,6 +478,7 @@ fn thread(
item: &InboxItem, item: &InboxItem,
first: bool, first: bool,
last: bool, last: bool,
directory: &Entity<ProfileDirectory>,
cx: &App, cx: &App,
) -> Stateful<Div> { ) -> Stateful<Div> {
let title = SharedString::from(item.title()); let title = SharedString::from(item.title());
@@ -472,7 +490,7 @@ fn thread(
let mut timeline = v_flex().gap_2().w_full(); let mut timeline = v_flex().gap_2().w_full();
for event in item.timeline(MAX_SUB_ACTIVITIES) { 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() v_flex()
@@ -521,9 +539,13 @@ fn thread(
.child(timeline) .child(timeline)
} }
fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement { fn sub_activity(
let profile_store = ProfileStore::global(cx).read(cx); event: &Event,
let profile = profile_store.get(&event.pubkey); me: Option<PublicKey>,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let profile = directory.read(cx).profile(&event.pubkey, cx);
let name = if Some(event.pubkey) == me { let name = if Some(event.pubkey) == me {
SharedString::from("You") SharedString::from("You")
+47 -8
View File
@@ -7,9 +7,10 @@ use gpui::{
use gpui_component::input::TextareaState; use gpui_component::input::TextareaState;
use gpui_component::scroll::ScrollableElement; use gpui_component::scroll::ScrollableElement;
use gpui_component::{ActiveTheme, StyledExt, h_flex, v_flex}; 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_core::GitEvent;
use signed_state::{ProfileStore, RepoStore}; use signed_state::RepoStore;
use signed_ui::{Avatar, placeholder, status_badge}; use signed_ui::{Avatar, placeholder, status_badge};
use utils::relative_time; use utils::relative_time;
@@ -21,6 +22,8 @@ pub struct IssueDetailView {
store: Entity<RepoStore>, store: Entity<RepoStore>,
issue_id: EventId, issue_id: EventId,
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
/// Profiles for the author, commenters and the repository owner.
directory: Entity<ProfileDirectory>,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -34,16 +37,48 @@ impl IssueDetailView {
let comment_input = let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment...")); 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 { Self {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
store, store,
issue_id, issue_id,
comment_input, comment_input,
directory,
_subscription: subscription, _subscription: subscription,
} }
} }
/// Track the issue author, everyone who commented and the repository owner.
fn track_participants(&mut self, cx: &mut Context<Self>) {
let store = self.store.read(cx);
let mut keys: Vec<PublicKey> = 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 { impl BasePanel for IssueDetailView {
@@ -66,7 +101,7 @@ impl Panel for IssueDetailView {
}) })
.unwrap_or_else(|| SharedString::from("Issue")); .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) tab_title(avatar, short_id)
} }
@@ -94,8 +129,7 @@ impl Render for IssueDetailView {
}; };
let (title, author, picture, status, age, issue_id, content, pubkey) = { let (title, author, picture, status, age, issue_id, content, pubkey) = {
let profile_store = ProfileStore::global(cx); let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
let content = if issue.content.is_empty() { let content = if issue.content.is_empty() {
SharedString::from("No description provided.") SharedString::from("No description provided.")
} else { } else {
@@ -154,7 +188,11 @@ impl Render for IssueDetailView {
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
.child(Avatar::new(pubkey).name(author.clone()).picture(picture)) .child(
Avatar::new(pubkey)
.name(author.clone())
.picture(picture),
)
.child(author), .child(author),
) )
.child( .child(
@@ -170,7 +208,7 @@ impl Render for IssueDetailView {
) )
.child(div().text_sm().child(content)), .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( .child(comment_form(
&self.store, &self.store,
issue_id, issue_id,
@@ -186,6 +224,7 @@ impl Render for IssueDetailView {
issue_id, issue_id,
issue_roots, issue_roots,
false, false,
&self.directory,
cx, cx,
)) ))
.into_any_element() .into_any_element()
+23 -4
View File
@@ -15,9 +15,10 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, 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_core::{GitEvent, RepoStatus};
use signed_state::{ProfileStore, RepoStore}; use signed_state::RepoStore;
use signed_ui::{Avatar, SegmentButton, placeholder, status_badge}; use signed_ui::{Avatar, SegmentButton, placeholder, status_badge};
use utils::relative_time; use utils::relative_time;
@@ -59,6 +60,8 @@ pub struct IssuesView {
// e.g. switching between two empty filters. // e.g. switching between two empty filters.
synced_filter: IssueFilter, synced_filter: IssueFilter,
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// Profiles for issue authors and the repository owner.
directory: Entity<ProfileDirectory>,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -90,6 +93,7 @@ impl IssuesView {
counts: StatusCounts::default(), counts: StatusCounts::default(),
synced_filter: IssueFilter::Open, synced_filter: IssueFilter::Open,
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
directory: ProfileDirectory::new(cx),
_subscription: subscription, _subscription: subscription,
} }
} }
@@ -106,6 +110,21 @@ impl IssuesView {
) )
}; };
let store = self.store.read(cx);
let mut keys: Vec<PublicKey> = 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 filter_changed = self.synced_filter != filter;
let visible_issues_changed = self.visible_issues != visible_issues; let visible_issues_changed = self.visible_issues != visible_issues;
let counts_changed = self.counts != counts; let counts_changed = self.counts != counts;
@@ -148,7 +167,7 @@ impl IssuesView {
let issue = &self.store.read(cx).issues[issue_ix]; let issue = &self.store.read(cx).issues[issue_ix];
let title = issue.activity_subject(); let title = issue.activity_subject();
let id_hex = issue.id.to_hex(); 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 author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(issue.created_at); let age = relative_time(issue.created_at);
@@ -339,7 +358,7 @@ impl BasePanel for IssuesView {
impl Panel for IssuesView { impl Panel for IssuesView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)); let label = SharedString::from(format!("{}/issues", self.repo_name));
tab_title(avatar, label) tab_title(avatar, label)
+9 -4
View File
@@ -15,15 +15,16 @@ pub(crate) mod tree;
use std::fmt::Display; use std::fmt::Display;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{AnyElement, App, div}; use gpui::{AnyElement, App, Entity, div};
use gpui_component::{Sizable as _, h_flex}; use gpui_component::{Sizable as _, h_flex};
pub use inbox::InboxView; pub use inbox::InboxView;
use metadata::ProfileDirectory;
pub(crate) use placeholder_panel::PlaceholderPanel; pub(crate) use placeholder_panel::PlaceholderPanel;
pub use repo::RepoDetailView; pub use repo::RepoDetailView;
pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel}; pub(crate) use repo::{RepoItem, open_repo_item, open_repo_panel};
pub use repo_list::RepoListView; pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel; pub use sidebar::SidebarPanel;
use signed_state::{ProfileStore, RepoStore}; use signed_state::RepoStore;
use signed_ui::Avatar; use signed_ui::Avatar;
pub(crate) fn tab_title(avatar: AnyElement, label: impl IntoElement) -> impl IntoElement { 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() 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<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let seed = store let seed = store
.announcement .announcement
.as_ref() .as_ref()
@@ -55,7 +60,7 @@ pub(crate) fn repo_tab_avatar(store: &RepoStore, cx: &App) -> AnyElement {
let owner = store let owner = store
.announcement .announcement
.as_ref() .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 let (name, picture) = owner
.map(|profile| (Some(profile.name()), profile.picture())) .map(|profile| (Some(profile.name()), profile.picture()))
@@ -20,10 +20,11 @@ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex, ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list, 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_core::{GitEvent, PullRequest, RepoAddr};
use signed_git::{FileCommit, PatchParser}; 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 signed_ui::{Avatar, CountBadge, placeholder, status_badge};
use utils::{relative_time, relative_time_secs}; use utils::{relative_time, relative_time_secs};
@@ -77,6 +78,8 @@ pub struct PullRequestDetailView {
commit_item_sizes: Rc<Vec<Size<Pixels>>>, commit_item_sizes: Rc<Vec<Size<Pixels>>>,
commit_scroll_handle: VirtualListScrollHandle, commit_scroll_handle: VirtualListScrollHandle,
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>, tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
/// Profiles for the author, commenters and the repository owner.
directory: Entity<ProfileDirectory>,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -121,16 +124,37 @@ impl PullRequestDetailView {
commit_item_sizes: Rc::new(Vec::new()), commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(), commit_scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(), tasks: Vec::new(),
directory: ProfileDirectory::new(cx),
_subscription: subscription, _subscription: subscription,
} }
} }
/// Track the PR author, everyone who commented and the repository owner.
fn track_participants(&mut self, cx: &mut Context<Self>) {
let store = self.store.read(cx);
let mut keys: Vec<PublicKey> = 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. /// 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>) { fn sync(&mut self, cx: &mut Context<Self>) {
self.track_participants(cx);
let loaded = self.store.read(cx).loaded; let loaded = self.store.read(cx).loaded;
let binding = { let binding = {
@@ -416,7 +440,7 @@ impl PullRequestDetailView {
else { else {
return placeholder("Pull request not found", cx); 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.name(),
profile.picture(), profile.picture(),
@@ -469,7 +493,7 @@ impl PullRequestDetailView {
this.child(div().text_sm().child(self.description.clone())) 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( .child(comment_form(
&self.store, &self.store,
root_id, 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() .into_any_element()
} }
@@ -791,7 +822,7 @@ impl BasePanel for PullRequestDetailView {
impl Panel for PullRequestDetailView { impl Panel for PullRequestDetailView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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 hex = self.pr_id.to_hex();
let id = SharedString::from(&hex[..8]); let id = SharedString::from(&hex[..8]);
let label = if self.repo_name.is_empty() { let label = if self.repo_name.is_empty() {
@@ -13,9 +13,10 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, 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_core::{GitEvent, RepoStatus};
use signed_state::{ProfileStore, RepoStore}; use signed_state::RepoStore;
use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge}; use signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
use utils::relative_time; use utils::relative_time;
@@ -66,6 +67,8 @@ pub struct PullRequestsView {
// e.g. switching between two empty filters. // e.g. switching between two empty filters.
synced_filter: PullRequestFilter, synced_filter: PullRequestFilter,
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// Profiles for PR authors and the repository owner.
directory: Entity<ProfileDirectory>,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -97,11 +100,13 @@ impl PullRequestsView {
counts: StatusCounts::default(), counts: StatusCounts::default(),
synced_filter: PullRequestFilter::Open, synced_filter: PullRequestFilter::Open,
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
directory: ProfileDirectory::new(cx),
_subscription: subscription, _subscription: subscription,
} }
} }
fn rebuild(&mut self, cx: &mut Context<Self>) { fn rebuild(&mut self, cx: &mut Context<Self>) {
let store = self.store.read(cx);
let filter = self.filter; let filter = self.filter;
let (visible_prs, counts) = { let (visible_prs, counts) = {
@@ -117,6 +122,25 @@ impl PullRequestsView {
) )
}; };
let mut keys: Vec<PublicKey> = 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; let filter_changed = self.synced_filter != filter;
if !filter_changed && self.visible_prs == visible_prs && self.counts == counts { 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 age = relative_time(pr.created_at);
let status = self.store.read(cx).status_of(pr); 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 author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
@@ -339,7 +363,7 @@ impl BasePanel for PullRequestsView {
impl Panel for PullRequestsView { impl Panel for PullRequestsView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)); let label = SharedString::from(format!("{}/pulls", self.repo_name));
tab_title(avatar, label) tab_title(avatar, label)
@@ -20,6 +20,7 @@ use gpui_component::{
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex, ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list, v_virtual_list,
}; };
use metadata::ProfileDirectory;
use nostr::prelude::*; use nostr::prelude::*;
use signed_core::{Announcement, RepoAddr}; use signed_core::{Announcement, RepoAddr};
use signed_git::{GitCache, Repo}; use signed_git::{GitCache, Repo};
@@ -65,6 +66,8 @@ pub struct NewPullRequestView {
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>, tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
/// Profile for the repository owner in the tab title.
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -222,6 +225,7 @@ impl NewPullRequestView {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let directory = ProfileDirectory::new(cx);
let repo_name = store.read(cx).name(); let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new); let pane = cx.new(DiffPane::new);
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title")); 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| { cx.defer_in(window, |this, window, cx| {
let Some(addr) = this.store.read(cx).addr().cloned() else { let Some(addr) = this.store.read(cx).addr().cloned() else {
return; return;
@@ -312,6 +322,7 @@ impl NewPullRequestView {
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
tasks: Vec::new(), tasks: Vec::new(),
directory,
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -1313,7 +1324,7 @@ impl BasePanel for NewPullRequestView {
impl Panel for NewPullRequestView { impl Panel for NewPullRequestView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)); let label = SharedString::from(format!("{}/new-pull-request", self.repo_name));
tab_title(avatar, label) tab_title(avatar, label)
+21 -9
View File
@@ -1,14 +1,19 @@
use gpui::prelude::*; 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::clipboard::Clipboard;
use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex}; use gpui_component::{ActiveTheme, StyledExt, WindowExt, h_flex, v_flex};
use metadata::ProfileDirectory;
use nostr::prelude::PublicKey; use nostr::prelude::PublicKey;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::ProfileStore;
use signed_ui::Avatar; use signed_ui::Avatar;
use utils::middle_truncate; 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<ProfileDirectory>,
window: &mut Window,
cx: &mut App,
) {
window.open_dialog(cx, move |dialog, _window, cx| { window.open_dialog(cx, move |dialog, _window, cx| {
let announcement = announcement.clone(); let announcement = announcement.clone();
dialog dialog
@@ -17,11 +22,15 @@ pub(super) fn open_about_dialog(announcement: Announcement, window: &mut Window,
.keyboard(true) .keyboard(true)
.close_button(true) .close_button(true)
.title("About") .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<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let mut rows: Vec<AnyElement> = Vec::new(); let mut rows: Vec<AnyElement> = Vec::new();
rows.push(row( rows.push(row(
@@ -107,7 +116,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
if !announcement.maintainers.is_empty() { if !announcement.maintainers.is_empty() {
rows.push(row( rows.push(row(
"Maintainers", "Maintainers",
maintainers(&announcement.maintainers, cx), maintainers(&announcement.maintainers, directory, cx),
cx, cx,
)); ));
} }
@@ -160,13 +169,16 @@ fn copy_value(id: &'static str, value: String, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
fn maintainers(maintainers: &[PublicKey], cx: &App) -> AnyElement { fn maintainers(
let profile_store = ProfileStore::global(cx); maintainers: &[PublicKey],
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
v_flex() v_flex()
.gap_2() .gap_2()
.min_w_0() .min_w_0()
.children(maintainers.iter().map(|pubkey| { .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 name = profile.name();
let picture = profile.picture(); let picture = profile.picture();
+59 -15
View File
@@ -20,14 +20,14 @@ use gpui_component::spinner::Spinner;
use gpui_component::{ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, h_flex, v_flex,
}; };
use metadata::ProfileDirectory;
use nostr::nips::nip19::Nip19Coordinate; 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_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::{FileCommit, GitCache, RefSync, Repo}; use signed_git::{FileCommit, GitCache, RefSync, Repo};
use signed_state::{ use signed_state::{
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
Nip34Binding, Nip34Kind, ProfileStore, PushStepStatus, RepoListStore, RepoStore, Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore,
SyncStatusStore,
}; };
use signed_ui::{ use signed_ui::{
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger, Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
@@ -94,6 +94,8 @@ pub struct RepoDetailView {
refs: RefSwitcher, refs: RefSwitcher,
banners: Banners, banners: Banners,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Profiles rendered by the header, maintainers and tab title.
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -216,6 +218,7 @@ impl RepoDetailView {
tasks: Vec::new(), tasks: Vec::new(),
banners: Banners::default(), banners: Banners::default(),
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
directory: ProfileDirectory::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; };
@@ -251,6 +254,7 @@ impl RepoDetailView {
self._subscriptions self._subscriptions
.push(cx.observe_in(store, window, |this, store, window, cx| { .push(cx.observe_in(store, window, |this, store, window, cx| {
this.refresh_ready_statuses(cx); this.refresh_ready_statuses(cx);
this.track_profiles(cx);
if !this.repo_started && store.read(cx).announcement.is_some() { if !this.repo_started && store.read(cx).announcement.is_some() {
this.load_repo(window, cx); this.load_repo(window, cx);
} }
@@ -259,6 +263,35 @@ impl RepoDetailView {
self.refresh_ready_statuses(cx); self.refresh_ready_statuses(cx);
} }
/// Track the repository owner, maintainers and the local binding owner.
fn track_profiles(&mut self, cx: &mut Context<Self>) {
let store = self.store.read(cx);
let mut keys: Vec<PublicKey> = 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<Self>) { fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else { let Some(addr) = self.store.read(cx).addr().cloned() else {
return; return;
@@ -315,6 +348,7 @@ impl RepoDetailView {
pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub(super) fn load_repo(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true; self.loading = true;
self.error = None; self.error = None;
self.track_profiles(cx);
cx.notify(); cx.notify();
let (announcement, local_path) = { let (announcement, local_path) = {
@@ -878,9 +912,10 @@ impl RepoDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
let nip05 = ProfileStore::global(cx) let nip05 = self
.directory
.read(cx) .read(cx)
.get(&source.owner) .profile(&source.owner, cx)
.metadata() .metadata()
.nip05 .nip05
.clone() .clone()
@@ -917,7 +952,12 @@ impl RepoDetailView {
} }
RepoAction::About => { RepoAction::About => {
if let Some(announcement) = this.announcement(cx) { 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), RepoAction::Push => this.push_repository(window, cx),
@@ -1261,7 +1301,7 @@ impl RepoDetailView {
.as_ref() .as_ref()
.filter(|binding| binding.kind == Nip34Kind::Initialized) .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") None => Button::new("init")
.icon(CustomIconName::Init) .icon(CustomIconName::Init)
.label("Initialize on Nostr") .label("Initialize on Nostr")
@@ -1457,7 +1497,6 @@ impl RepoDetailView {
let Some(announcement) = self.announcement(cx) else { let Some(announcement) = self.announcement(cx) else {
return div().into_any_element(); return div().into_any_element();
}; };
let profile_store = ProfileStore::global(cx);
let mut seen = HashSet::new(); let mut seen = HashSet::new();
let rest: Vec<_> = announcement let rest: Vec<_> = announcement
@@ -1467,7 +1506,7 @@ impl RepoDetailView {
.filter(|key| key != &announcement.owner && seen.insert(*key)) .filter(|key| key != &announcement.owner && seen.insert(*key))
.collect(); .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_name = owner.name();
let owner_picture = owner.picture(); let owner_picture = owner.picture();
@@ -1481,9 +1520,11 @@ impl RepoDetailView {
.child( .child(
h_flex() h_flex()
.gap_1() .gap_1()
.child(Avatar::new(announcement.owner) .child(
Avatar::new(announcement.owner)
.name(owner_name.clone()) .name(owner_name.clone())
.picture(owner_picture)) .picture(owner_picture),
)
.child(div().text_xs().whitespace_nowrap().child(owner_name)), .child(div().text_xs().whitespace_nowrap().child(owner_name)),
) )
.when(!rest.is_empty(), |this| { .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. /// The owner and identifier a local repository is already bound to.
fn bound_repo_label(binding: &Nip34Binding, cx: &App) -> AnyElement { fn bound_repo_label(
let store = ProfileStore::global(cx); binding: &Nip34Binding,
let profile = binding.owner.map(|pk| store.read(cx).get(&pk)); directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx));
h_flex() h_flex()
.flex_shrink_0() .flex_shrink_0()
@@ -2216,7 +2260,7 @@ impl Panel for RepoDetailView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx); let store = self.store.read(cx);
let name = repo_display_name(store); 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) tab_title(avatar, name)
} }
+20 -5
View File
@@ -14,8 +14,10 @@ use gpui_component::{
ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list, v_virtual_list,
}; };
use metadata::ProfileDirectory;
use nostr::prelude::PublicKey;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::{ProfileStore, RepoListStore, Timestamp}; use signed_state::{RepoListStore, Timestamp};
use signed_ui::{Avatar, SegmentButton}; use signed_ui::{Avatar, SegmentButton};
use utils::relative_time; use utils::relative_time;
@@ -107,6 +109,8 @@ pub struct RepoListView {
/// Indices matching [`Self::filter`] into the store's `announcements`. /// Indices matching [`Self::filter`] into the store's `announcements`.
visible: Vec<usize>, visible: Vec<usize>,
search: Entity<InputState>, search: Entity<InputState>,
/// Profiles for the card owners.
directory: Entity<ProfileDirectory>,
_search_subscription: Subscription, _search_subscription: Subscription,
_subscription: Subscription, _subscription: Subscription,
} }
@@ -118,6 +122,7 @@ impl RepoListView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let store = RepoListStore::global(cx); let store = RepoListStore::global(cx);
let directory = ProfileDirectory::new(cx);
let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search..."));
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { 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. // Rebuild the rows when the store changes.
// Newly announced repositories appear without waiting for a click.
let subscription = cx.observe(&store, |this, _store, cx| { let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild_rows(cx); this.rebuild_rows(cx);
}); });
@@ -146,6 +150,7 @@ impl RepoListView {
repo_len: 0, repo_len: 0,
visible: Vec::new(), visible: Vec::new(),
search, search,
directory,
_search_subscription: search_subscription, _search_subscription: search_subscription,
_subscription: subscription, _subscription: subscription,
} }
@@ -158,6 +163,17 @@ impl RepoListView {
self.visible = filter.visible(store, &query); self.visible = filter.visible(store, &query);
let owners: Vec<PublicKey> = 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. // Each virtual list row holds `COLUMNS` repo cards.
let rows = self.visible.len().div_ceil(COLUMNS); let rows = self.visible.len().div_ceil(COLUMNS);
@@ -176,8 +192,7 @@ impl RepoListView {
last_activity: Option<Timestamp>, last_activity: Option<Timestamp>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> AnyElement { ) -> AnyElement {
let profile_store = ProfileStore::global(cx); let owner = self.directory.read(cx).profile(&announcement.owner, cx);
let owner = profile_store.read(cx).get(&announcement.owner);
let name = announcement let name = announcement
.name .name
+15 -5
View File
@@ -9,6 +9,7 @@ use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement; use gpui_component::scroll::ScrollableElement;
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use metadata::ProfileDirectory;
use signed_state::RepoStore; use signed_state::RepoStore;
use crate::views::{repo_tab_avatar, tab_title}; use crate::views::{repo_tab_avatar, tab_title};
@@ -24,6 +25,8 @@ pub struct SendPatchView {
submitting: bool, submitting: bool,
/// Error of the last submit attempt, it keeps the panel open. /// Error of the last submit attempt, it keeps the panel open.
error: Option<SharedString>, error: Option<SharedString>,
/// Profile for the repository owner in the tab title.
directory: Entity<ProfileDirectory>,
} }
impl SendPatchView { impl SendPatchView {
@@ -34,11 +37,17 @@ impl SendPatchView {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> Self { ) -> Self {
let repo_name = store.read(cx).name(); 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 subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description =
let description = cx cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change"));
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
let patch = cx.new(|cx| { 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") 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, patch,
submitting: false, submitting: false,
error: None, error: None,
directory,
} }
} }
@@ -208,7 +218,7 @@ impl BasePanel for SendPatchView {
impl Panel for SendPatchView { impl Panel for SendPatchView {
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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)); let label = SharedString::from(format!("{}/send-patch", self.repo_name));
tab_title(avatar, label) tab_title(avatar, label)
+34 -9
View File
@@ -20,11 +20,12 @@ use gpui_component::dialog::DialogButtonProps;
use gpui_component::input::InputState; use gpui_component::input::InputState;
use gpui_component::menu::{DropdownMenu, PopupMenuItem}; use gpui_component::menu::{DropdownMenu, PopupMenuItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, WindowExt, h_flex, v_flex}; 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_core::{Announcement, RepoAddr};
use signed_state::{ use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore, ResolvedLocalRepo, SyncStatusStore,
}; };
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers}; use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
@@ -69,6 +70,8 @@ pub struct SidebarPanel {
unpushed: HashMap<RepoAddr, usize>, unpushed: HashMap<RepoAddr, usize>,
/// Commits not on Nostr per announced repository. /// Commits not on Nostr per announced repository.
unsynced: HashMap<RepoAddr, usize>, unsynced: HashMap<RepoAddr, usize>,
/// Profiles rendered by the sidebar.
directory: Entity<ProfileDirectory>,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -135,6 +138,7 @@ impl SidebarPanel {
scanning: false, scanning: false,
unpushed: HashMap::new(), unpushed: HashMap::new(),
unsynced: HashMap::new(), unsynced: HashMap::new(),
directory: ProfileDirectory::new(cx),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
} }
@@ -166,6 +170,25 @@ impl SidebarPanel {
self.local_repos = Arc::new(local_repos); self.local_repos = Arc::new(local_repos);
self.scanning = scanning; self.scanning = scanning;
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
let owners: Vec<PublicKey> = 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 { if announcements_changed {
self.request_push_watches(cx); self.request_push_watches(cx);
self.unpushed.clear(); self.unpushed.clear();
@@ -568,7 +591,7 @@ impl SidebarPanel {
cx: &mut Context<Self>, cx: &mut Context<Self>,
) -> impl IntoElement { ) -> impl IntoElement {
let name = entry.name(); 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 id = format!("local-repo:{}", entry.path.display());
let entry = entry.clone(); 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. /// 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<ProfileDirectory>,
cx: &App,
) -> AnyElement {
// Matches the other sidebar rows, and the badge below sits at its corner. // Matches the other sidebar rows, and the badge below sits at its corner.
let avatar = Avatar::new(entry.path.to_string_lossy()).xsmall(); 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(); return avatar.into_any_element();
}; };
let store = ProfileStore::global(cx); let profile = directory.read(cx).profile(&owner, cx);
let profile = store.read(cx).get(&owner);
div() div()
.relative() .relative()
@@ -831,12 +857,11 @@ impl Focusable for SidebarPanel {
impl Render for SidebarPanel { impl Render for SidebarPanel {
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let profile_store = ProfileStore::global(cx);
let profile = backend let profile = backend
.read(cx) .read(cx)
.current_user() .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() { if profile.is_none() {
return self.render_sign_in(window, cx); return self.render_sign_in(window, cx);
+1
View File
@@ -28,6 +28,7 @@ icons = [
[dependencies] [dependencies]
assets = { path = "../crates/assets" } assets = { path = "../crates/assets" }
metadata = { path = "../crates/metadata" }
paths = { path = "../crates/paths" } paths = { path = "../crates/paths" }
settings = { path = "../crates/settings" } settings = { path = "../crates/settings" }
signed_state = { path = "../crates/signed_state" } signed_state = { path = "../crates/signed_state" }
+1
View File
@@ -82,6 +82,7 @@ fn main() {
settings.local_repos.scan_paths, settings.local_repos.scan_paths,
cx, cx,
); );
metadata::init(cx);
cx.set_app_identity("info.reya.signed", "Signed"); cx.set_app_identity("info.reya.signed", "Signed");