Compare commits
3
Commits
cd61a09320
...
05221b5af9
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
05221b5af9 | ||
|
|
89836a0394 | ||
|
|
60d8eef340 |
@@ -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 {}`.
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ 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 profile::{Profile, ProfileDirectory, ProfileEvent, 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;
|
||||||
|
|||||||
+230
-145
@@ -2,10 +2,9 @@ use std::cell::RefCell;
|
|||||||
use std::collections::{HashMap, HashSet};
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use anyhow::Error;
|
use flume::Sender;
|
||||||
use flume::{Receiver, Sender};
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
App, AppContext, Context, Entity, EventEmitter, Global, SharedString, Subscription, Task,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
@@ -15,7 +14,6 @@ use crate::bootstrap::sync_bootstrap_only;
|
|||||||
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
const REQUEST_CHUNK: usize = 100;
|
const REQUEST_CHUNK: usize = 100;
|
||||||
const WARM_LIMIT: usize = 500;
|
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
@@ -63,12 +61,39 @@ impl Profile {
|
|||||||
.filter(|p| !p.is_empty())
|
.filter(|p| !p.is_empty())
|
||||||
.map(|p| SharedString::from(p.clone()))
|
.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 {
|
pub struct ProfileStore {
|
||||||
profiles: HashMap<PublicKey, Profile>,
|
in_flight: RefCell<HashSet<PublicKey>>,
|
||||||
seen: RefCell<HashSet<PublicKey>>,
|
|
||||||
sender: Sender<PublicKey>,
|
sender: Sender<PublicKey>,
|
||||||
|
_task: Task<Result<(), anyhow::Error>>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,6 +101,8 @@ struct GlobalProfileStore(Entity<ProfileStore>);
|
|||||||
|
|
||||||
impl Global for GlobalProfileStore {}
|
impl Global for GlobalProfileStore {}
|
||||||
|
|
||||||
|
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||||
|
|
||||||
impl ProfileStore {
|
impl ProfileStore {
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalProfileStore>().0.clone()
|
cx.global::<GlobalProfileStore>().0.clone()
|
||||||
@@ -87,150 +114,17 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let entity = cx.entity().downgrade();
|
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
if let BackendEvent::ProfileUpdates(authors) = event {
|
||||||
this.apply_authors(authors.clone(), cx);
|
this.emit_updated(authors.clone(), cx);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task = 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();
|
let mut batch: HashSet<PublicKey> = HashSet::new();
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
@@ -274,14 +168,205 @@ impl ProfileStore {
|
|||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(chunk.iter().copied());
|
.authors(chunk.iter().copied());
|
||||||
|
|
||||||
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
sync_bootstrap_only(&client, filter, opts).await.ok();
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
/// Queue a batched relay sync for keys without one in flight.
|
||||||
return Ok(());
|
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.retain(|task| !task.is_ready());
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
let loaded = work.await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
let remaining = match loaded {
|
||||||
|
Ok(profiles) => {
|
||||||
|
let found: HashSet<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>(&self, then: impl FnOnce(&mut HashSet<PublicKey>) -> R) -> R {
|
||||||
|
let mut pending = self.pending.borrow_mut();
|
||||||
|
then(&mut pending)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ use gpui_component::{
|
|||||||
use signed_git::{
|
use signed_git::{
|
||||||
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo,
|
||||||
};
|
};
|
||||||
use signed_state::RepoStore;
|
use signed_state::{ProfileDirectory, RepoStore};
|
||||||
use signed_ui::{placeholder, tree_row};
|
use signed_ui::{placeholder, tree_row};
|
||||||
use utils::relative_time_secs;
|
use utils::relative_time_secs;
|
||||||
|
|
||||||
@@ -305,6 +305,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 +320,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 +348,7 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
|
directory,
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -475,7 +485,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)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ 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 nostr::prelude::{Event, EventId, PublicKey};
|
use nostr::prelude::{Event, EventId, PublicKey};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileDirectory, RepoStore};
|
||||||
use signed_ui::Avatar;
|
use signed_ui::Avatar;
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -32,6 +32,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 +40,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 +64,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 +109,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 +123,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);
|
||||||
|
|||||||
@@ -5,15 +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 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, ProfileStore, RefreshGate, RefreshRequest, RepoListStore,
|
Backend, BackendEvent, Inbox, ProfileDirectory, RefreshGate, RefreshRequest, RepoListStore,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, CountBadge};
|
use signed_ui::{Avatar, CountBadge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
@@ -54,6 +55,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 +104,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 +276,19 @@ impl InboxView {
|
|||||||
let rows = self.flatten_rows(§ions);
|
let rows = self.flatten_rows(§ions);
|
||||||
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 +414,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 +479,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 +491,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 +540,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")
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ 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 nostr::prelude::{EventId, PublicKey};
|
||||||
use signed_core::GitEvent;
|
use signed_core::GitEvent;
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileDirectory, 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 +21,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 +36,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 +100,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 +128,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 +187,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 +207,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 +223,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()
|
||||||
|
|||||||
@@ -15,9 +15,9 @@ 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 nostr::prelude::{EventId, PublicKey};
|
||||||
use signed_core::{GitEvent, RepoStatus};
|
use signed_core::{GitEvent, RepoStatus};
|
||||||
use signed_state::{ProfileStore, RepoStore};
|
use signed_state::{ProfileDirectory, 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 +59,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 +92,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 +109,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 +166,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 +357,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)
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ 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;
|
||||||
pub(crate) use placeholder_panel::PlaceholderPanel;
|
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(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::{ProfileDirectory, 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 +38,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 +59,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,10 @@ 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 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, ProfileDirectory, 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 +77,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 +123,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 +439,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 +492,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 +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()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -791,7 +821,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,9 @@ 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 nostr::prelude::{EventId, Kind, PublicKey};
|
||||||
use signed_core::{GitEvent, RepoStatus};
|
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 signed_ui::{Avatar, DropdownButton, SegmentButton, placeholder, status_badge};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -66,6 +66,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 +99,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 +121,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 +188,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 +362,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)
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use gpui_component::{
|
|||||||
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};
|
||||||
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 signed_ui::{CountBadge, placeholder, ref_selector_trigger};
|
||||||
use utils::middle_truncate;
|
use utils::middle_truncate;
|
||||||
|
|
||||||
@@ -65,6 +65,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 +224,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 +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| {
|
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 +321,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 +1323,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)
|
||||||
|
|||||||
@@ -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 nostr::prelude::PublicKey;
|
use nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::ProfileStore;
|
use signed_state::ProfileDirectory;
|
||||||
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();
|
||||||
|
|
||||||
|
|||||||
@@ -21,12 +21,12 @@ 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 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, ProfileDirectory, PushStepStatus, RepoListStore, RepoStore,
|
||||||
SyncStatusStore,
|
SyncStatusStore,
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,8 +14,9 @@ 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 nostr::prelude::PublicKey;
|
||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::{ProfileStore, RepoListStore, Timestamp};
|
use signed_state::{ProfileDirectory, RepoListStore, Timestamp};
|
||||||
use signed_ui::{Avatar, SegmentButton};
|
use signed_ui::{Avatar, SegmentButton};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
@@ -107,6 +108,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 +121,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 +130,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 +149,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 +162,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 +191,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
|
||||||
|
|||||||
@@ -9,7 +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 signed_state::RepoStore;
|
use signed_state::{ProfileDirectory, RepoStore};
|
||||||
|
|
||||||
use crate::views::{repo_tab_avatar, tab_title};
|
use crate::views::{repo_tab_avatar, tab_title};
|
||||||
|
|
||||||
@@ -24,6 +24,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 +36,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 +61,7 @@ impl SendPatchView {
|
|||||||
patch,
|
patch,
|
||||||
submitting: false,
|
submitting: false,
|
||||||
error: None,
|
error: None,
|
||||||
|
directory,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -208,7 +217,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)
|
||||||
|
|||||||
@@ -20,11 +20,11 @@ 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 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, Profile,
|
||||||
ProfileStore, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
|
ProfileDirectory, RepoListStore, ResolvedLocalRepo, SyncStatusStore,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||||
|
|
||||||
@@ -69,6 +69,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 +137,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 +169,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 +590,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 +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.
|
/// 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 +803,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 +856,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);
|
||||||
|
|||||||
@@ -0,0 +1,230 @@
|
|||||||
|
# Per-view profile directories
|
||||||
|
|
||||||
|
Status: proposal
|
||||||
|
Reference: coop `a365a8f` "bind user metadata to per-view directories"
|
||||||
|
|
||||||
|
## Background
|
||||||
|
|
||||||
|
`ProfileStore` (`crates/signed_state/src/profile.rs`) is a single global entity:
|
||||||
|
|
||||||
|
- Warm-loads the 500 most recent metadata events at startup (`WARM_LIMIT`).
|
||||||
|
- `get(pk)` returns a cached clone or a placeholder and queues a network fetch
|
||||||
|
(flume channel, 500 ms batching, chunks of 100, `sync_bootstrap_only`).
|
||||||
|
- Subscribes to `BackendEvent::ProfileUpdates` to re-query the local database.
|
||||||
|
- Every view reads through `ProfileStore::global(cx)`.
|
||||||
|
|
||||||
|
Problems:
|
||||||
|
|
||||||
|
- Invalidation is global: every `cx.notify()` on the store invalidates every
|
||||||
|
panel that read it, so any profile update repaints all profile-consuming
|
||||||
|
surfaces (including `panel.cached` ones), not just the panel whose data
|
||||||
|
changed.
|
||||||
|
- Startup work and memory scale with the local database, not with what is on
|
||||||
|
screen, and the global map never shrinks.
|
||||||
|
- Every screen shares one data set, so nothing is cancellable or reclaimable.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Profile data is owned per panel/screen. A directory dies with its panel,
|
||||||
|
cancelling outstanding work and dropping its cache.
|
||||||
|
- An update re-renders only the panel whose data changed: reads during render
|
||||||
|
are tracked by the window, and `panel.cached` repaints a panel as soon as
|
||||||
|
its directory notifies.
|
||||||
|
- Local database first: query the local cache, fetch from relays only for keys
|
||||||
|
the directory cannot resolve.
|
||||||
|
- One shared network pipeline (batching, dedupe) so N panels still produce one
|
||||||
|
relay sync per key.
|
||||||
|
|
||||||
|
Non-goals: changing `Profile` accessors, changing `Backend`, persistence.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Two pieces replace the fat global:
|
||||||
|
|
||||||
|
1. `ProfileStore` (global, thin): network fetch service + event broadcaster.
|
||||||
|
Holds no profiles and does no warm load.
|
||||||
|
- `fetch(keys)`: dedupe via an in-flight set, batch via the existing flume
|
||||||
|
channel + 500 ms timeout, chunks of 100 through `sync_bootstrap_only`.
|
||||||
|
- Emits `ProfileEvent::Updated(Vec<PublicKey>)` (a) after each synced batch
|
||||||
|
and (b) when `BackendEvent::ProfileUpdates` arrives from the pump.
|
||||||
|
2. `ProfileDirectory` (per panel/screen entity): the metadata one surface
|
||||||
|
renders.
|
||||||
|
- `track(keys, cx)`: queries the local database for missing keys, inserts
|
||||||
|
what it finds, queues the rest for a network fetch.
|
||||||
|
- `profile(key, cx)`: cached value or placeholder; a miss marks the key
|
||||||
|
pending and queues a fetch (same call shape as today's `get`).
|
||||||
|
- Subscribes to `ProfileEvent::Updated`, refreshes only keys it tracks or
|
||||||
|
has pending, notifies itself on change.
|
||||||
|
- Stores its own tasks and subscription; both drop with the panel.
|
||||||
|
|
||||||
|
```
|
||||||
|
panel view ──track(keys)──▶ ProfileDirectory ──DB query──▶ local database
|
||||||
|
▲ │ keys not found
|
||||||
|
│ cx.notify() ▼
|
||||||
|
└────refresh(authors)── ProfileEvent::Updated ──── ProfileStore (global)
|
||||||
|
▲ │ batch +
|
||||||
|
│ │ sync_bootstrap_only
|
||||||
|
BackendEvent::ProfileUpdates ▼
|
||||||
|
relay pump ─────────────▶ relays
|
||||||
|
```
|
||||||
|
|
||||||
|
Panels own `Entity<ProfileDirectory>` and read it directly in render; no
|
||||||
|
`cx.observe` is needed. gpui-pre records which entities a window reads while
|
||||||
|
drawing (`App::record_entities_accessed`) and, on `cx.notify`, invalidates
|
||||||
|
exactly the views that read them (`App::notify` → `Window::invalidate_view`).
|
||||||
|
This is the mechanism coop relies on — its `Directory` only calls
|
||||||
|
`cx.notify()` — and it composes with `panel.cached(...)`, which repaints a
|
||||||
|
panel when the panel view itself is invalidated. Scoping data per directory is
|
||||||
|
what scopes invalidation: notifying the old global store would invalidate
|
||||||
|
every panel that ever read it, while notifying one directory invalidates only
|
||||||
|
the panels reading it.
|
||||||
|
|
||||||
|
## API sketch
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// signed_state/src/profile.rs
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum ProfileEvent {
|
||||||
|
/// Metadata for these authors may have changed in the local database.
|
||||||
|
Updated(Vec<PublicKey>),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<ProfileEvent> for ProfileStore {}
|
||||||
|
|
||||||
|
/// Thin global: batches metadata requests from directories and syncs them
|
||||||
|
/// into the local database. Holds no profile data.
|
||||||
|
pub struct ProfileStore {
|
||||||
|
in_flight: RefCell<HashSet<PublicKey>>,
|
||||||
|
sender: Sender<PublicKey>,
|
||||||
|
_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileStore {
|
||||||
|
pub fn global(cx: &App) -> Entity<Self>;
|
||||||
|
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App);
|
||||||
|
/// Queue a batched metadata request; no-op for keys already in flight.
|
||||||
|
pub fn fetch(&self, keys: impl IntoIterator<Item = PublicKey>);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Metadata cache for one panel or screen.
|
||||||
|
pub struct ProfileDirectory {
|
||||||
|
profiles: HashMap<PublicKey, Profile>,
|
||||||
|
pending: RefCell<HashSet<PublicKey>>,
|
||||||
|
tasks: SmallVec<[Task<()>; 2]>,
|
||||||
|
_subscriptions: SmallVec<[Subscription; 1]>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ProfileDirectory {
|
||||||
|
/// Create a directory, following the profile store's events.
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self>;
|
||||||
|
/// Resolve keys from the local database, queue the rest for fetch.
|
||||||
|
pub fn track(&mut self, keys: impl IntoIterator<Item = PublicKey>, cx: &mut Context<Self>);
|
||||||
|
/// The profile for a key, or a placeholder that queues a fetch.
|
||||||
|
pub fn profile(&self, public_key: &PublicKey, cx: &App) -> Profile;
|
||||||
|
/// Re-query tracked/pending keys named by a `ProfileEvent`.
|
||||||
|
fn refresh(&mut self, authors: &[PublicKey], cx: &mut Context<Self>);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Panel wiring:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
pub struct IssuesView {
|
||||||
|
// ...
|
||||||
|
directory: Entity<ProfileDirectory>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// constructor — no observe wiring; render-time reads of the directory are
|
||||||
|
// tracked automatically and `cx.notify()` on it invalidates the panel.
|
||||||
|
let directory = ProfileDirectory::new(cx);
|
||||||
|
```
|
||||||
|
|
||||||
|
Behavior details carried over from coop's `Directory`:
|
||||||
|
|
||||||
|
- The `pending` set guards the window between `track`/`profile` and the
|
||||||
|
database task finishing, so events arriving mid-query are still accepted.
|
||||||
|
- Inserts never overwrite data that already arrived from an event
|
||||||
|
(insert-if-absent on bulk load).
|
||||||
|
- `refresh` removes a pending key only when the database produced a profile,
|
||||||
|
so keys that exist on no relay stay pending and never loop into refetches;
|
||||||
|
`in_flight` in the store is released on every `Updated` emission.
|
||||||
|
|
||||||
|
## Where directories live
|
||||||
|
|
||||||
|
| Surface | File | Keys tracked | When |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `SidebarPanel` | `views/sidebar/mod.rs` | current user, local repo owners | on `SignerChanged`, on local repos update |
|
||||||
|
| `RepoListView` | `views/repo_list.rs` | announcement owners | when cards rebuild |
|
||||||
|
| `RepoDetailView` | `views/repo/mod.rs`, `repo/about.rs` | owner, maintainers, binding owners | on announcement/sync; about dialog captures the handle |
|
||||||
|
| `IssuesView` | `views/issues/mod.rs` | issue authors, repo owner (tab title) | in `rebuild` |
|
||||||
|
| `IssueDetailView` | `views/issues/detail.rs` | author, discussion participants | on store events; passed to `discussion::` helpers |
|
||||||
|
| `PullRequestsView` | `views/pull_requests/mod.rs` | PR authors, repo owner | in `rebuild` |
|
||||||
|
| `PullRequestDetailView` | `views/pull_requests/detail.rs` | author, participants | on store events |
|
||||||
|
| `NewPullRequestView`, `SendPatchView`, `CommitDiffView` | respective files | repo owner (tab title only) | on construction |
|
||||||
|
| `InboxView` | `views/inbox.rs` | activity authors | in rows rebuild |
|
||||||
|
|
||||||
|
Helpers that stop reading the global and take `&Entity<ProfileDirectory>`
|
||||||
|
instead — every caller has `&mut self` or captures the handle:
|
||||||
|
|
||||||
|
- `repo_tab_avatar(store, cx)` in `views/mod.rs` (all `Panel::title` impls)
|
||||||
|
- `discussion::sidebar_section`, `discussion::comments_section`
|
||||||
|
- `about::maintainers` via `open_about_dialog` (dialog closure captures the
|
||||||
|
entity handle, same as coop's device-registry notification)
|
||||||
|
- `bound_repo_label` in `views/repo/mod.rs`
|
||||||
|
- `sub_activity` in `views/inbox.rs`, `local_avatar` in `views/sidebar/mod.rs`
|
||||||
|
|
||||||
|
## Migration steps
|
||||||
|
|
||||||
|
1. `signed_state`: introduce `ProfileEvent` + `EventEmitter`, extract the
|
||||||
|
"latest metadata per author" reduction from `apply_authors` into a pure
|
||||||
|
`fn latest_profiles(events: Vec<Event>) -> Vec<Profile>`, and strip
|
||||||
|
`ProfileStore` down to the fetch service (drop `profiles`, `seen`, `load`,
|
||||||
|
`apply_seen`, `apply_authors`, `get`, `WARM_LIMIT`). The batch loop emits
|
||||||
|
`ProfileEvent::Updated` instead of querying; the `BackendEvent::ProfileUpdates`
|
||||||
|
subscription re-emits and releases in-flight keys.
|
||||||
|
2. `signed_state`: add `ProfileDirectory` in the same `profile.rs` and export
|
||||||
|
it from `lib.rs`. `init` keeps creating the global store.
|
||||||
|
3. `workspace`: add a `directory` field to each surface in the table, replace
|
||||||
|
`ProfileStore::global(cx).read(cx).get(...)` with
|
||||||
|
`self.directory.read(cx).profile(...)`, thread the handle through the free
|
||||||
|
helpers, and call `track` from each surface's data-load/rebuild path.
|
||||||
|
4. Delete `ProfileStore::get` last; the compiler walks the remaining call
|
||||||
|
sites. The workspace crate will not compile between steps 1 and 3, so this
|
||||||
|
lands as one change.
|
||||||
|
|
||||||
|
## Edge cases
|
||||||
|
|
||||||
|
- **Relay event during a database query**: covered by the pending set.
|
||||||
|
- **Keys with no profile on relays**: stay pending/in-flight, no retry storm
|
||||||
|
(strictly no worse than today's permanent `seen` dedupe).
|
||||||
|
- **Panel closed**: directory drops → subscription, tasks, and cache drop; no
|
||||||
|
cross-panel notifies.
|
||||||
|
- **Same person on many panels**: each directory keeps its own `Profile`
|
||||||
|
clone. Metadata JSON is small and queries are local index lookups; if this
|
||||||
|
ever shows up, a directory can attach to `RepoStore` later.
|
||||||
|
- **Own profile edits**: publishing flows through `Backend` → pump →
|
||||||
|
`ProfileUpdates` → store event → directories refresh, so no extra channel is
|
||||||
|
needed.
|
||||||
|
- **Dock tab titles**: `Panel::title` receives `&mut self`, so panels pass
|
||||||
|
their own directory to `repo_tab_avatar`; no global needed for chrome.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
- Unit: `latest_profiles` keeps the newest event per author (pure, no GPUI).
|
||||||
|
- Unit: `ProfileDirectory` pending-set dedupe and insert-if-absent merge via
|
||||||
|
`TestAppContext` (the dock crate already uses GPUI test contexts), seeding
|
||||||
|
the directory through a `#[cfg(test)]` helper rather than the network.
|
||||||
|
- Manual: cold start with an empty database, open explore / repo / issues /
|
||||||
|
inbox, confirm placeholders resolve without interacting with each panel, and
|
||||||
|
that `panel.cached` tabs still repaint on profile arrival.
|
||||||
|
|
||||||
|
## Alternatives considered
|
||||||
|
|
||||||
|
- **Attach the directory to `RepoStore`** instead of panels: repo surfaces
|
||||||
|
sharing one store would share one directory, but `RepoStore` instances are
|
||||||
|
created per open and sidebar/inbox/explore still need their own; revisit
|
||||||
|
only if duplicated queries ever matter.
|
||||||
|
- **Keep the global store as-is**: read tracking already propagates updates,
|
||||||
|
but the warm load, unbounded cache, and all-panels invalidation remain; does
|
||||||
|
not meet the goals.
|
||||||
Reference in New Issue
Block a user