update issue panel

This commit is contained in:
2026-08-23 19:54:50 +07:00
parent 1956cb96bb
commit 6eccd65b93
13 changed files with 470 additions and 131 deletions
+26
View File
@@ -59,6 +59,32 @@ pub fn grasp_list(public_key: PublicKey) -> Filter {
.author(public_key)
}
/// NIP-22 comments (kind `1111`) referencing any of the given root events
/// (issues, patches, PRs).
///
/// Comments are not addressed to the repository — they carry no `a` tag with
/// the repo coordinate — so they must be fetched by their root reference
/// instead. NIP-22 defines the uppercase `E` tag as the root of the thread
/// (used by ngit) while some clients (including Signed itself) reference the
/// root with a lowercase `e` tag, so both are matched.
///
/// Returns two filters because `#E` and `#e` conditions would be ANDed if
/// combined into one.
pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
let roots: Vec<String> = roots.into_iter().map(|id| id.to_hex()).collect();
if roots.is_empty() {
return Vec::new();
}
vec![
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::UPPERCASE_E, roots.clone()),
Filter::new()
.kind(Kind::Comment)
.custom_tags(SingleLetterTag::LOWERCASE_E, roots),
]
}
/// All repositories announced by an author.
pub fn announcements_by(public_key: PublicKey) -> Filter {
Filter::new()
+25 -2
View File
@@ -30,9 +30,15 @@ impl RepoStatus {
}
}
/// Check whether a status event references the given root event via an `e` tag.
/// Check whether an event references the given root event via an `e` or `E`
/// tag. NIP-10 / NIP-34 use the lowercase `e` tag; NIP-22 comments (kind
/// `1111`) use the uppercase `E` tag for the root of the thread.
pub fn references_root(event: &Event, root: &EventId) -> bool {
event.tags.event_ids().any(|id| id == *root)
let root = root.to_hex();
event
.tags
.iter()
.any(|tag| matches!(tag.kind(), "e" | "E") && tag.content() == Some(root.as_str()))
}
/// Resolve the status of a root event per NIP-34:
@@ -96,6 +102,23 @@ mod tests {
));
}
#[test]
fn references_root_matches_uppercase_e_tag() {
let root = root_event_id();
let event = EventBuilder::new(Kind::Comment, "")
.tags([Tag::parse(["E", ROOT_ID_HEX]).expect("valid E tag")])
.finalize(&keys_from_hex(
"0000000000000000000000000000000000000000000000000000000000000001",
))
.expect("signed event");
assert!(references_root(&event, &root));
assert!(!references_root(
&event,
&EventId::from_hex(OTHER_ID_HEX).expect("valid id")
));
}
#[test]
fn references_root_false_without_e_tags() {
let event = EventBuilder::new(Kind::GitStatusOpen, "")
+68
View File
@@ -670,6 +670,30 @@ impl Backend {
}));
}
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
/// fetch its events from them: a one-shot auto-closing subscription for
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed.
///
/// Best-effort: failures are logged, not surfaced, because the bootstrap
/// relays already cover the repository. The relays stay in the pool, so
/// events the user publishes for this repository also reach them.
pub fn connect_repo_relays(
&mut self,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
cx: &mut Context<Self>,
) {
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}");
}
Ok(())
}));
}
/// Start a one-shot subscription targeted only at the bootstrap relays,
/// auto-closing after EOSE or a short timeout. Matching events are stored
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
@@ -832,6 +856,50 @@ impl Backend {
}
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
/// as a second pass, so events that race with the subscription or relays
/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support
/// just fail the sync step; the subscription already covered them.
async fn connect_repo_relays_only(
client: &Client,
relays: Vec<RelayUrl>,
filters: Vec<Filter>,
) -> Result<(), Error> {
if relays.is_empty() {
return Ok(());
}
for url in &relays {
client.add_relay(url).await?;
}
client.connect().await;
let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE)
.timeout(Some(Duration::from_secs(10)));
let target: HashMap<&str, Vec<Filter>> = relays
.iter()
.map(|url| (url.as_str(), filters.clone()))
.collect();
client.subscribe(target).close_on(opts).await?;
for filter in filters {
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
log::warn!("repo relay negentropy sync failed: {e}");
}
}
Ok(())
}
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
/// short timeout. Use for one-shot data fetches (repo events, profiles)
/// instead of persistent gossip-routed subscriptions.
+110 -11
View File
@@ -1,3 +1,4 @@
use std::collections::HashSet;
use std::time::Duration;
use anyhow::Error;
@@ -32,6 +33,13 @@ pub struct RepoStore {
statuses: Vec<Event>,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we
/// have already been asked to connect to and fetch from, to avoid
/// re-subscribing on every refresh.
repo_relays: HashSet<RelayUrl>,
/// Root events (issues, patches, PRs) for which a NIP-22 comment fetch
/// has already been requested, to avoid re-fetching on every refresh.
comment_roots: HashSet<EventId>,
refreshing: bool,
refresh_dirty: bool,
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
@@ -41,7 +49,7 @@ pub struct RepoStore {
}
impl RepoStore {
pub fn new(addr: RepoAddr, cx: &mut Context<Self>) -> Self {
pub fn new(addr: RepoAddr, announced_relays: Vec<RelayUrl>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
@@ -53,8 +61,12 @@ impl RepoStore {
let coordinate = update.coordinate.as_ref() == Some(&this.addr);
let author = update.author == this.addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// NIP-22 comments carry no `a` tag, so they can't be
// matched by coordinate; any comment may reference this
// repository's roots.
let comment = update.kind == Kind::Comment;
deletion || coordinate || (author && kind)
deletion || coordinate || (author && kind) || comment
}
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
@@ -82,6 +94,8 @@ impl RepoStore {
comments: Vec::new(),
statuses: Vec::new(),
last_error: None,
repo_relays: HashSet::new(),
comment_roots: HashSet::new(),
refreshing: false,
refresh_dirty: false,
debouncing: false,
@@ -90,6 +104,10 @@ impl RepoStore {
};
store.subscribe_remote(cx);
// The announcement we opened the repo from may already list its
// relays; connect to them right away instead of waiting for the
// bootstrap fetch to return the same event.
store.connect_announced_relays(&announced_relays, cx);
store.refresh(cx);
store
}
@@ -98,6 +116,42 @@ impl RepoStore {
&self.addr
}
/// Filters that make up a repository: announcement, state, activity and
/// deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![
filters::announcement(addr),
filters::state(addr),
filters::activity(addr),
];
// Deletion requests (NIP-09/62) must be known before any event of
// this repository can be shown.
filters.extend(filters::deletions_for_repo(addr));
filters
}
/// Fetch this repository's events from the relays announced in its
/// NIP-34 `relays` tag. Deduplicated: each relay is only contacted once
/// per store, so refreshes after the first are no-ops unless the
/// announcement lists new relays.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let new: Vec<RelayUrl> = relays
.iter()
.filter(|url| !self.repo_relays.contains(*url))
.cloned()
.collect();
if new.is_empty() {
return;
}
self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
});
}
/// Fetch this repository's events from the bootstrap relays (one-shot,
/// auto-closing subscription).
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
@@ -105,15 +159,7 @@ impl RepoStore {
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
let mut repo_filters = vec![
filters::announcement(&addr),
filters::state(&addr),
filters::activity(&addr),
];
// Deletion requests (NIP-09/62) must be known before any
// event of this repository can be shown.
repo_filters.extend(filters::deletions_for_repo(&addr));
backend.subscribe_bootstrap(repo_filters, cx);
backend.subscribe_bootstrap(Self::repo_filters(&addr), cx);
});
}
@@ -196,6 +242,24 @@ impl RepoStore {
}
}
// NIP-22 comments reference their root via an `E`/`e` tag rather
// than the repository's `a` tag, so query them by the root events
// of this repository.
let db = client.database();
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let roots = issues
.iter()
.chain(&patches)
.chain(&pull_requests)
.map(|e| e.id);
for filter in filters::comments_for(roots) {
for event in db.query(filter).await? {
if seen_comments.insert(event.id) {
comments.push(event);
}
}
}
sort_newest_first(&mut issues);
sort_newest_first(&mut patches);
sort_newest_first(&mut pull_requests);
@@ -230,6 +294,15 @@ impl RepoStore {
let again = this.update(cx, |this, cx| {
this.announcement = announcement;
// The announcement may list relays for this repository's
// activity; connect to any we haven't fetched from yet.
let relays = this
.announcement
.as_ref()
.map(|a| a.relays.clone())
.unwrap_or_default();
this.connect_announced_relays(&relays, cx);
if let Some((refs, head)) = state {
this.refs = refs;
this.head = head;
@@ -241,6 +314,32 @@ impl RepoStore {
this.comments = comments;
this.statuses = statuses;
// Comments are not addressed to the repository, so fetch
// them by the root events they reference, on the bootstrap
// relays and on the relays this repository announced.
let roots = this
.issues
.iter()
.chain(&this.patches)
.chain(&this.pull_requests)
.map(|e| e.id)
.collect::<HashSet<EventId>>();
let new_roots: Vec<EventId> = roots
.iter()
.filter(|id| !this.comment_roots.contains(id))
.copied()
.collect();
if !new_roots.is_empty() {
this.comment_roots.extend(new_roots.iter().copied());
let comment_filters = filters::comments_for(new_roots);
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(comment_filters.clone(), cx);
backend.connect_repo_relays(announced, comment_filters, cx);
});
}
cx.notify();
this.refreshing = false;
@@ -225,7 +225,7 @@ pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement {
pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => (
CustomIconName::GitIssueOpen,
CustomIconName::GitIssueDone,
"open",
"Issue is open",
cx.theme().primary,
@@ -1,14 +1,16 @@
use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
px,
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Window, div, px, relative,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::tag::Tag;
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
use nostr::prelude::{Event, EventId};
use nostr::prelude::{Event, EventId, PublicKey};
use signed_core::activity_subject;
use signed_state::{ProfileStore, RepoStore};
use utils::relative_time;
@@ -46,6 +48,88 @@ impl IssueDetailView {
}
}
fn render_sidebar(&self, cx: &mut Context<Self>) -> impl IntoElement {
let profile_store = ProfileStore::global(cx);
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
// `render` already bails out when the issue is missing.
return div().into_any_element();
};
// Participants: the issue author plus everyone who commented.
let mut participants: Vec<PublicKey> = vec![issue.pubkey];
participants.extend(store.comments_of(&issue.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
// Issue labels are NIP-34 `t` hashtag tags on the event.
let labels: Vec<String> = issue.tags.hashtags().map(|tag| tag.to_string()).collect();
v_flex()
.w(px(240.))
.h_full()
.flex_none()
.px_4()
.gap_4()
.border_l(px(1.))
.border_color(cx.theme().sidebar_border)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| {
let profile = profile_store.read(cx).get(pubkey);
let name = profile.name();
let picture = profile.picture();
h_flex()
.gap_2()
.items_center()
.child(
Avatar::new()
.name(name.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().text_sm().truncate().text_ellipsis().child(name))
.into_any_element()
})),
)
.child(
v_flex()
.gap_2()
.child(sidebar_title("Labels", cx))
.map(|this| {
if labels.is_empty() {
this.child(
div()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child("No labels"),
)
} else {
this.child(h_flex().gap_1().children({
let mut items = vec![];
for label in labels.iter() {
items.push(
Tag::secondary()
.outline()
.xsmall()
.child(SharedString::from(label)),
);
}
items
}))
}
}),
)
.into_any_element()
}
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).collect();
@@ -71,6 +155,7 @@ impl IssueDetailView {
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.xsmall(),
)
.child(author),
@@ -165,14 +250,16 @@ impl Focusable for IssueDetailView {
impl Render for IssueDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
// Extract everything owned first: the store borrow must end before
// the markdown state is (re)built below.
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
return placeholder("Issue not found", cx);
};
let (title, author, picture, status, age, issue_id, content) = {
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
return placeholder("Issue not found", cx);
};
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey);
(
activity_subject(issue),
profile.name(),
@@ -184,57 +271,83 @@ impl Render for IssueDetailView {
)
};
v_flex()
h_flex()
.id("issue-detail")
.size_full()
.overflow_y_scroll()
.gap_6()
.px_4()
.child(
h_flex()
.gap_2()
.items_center()
.child(status_badge(status, cx))
.child(div().font_semibold().child(title)),
)
.child(
v_flex()
.px_4()
.gap_8()
.pb_4()
.gap_6()
.size_full()
.min_w_0()
.overflow_y_scrollbar()
.child(
h_flex()
.min_h_16()
.gap_2()
.child(status_badge(status, cx))
.child(
div()
.flex_1()
.min_w_0()
.font_semibold()
.line_height(relative(1.2))
.child(title),
),
)
.child(
v_flex()
.gap_2()
.px_4()
.gap_8()
.child(
h_flex()
.gap_2()
.text_sm()
v_flex()
.gap_4()
.child(
h_flex()
.gap_1()
.gap_2()
.text_sm()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.small(),
h_flex()
.gap_1()
.child(
Avatar::new()
.when_some(picture, |this, url| {
this.src(url)
})
.name(author.clone())
.rounded(cx.theme().radius)
.small(),
)
.child(author),
)
.child(author),
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("opened")),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from("opened")),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
.child(div().text_sm().child(SharedString::from(&content))),
)
.child(div().text_sm().child(SharedString::from(&content))),
)
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
.child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)),
),
)
.child(self.render_sidebar(cx))
.into_any_element()
}
}
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(text.to_string())
.into_any_element()
}
@@ -177,6 +177,7 @@ impl IssuesView {
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().child(author)),
@@ -149,7 +149,10 @@ impl RepoDetailView {
// cache until the panel closes; free them then.
crate::image_cache::clear_on_release(&cx.entity(), window, cx);
let store = cx.new(|cx| RepoStore::new(initial.addr(), cx));
// The announcement we opened from already carries the repository's
// NIP-34 `relays` tag, so the store can connect to those relays
// immediately instead of waiting for the bootstrap fetch.
let store = cx.new(|cx| RepoStore::new(initial.addr(), initial.relays.clone(), cx));
let tree_state = cx.new(|cx| TreeState::new(cx));
// Empty until the clone completes; populated with the local refs.
@@ -1184,6 +1187,7 @@ impl RepoDetailView {
Avatar::new()
.name(owner_name.clone())
.when_some(owner_picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().text_xs().whitespace_nowrap().child(owner_name)),
@@ -1195,6 +1199,7 @@ impl RepoDetailView {
Avatar::new()
.name(profile.name())
.when_some(profile.picture(), |this, url| this.src(url))
.rounded(cx.theme().radius)
}),
))
})
@@ -555,6 +555,7 @@ impl PullRequestDetailView {
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(author),
@@ -684,6 +685,7 @@ impl PullRequestDetailView {
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.xsmall(),
)
.child(author),
@@ -180,6 +180,7 @@ impl PullRequestsView {
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(div().child(author)),
+1
View File
@@ -141,6 +141,7 @@ impl RepoListView {
Avatar::new()
.name(owner.name())
.when_some(owner.picture(), |this, url| this.src(url))
.rounded(cx.theme().radius)
.small(),
)
.child(
+2 -2
View File
@@ -126,8 +126,8 @@ impl SidebarPanel {
Avatar::new()
.name(name.clone())
.when_some(picture, |this, url| this.src(url))
.small()
.border_0(),
.rounded(cx.theme().radius)
.small(),
)
.child(div().text_xs().font_semibold().child(name)),
),