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
@@ -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)),
),