add issues view with new issue dialog
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-12 04:56:18 +07:00
parent 44b530f11d
commit 78b7cac4c8
12 changed files with 1051 additions and 15 deletions
+32
View File
@@ -0,0 +1,32 @@
use assets::CustomIconName;
use gpui_native_kit::badge::{Badge, BadgeVariant};
use gpui_native_kit::icons::Icon;
use gpui_native_kit::tooltip::Tooltip;
use signed_core::RepoStatus;
/// The status pill shown on issue and pull-request rows.
pub fn status_badge(status: RepoStatus) -> Badge {
let (label, variant, icon) = match status {
RepoStatus::Open => ("Open", BadgeVariant::Neutral, CustomIconName::GitIssueOpen),
RepoStatus::Closed => (
"Closed",
BadgeVariant::Danger,
CustomIconName::GitIssueClosed,
),
RepoStatus::Draft => (
"Draft",
BadgeVariant::Warning,
CustomIconName::GitIssueOngoing,
),
RepoStatus::Applied => (
"Applied",
BadgeVariant::Success,
CustomIconName::GitIssueDone,
),
};
Badge::new(format!("status-{label}"), label)
.variant(variant)
.icon(Icon::named(icon))
.tooltip(Tooltip::text(label))
}
+4 -5
View File
@@ -243,8 +243,7 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
cx.spawn(async move |cx| { cx.spawn(async move |cx| {
if let Ok(Ok(Some(mut paths))) = prompt.await if let Ok(Ok(Some(mut paths))) = prompt.await
&& let Some(path) = paths.pop() && let Some(path) = paths.pop()
{ && let Err(error) = cx.update_window(handle, |_, window, cx| {
if let Err(error) = cx.update_window(handle, |_, window, cx| {
let text = path.to_string_lossy().to_string(); let text = path.to_string_lossy().to_string();
store.update(cx, |store, cx| { store.update(cx, |store, cx| {
store.edit( store.edit(
@@ -255,9 +254,9 @@ fn choose_folder(folder_input: &Entity<InputState>, window: &mut Window, cx: &mu
); );
}); });
folder_input.update(cx, |input, cx| input.set_value(text, window, cx)); folder_input.update(cx, |input, cx| input.set_value(text, window, cx));
}) { })
log::warn!("failed to apply selected folder: {error}"); {
} log::warn!("failed to apply selected folder: {error}");
} }
}) })
.detach(); .detach();
+1
View File
@@ -4,6 +4,7 @@ pub mod exit;
pub mod grasp_servers; pub mod grasp_servers;
pub mod import; pub mod import;
pub mod init_repo; pub mod init_repo;
pub mod new_issue;
pub mod onboarding; pub mod onboarding;
pub mod passphrase; pub mod passphrase;
pub mod progress; pub mod progress;
@@ -0,0 +1,98 @@
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
Styled as _, Window, div,
};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::v_flex;
use gpui_native_kit::dialog::Dialog;
use gpui_native_kit::field::Field;
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::theme::ActiveTheme as _;
use signed_state::RepoStore;
use super::{close_global, open_global};
pub struct NewIssueDialog {
store: Entity<RepoStore>,
subject: Entity<InputState>,
content: Entity<InputState>,
}
impl NewIssueDialog {
pub fn new(store: Entity<RepoStore>, cx: &mut App) -> Entity<Self> {
let subject = InputState::new(cx);
let content = InputState::new(cx);
content.update(cx, |state, cx| state.set_multiline(true, cx));
cx.new(|_| Self {
store,
subject,
content,
})
}
fn submit(&mut self, cx: &mut Context<Self>) {
let subject = self.subject.read(cx).value().to_string();
let content = self.content.read(cx).value().to_string();
let subject = (!subject.trim().is_empty()).then_some(subject);
self.store.update(cx, |store, cx| {
store.open_issue(subject, content, cx);
});
close_global(cx);
}
}
impl Render for NewIssueDialog {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let subject = self.subject.clone();
let content = self.content.clone();
let this = cx.entity().downgrade();
Dialog::new("new-issue-dialog")
.open(true)
.min_width(gpui::px(520.))
.on_change(|open, _, cx| {
if !open {
close_global(cx);
}
})
.title("New issue")
.content(move |_, cx| {
v_flex()
.gap_3()
.child(
div()
.text_sm()
.text_color(cx.theme().colors.muted_foreground)
.child("Report a bug, ask a question, or propose a change."),
)
.child(
Field::new("issue-title", "Title")
.child(Input::new("issue-title-input", subject.clone())),
)
.child(
Field::new("issue-content", "Content")
.child(Input::new("issue-content-input", content.clone()).rows(6)),
)
.into_any_element()
})
.actions(move |_, _| {
let this = this.clone();
vec![
Button::new("create-issue")
.label("Create issue")
.on_click(move |_, _, cx| {
let _ = this.update(cx, |this, cx| this.submit(cx));
})
.into_any_element() as AnyElement,
]
})
}
}
/// Opens the new-issue form for `store`.
pub fn open(store: Entity<RepoStore>, cx: &mut App) {
open_global(NewIssueDialog::new(store, cx), cx);
}
+1
View File
@@ -1,4 +1,5 @@
mod app; mod app;
pub mod components;
mod dialogs; mod dialogs;
mod panels; mod panels;
mod views; mod views;
+225
View File
@@ -0,0 +1,225 @@
use assets::CustomIconName;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::badge::{Badge, BadgeVariant};
use gpui_native_kit::button::Button;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::icons::Icon;
use gpui_native_kit::input::{Input, InputState};
use gpui_native_kit::theme::ActiveTheme as _;
use gpui_native_kit::tooltip::Tooltip;
use metadata::ProfileDirectory;
use nostr::prelude::{Event, EventId, PublicKey};
use signed_state::RepoStore;
use utils::relative_time;
pub fn issue_roots(store: &RepoStore) -> &[Event] {
&store.issues
}
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().colors.muted_foreground)
.child(text.to_string())
.into_any_element()
}
pub fn sidebar_section(
store: &Entity<RepoStore>,
id: EventId,
roots: fn(&RepoStore) -> &[Event],
top_gap: bool,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let store = store.read(cx);
let Some(root) = roots(store).iter().find(|event| event.id == id) else {
return div().into_any_element();
};
let mut participants: Vec<PublicKey> = vec![root.pubkey];
participants.extend(store.comments_of(&root.id).map(|comment| comment.pubkey));
participants.sort_by_key(PublicKey::to_hex);
participants.dedup();
let labels: Vec<String> = root.tags.hashtags().map(|tag| tag.to_string()).collect();
v_flex()
.w(px(240.))
.h_full()
.flex_none()
.px_4()
.gap_4()
.border_l_1()
.border_color(cx.theme().colors.border)
.child(
v_flex()
.when(top_gap, |this| this.mt_4())
.gap_2()
.child(sidebar_title("Participants", cx))
.children(participants.iter().map(|pubkey| {
let profile = directory.read(cx).profile(pubkey, cx);
let name = profile.name();
h_flex()
.gap_1()
.child(
Avatar::new(*pubkey)
.name(name.clone())
.picture(profile.picture())
.small(),
)
.child(div().text_sm().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().colors.muted_foreground)
.child("None yet."),
)
} else {
this.child(v_flex().gap_1().children(labels.iter().map(|label| {
Badge::new(SharedString::from(format!("label:{label}")), label.clone())
.variant(BadgeVariant::Outline)
})))
}
}),
)
.into_any_element()
}
pub fn comments_section(
store: &Entity<RepoStore>,
root: EventId,
directory: &Entity<ProfileDirectory>,
cx: &App,
) -> AnyElement {
let store = store.read(cx);
let comments: Vec<&Event> = store.comments_of(&root).collect();
let title = SharedString::from(format!("Discussions {}", comments.len()));
v_flex()
.gap_4()
.child(
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(title),
)
.children(comments.iter().map(|comment| {
let profile = directory.read(cx).profile(&comment.pubkey, cx);
let author = profile.name();
let age = relative_time(comment.created_at);
let content = SharedString::from(comment.content.as_str());
v_flex()
.gap_1()
.p_3()
.border_1()
.border_color(cx.theme().colors.border)
.rounded(cx.theme().radius.control)
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new(comment.pubkey)
.name(author.clone())
.picture(profile.picture())
.small(),
)
.child(author),
)
.child(
div()
.text_color(cx.theme().colors.muted_foreground)
.child("commented"),
)
.child(
div()
.text_color(cx.theme().colors.muted_foreground)
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(content))
}))
.into_any_element()
}
/// `roots` selects the root's list within the store, issues or pull requests.
pub fn comment_form(
store: &Entity<RepoStore>,
root: EventId,
roots: fn(&RepoStore) -> &[Event],
comment_input: &Entity<InputState>,
button_id: &'static str,
cx: &App,
) -> AnyElement {
let comment_input = comment_input.clone();
let store = store.clone();
v_flex()
.gap_2()
.child(
Input::new("comment-input", comment_input.clone())
.rows(4)
.placeholder("Leave a comment..."),
)
.child(
h_flex()
.justify_between()
.child(
h_flex()
.gap_1()
.text_xs()
.text_color(cx.theme().colors.muted_foreground)
.child(
Icon::named(CustomIconName::Markdown)
.size(px(12.))
.color(cx.theme().colors.muted_foreground),
)
.child("Markdown is supported"),
)
.child(
Button::new(button_id)
.label("Comment")
.tooltip(Tooltip::text("Post comment"))
.on_click(move |_, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = roots(store.read(cx))
.iter()
.find(|event| event.id == root)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
@@ -0,0 +1,232 @@
use gpui::{
AnyElement, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, Render,
ScrollHandle, SharedString, Styled as _, Subscription, Window, div,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::empty_state::EmptyState;
use gpui_native_kit::input::InputState;
use gpui_native_kit::layout::dock::Panel;
use gpui_native_kit::scroll_area::ScrollArea;
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::{EventId, PublicKey};
use signed_core::GitEvent;
use signed_state::RepoStore;
use utils::relative_time;
use crate::components::status_badge;
use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section};
pub struct IssueDetailView {
store: Entity<RepoStore>,
issue_id: EventId,
comment_input: Entity<InputState>,
/// Profiles for the author, commenters and the repository owner.
directory: Entity<ProfileDirectory>,
scroll_handle: ScrollHandle,
_subscription: Subscription,
}
impl IssueDetailView {
pub fn new(store: Entity<RepoStore>, issue_id: EventId, cx: &mut Context<Self>) -> Self {
let comment_input = InputState::new(cx);
comment_input.update(cx, |state, cx| state.set_multiline(true, cx));
let subscription = cx.observe(&store, |this, _store, cx| {
this.track_participants(cx);
cx.notify();
});
let directory = ProfileDirectory::new(cx);
let mut view = Self {
store,
issue_id,
comment_input,
directory,
scroll_handle: ScrollHandle::new(),
_subscription: subscription,
};
view.track_participants(cx);
view
}
/// 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 Panel for IssueDetailView {
fn title(&self, cx: &gpui::App) -> Option<SharedString> {
let short_id = self
.store
.read(cx)
.issues
.iter()
.find(|issue| issue.id == self.issue_id)
.map(|issue| {
let hex = issue.id.to_hex();
SharedString::from(&hex[..8])
})
.unwrap_or_else(|| SharedString::from("Issue"));
Some(short_id)
}
fn lead(&self, cx: &gpui::App) -> Option<AnyElement> {
Some(crate::views::repo::repo_tab_avatar(
self.store.read(cx),
&self.directory,
cx,
))
}
}
impl Render for IssueDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let store = self.store.read(cx);
let Some(issue) = store.issues.iter().find(|issue| issue.id == self.issue_id) else {
let message = if store.loaded {
"Issue not found"
} else {
"Loading issue..."
};
return v_flex()
.size_full()
.items_center()
.justify_center()
.child(EmptyState::new(message))
.into_any_element();
};
let (title, author, status, age, issue_id, content, pubkey) = {
let profile = self.directory.read(cx).profile(&issue.pubkey, cx);
let content = if issue.content.is_empty() {
SharedString::from("No description provided.")
} else {
SharedString::from(&issue.content)
};
(
issue.activity_subject(),
profile.name(),
store.status_of(issue),
relative_time(issue.created_at),
issue.id,
content,
issue.pubkey,
)
};
let picture = self.directory.read(cx).profile(&pubkey, cx).picture();
h_flex()
.id("issue-detail")
.size_full()
.child(
ScrollArea::new("issue-detail-scroll")
.handle(self.scroll_handle.clone())
.flex_1()
.min_w_0()
.child(
v_flex()
.p_4()
.gap_6()
.child(
h_flex().gap_2().child(status_badge(status)).child(
div()
.flex_1()
.min_w_0()
.font_weight(gpui::FontWeight::SEMIBOLD)
.child(title),
),
)
.child(
v_flex()
.gap_8()
.child(
v_flex()
.gap_4()
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new(pubkey)
.name(author.clone())
.picture(picture)
.small(),
)
.child(author),
)
.child(
div()
.text_color(
cx.theme().colors.muted_foreground,
)
.child("opened"),
)
.child(
div()
.text_color(
cx.theme().colors.muted_foreground,
)
.child(SharedString::from(age)),
),
)
.child(div().text_sm().child(content)),
)
.child(comments_section(
&self.store,
issue_id,
&self.directory,
cx,
))
.child(comment_form(
&self.store,
issue_id,
issue_roots,
&self.comment_input,
"comment",
cx,
)),
),
),
)
.child(sidebar_section(
&self.store,
issue_id,
issue_roots,
true,
&self.directory,
cx,
))
.into_any_element()
}
}
+342
View File
@@ -0,0 +1,342 @@
use assets::CustomIconName;
use gpui::prelude::FluentBuilder as _;
use gpui::{
AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement,
ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _,
Subscription, UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::button::Button;
use gpui_native_kit::common::{Sizable as _, h_flex, v_flex};
use gpui_native_kit::empty_state::EmptyState;
use gpui_native_kit::icons::Icon;
use gpui_native_kit::layout::dock::{DockState, Panel};
use gpui_native_kit::scrollbar::Scrollbar;
use gpui_native_kit::segmented::{Segmented, SegmentedOption};
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::ProfileDirectory;
use nostr::prelude::PublicKey;
use signed_core::{GitEvent, RepoStatus};
use signed_state::RepoStore;
use utils::relative_time;
use self::detail::IssueDetailView;
use super::status_list::{StatusCounts, filter_by_status};
use crate::components::status_badge;
use crate::dialogs::new_issue;
use crate::panels;
pub mod detail;
const ISSUE_ROW_HEIGHT: f32 = 73.;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum IssueFilter {
All,
Open,
Closed,
}
impl IssueFilter {
fn matches(self, status: RepoStatus) -> bool {
match self {
Self::All => true,
Self::Open => status == RepoStatus::Open,
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
}
}
}
pub struct IssuesView {
dock: WeakEntity<DockState>,
store: Entity<RepoStore>,
repo_name: SharedString,
filter: IssueFilter,
visible_issues: Vec<usize>,
counts: StatusCounts,
// A filter change notifies even when the visible rows are unchanged.
synced_filter: IssueFilter,
scroll_handle: UniformListScrollHandle,
/// Profiles for issue authors and the repository owner.
directory: Entity<ProfileDirectory>,
_subscription: Subscription,
}
impl IssuesView {
pub fn new(
dock: WeakEntity<DockState>,
store: Entity<RepoStore>,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild(cx);
});
let mut view = Self {
dock,
store,
repo_name,
filter: IssueFilter::Open,
visible_issues: Vec::new(),
counts: StatusCounts::default(),
synced_filter: IssueFilter::Open,
scroll_handle: UniformListScrollHandle::new(),
directory: ProfileDirectory::new(cx),
_subscription: subscription,
};
view.rebuild(cx);
view
}
fn rebuild(&mut self, cx: &mut Context<Self>) {
let filter = self.filter;
let (visible_issues, counts) = {
let store = self.store.read(cx);
filter_by_status(
&store.issues,
|issue| store.status_of(issue),
|status| filter.matches(status),
)
};
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 visible_issues_changed = self.visible_issues != visible_issues;
let counts_changed = self.counts != counts;
if !filter_changed && !visible_issues_changed && !counts_changed {
return;
}
self.synced_filter = filter;
self.visible_issues = visible_issues;
self.counts = counts;
cx.notify();
}
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let counts = self.counts;
let muted = cx.theme().colors.muted_foreground;
let weak = cx.entity().downgrade();
h_flex()
.px_4()
.w_full()
.gap_3()
.border_b_1()
.border_color(cx.theme().colors.border)
.bg(cx.theme().colors.muted)
.child(
Segmented::new("issue-filter", self.filter)
.options(vec![
SegmentedOption::new(IssueFilter::All, format!("All ({})", counts.total))
.icon(Icon::named(CustomIconName::GitIssueDone).color(muted)),
SegmentedOption::new(IssueFilter::Open, format!("Open ({})", counts.open))
.icon(Icon::named(CustomIconName::GitIssueOpen).color(muted)),
SegmentedOption::new(
IssueFilter::Closed,
format!("Closed ({})", counts.closed),
)
.icon(Icon::named(CustomIconName::GitIssueClosed).color(muted)),
])
.on_change(move |filter, _, cx| {
if let Err(error) = weak.update(cx, |this, cx| {
this.filter = filter;
this.rebuild(cx);
}) {
log::warn!("failed to switch issue filter: {error}");
}
}),
)
.child(div().flex_1())
.child(
Button::new("new-issue")
.label("New issue")
.icon(Icon::named(CustomIconName::CirclePlus))
.on_click(cx.listener(|this, _, _, cx| {
new_issue::open(this.store.clone(), cx);
})),
)
.into_any_element()
}
}
impl Panel for IssuesView {
fn title(&self, _cx: &App) -> Option<SharedString> {
Some(SharedString::from(format!("{}/issues", self.repo_name)))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
Some(super::repo::repo_tab_avatar(
self.store.read(cx),
&self.directory,
cx,
))
}
}
impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter;
let count = self.visible_issues.len();
let visible_issues = self.visible_issues.clone();
let directory = self.directory.clone();
let store = self.store.clone();
let dock = self.dock.clone();
v_flex()
.size_full()
.child(self.render_header(cx))
.child(
v_flex()
.relative()
.flex_1()
.min_h_0()
.w_full()
.when(count > 0, |this| {
this.child(
uniform_list("issues", count, move |range, _, cx| {
range
.map(|ix| {
let issue_ix = visible_issues[ix];
let issue = &store.read(cx).issues[issue_ix];
let title = issue.activity_subject();
let id_hex = issue.id.to_hex();
let profile = directory.read(cx).profile(&issue.pubkey, cx);
let author = profile.name();
let age = relative_time(issue.created_at);
let status = store.read(cx).status_of(issue);
let issue_id = issue.id;
let dock = dock.clone();
let store = store.clone();
h_flex()
.id(ix)
.w_full()
.h(px(ISSUE_ROW_HEIGHT))
.gap_4()
.px_4()
.border_b_1()
.border_color(cx.theme().colors.border)
.hover(|this| this.bg(cx.theme().colors.muted_hover))
.on_click(move |_, window, cx| {
let id =
SharedString::from(format!("issue:{issue_id}"));
let store = store.clone();
panels::open(
&dock,
id,
move |_, cx| {
cx.new(|cx| {
IssueDetailView::new(
store.clone(),
issue_id,
cx,
)
})
},
window,
cx,
);
})
.child(status_badge(status))
.child(
v_flex()
.flex_1()
.min_w_0()
.gap_1()
.child(
div()
.text_sm()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.child(
h_flex()
.gap_2()
.text_xs()
.child(
h_flex()
.gap_1()
.child(
Avatar::new(issue.pubkey)
.name(author.clone())
.picture(
profile.picture(),
)
.small(),
)
.child(div().child(author)),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(
cx.theme()
.colors
.muted_foreground,
)
.child(SharedString::from(
&id_hex[..8],
)),
)
.child(SharedString::from(age)),
),
)
.into_any_element()
})
.collect()
})
.track_scroll(&self.scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(
"issues-scroll",
self.scroll_handle.clone(),
)),
)
})
.when(count == 0, |this| {
let message = match filter {
IssueFilter::All => "No issues",
IssueFilter::Open => "No open issues",
IssueFilter::Closed => "No closed issues",
};
this.child(
v_flex()
.size_full()
.items_center()
.justify_center()
.child(EmptyState::new(message)),
)
}),
)
.into_any_element()
}
}
+3
View File
@@ -1,5 +1,8 @@
pub mod discussion;
pub mod explore; pub mod explore;
pub mod issues;
pub mod placeholder; pub mod placeholder;
pub mod repo; pub mod repo;
pub mod sidebar; pub mod sidebar;
pub mod status_list;
pub mod tree; pub mod tree;
+58
View File
@@ -44,6 +44,7 @@ pub mod refs;
use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths};
use crate::dialogs; use crate::dialogs;
use crate::views::issues::IssuesView;
use banners::Banners; use banners::Banners;
use files::RepoFilesView; use files::RepoFilesView;
use history::RepoHistoryView; use history::RepoHistoryView;
@@ -83,6 +84,7 @@ pub struct RepoDetailView {
share_open: bool, share_open: bool,
repo_menu_open: bool, repo_menu_open: bool,
clone_open: bool, clone_open: bool,
issues_menu_open: bool,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -177,6 +179,7 @@ impl RepoDetailView {
share_open: false, share_open: false,
repo_menu_open: false, repo_menu_open: false,
clone_open: false, clone_open: false,
issues_menu_open: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; };
@@ -833,6 +836,24 @@ impl RepoDetailView {
dialogs::init_repo::open(local_path, cx.entity().downgrade(), window, cx); dialogs::init_repo::open(local_path, cx.entity().downgrade(), window, cx);
} }
fn open_issues(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else {
return;
};
let id = SharedString::from(format!("issues:{addr}"));
let dock = self.dock.clone();
let dock_for_build = dock.clone();
let store = self.store.clone();
crate::panels::open(
&dock,
id,
move |_, cx| cx.new(|cx| IssuesView::new(dock_for_build.clone(), store.clone(), cx)),
window,
cx,
);
}
fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.store.read(cx).addr().is_none() { if self.store.read(cx).addr().is_none() {
return self.render_local_header(cx); return self.render_local_header(cx);
@@ -931,6 +952,43 @@ impl RepoDetailView {
.flex_none() .flex_none()
.gap_2() .gap_2()
.justify_end() .justify_end()
.child({
let weak_issues = cx.entity().downgrade();
let weak_issues_open = cx.entity().downgrade();
let store_for_issues = self.store.clone();
let issues_count = self.store.read(cx).issue_count();
DropdownMenu::new("issues")
.label(format!("Issues {issues_count}"))
.icon(Icon::named(CustomIconName::GitIssueDone))
.variant(ButtonVariant::Secondary)
.open(self.issues_menu_open)
.on_click(move |_, window, cx| {
if let Err(error) = weak_issues.update(cx, |this, cx| {
this.open_issues(window, cx);
}) {
log::warn!("failed to open issues: {error}");
}
})
.on_open_change(move |open, _, cx| {
if let Err(error) =
weak_issues_open.update(cx, |this, cx| {
this.issues_menu_open = open;
cx.notify();
})
{
log::warn!("failed to update issues menu: {error}");
}
})
.items(move |_, _| {
vec![MenuItem::new("New issue").handler({
let store = store_for_issues.clone();
move |_, cx| {
dialogs::new_issue::open(store.clone(), cx);
}
})]
})
})
.child( .child(
DropdownMenu::new("share") DropdownMenu::new("share")
.icon(Icon::named(IconRole::Copy)) .icon(Icon::named(IconRole::Copy))
+10 -10
View File
@@ -19,12 +19,12 @@ impl RefSwitcher {
Combobox::new("branch-select", "Branch", Vec::new()) Combobox::new("branch-select", "Branch", Vec::new())
.placeholder("Branch") .placeholder("Branch")
.on_change(move |value, window, cx| { .on_change(move |value, window, cx| {
if let Some(name) = value { if let Some(name) = value
if let Err(error) = branch_view.update(cx, |this, cx| { && let Err(error) = branch_view.update(cx, |this, cx| {
this.switch_ref(super::RefKind::Branch, name, window, cx); this.switch_ref(super::RefKind::Branch, name, window, cx);
}) { })
log::warn!("failed to switch branch: {error}"); {
} log::warn!("failed to switch branch: {error}");
} }
}), }),
window, window,
@@ -38,12 +38,12 @@ impl RefSwitcher {
Combobox::new("tag-select", "Tag", Vec::new()) Combobox::new("tag-select", "Tag", Vec::new())
.placeholder("Tag") .placeholder("Tag")
.on_change(move |value, window, cx| { .on_change(move |value, window, cx| {
if let Some(name) = value { if let Some(name) = value
if let Err(error) = tag_view.update(cx, |this, cx| { && let Err(error) = tag_view.update(cx, |this, cx| {
this.switch_ref(super::RefKind::Tag, name, window, cx); this.switch_ref(super::RefKind::Tag, name, window, cx);
}) { })
log::warn!("failed to switch tag: {error}"); {
} log::warn!("failed to switch tag: {error}");
} }
}), }),
window, window,
@@ -0,0 +1,45 @@
use nostr::prelude::Event;
use signed_core::RepoStatus;
/// Root events counted by their resolved status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct StatusCounts {
pub total: usize,
pub open: usize,
pub closed: usize,
pub draft: usize,
pub applied: usize,
}
impl StatusCounts {
fn record(&mut self, status: RepoStatus) {
self.total += 1;
match status {
RepoStatus::Open => self.open += 1,
RepoStatus::Closed => self.closed += 1,
RepoStatus::Draft => self.draft += 1,
RepoStatus::Applied => self.applied += 1,
}
}
}
/// Indices of `roots` whose status `keep` accepts, counting every root's status.
pub fn filter_by_status<'a>(
roots: impl IntoIterator<Item = &'a Event>,
status_of: impl Fn(&Event) -> RepoStatus,
keep: impl Fn(RepoStatus) -> bool,
) -> (Vec<usize>, StatusCounts) {
let mut counts = StatusCounts::default();
let visible = roots
.into_iter()
.enumerate()
.filter_map(|(index, root)| {
let status = status_of(root);
counts.record(status);
keep(status).then_some(index)
})
.collect();
(visible, counts)
}