diff --git a/crates/signed_app/src/components.rs b/crates/signed_app/src/components.rs new file mode 100644 index 0000000..d047876 --- /dev/null +++ b/crates/signed_app/src/components.rs @@ -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)) +} diff --git a/crates/signed_app/src/dialogs/create_repo.rs b/crates/signed_app/src/dialogs/create_repo.rs index c934de4..ff2c37f 100644 --- a/crates/signed_app/src/dialogs/create_repo.rs +++ b/crates/signed_app/src/dialogs/create_repo.rs @@ -243,8 +243,7 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu cx.spawn(async move |cx| { if let Ok(Ok(Some(mut paths))) = prompt.await && let Some(path) = paths.pop() - { - if let Err(error) = cx.update_window(handle, |_, window, cx| { + && let Err(error) = cx.update_window(handle, |_, window, cx| { let text = path.to_string_lossy().to_string(); store.update(cx, |store, cx| { store.edit( @@ -255,9 +254,9 @@ fn choose_folder(folder_input: &Entity, window: &mut Window, cx: &mu ); }); 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(); diff --git a/crates/signed_app/src/dialogs/mod.rs b/crates/signed_app/src/dialogs/mod.rs index 757a4b5..b992b82 100644 --- a/crates/signed_app/src/dialogs/mod.rs +++ b/crates/signed_app/src/dialogs/mod.rs @@ -4,6 +4,7 @@ pub mod exit; pub mod grasp_servers; pub mod import; pub mod init_repo; +pub mod new_issue; pub mod onboarding; pub mod passphrase; pub mod progress; diff --git a/crates/signed_app/src/dialogs/new_issue.rs b/crates/signed_app/src/dialogs/new_issue.rs new file mode 100644 index 0000000..ccf2423 --- /dev/null +++ b/crates/signed_app/src/dialogs/new_issue.rs @@ -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, + subject: Entity, + content: Entity, +} + +impl NewIssueDialog { + pub fn new(store: Entity, cx: &mut App) -> Entity { + 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) { + 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) -> 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, cx: &mut App) { + open_global(NewIssueDialog::new(store, cx), cx); +} diff --git a/crates/signed_app/src/lib.rs b/crates/signed_app/src/lib.rs index 09369fc..3062bb9 100644 --- a/crates/signed_app/src/lib.rs +++ b/crates/signed_app/src/lib.rs @@ -1,4 +1,5 @@ mod app; +pub mod components; mod dialogs; mod panels; mod views; diff --git a/crates/signed_app/src/views/discussion.rs b/crates/signed_app/src/views/discussion.rs new file mode 100644 index 0000000..1f37e72 --- /dev/null +++ b/crates/signed_app/src/views/discussion.rs @@ -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, + id: EventId, + roots: fn(&RepoStore) -> &[Event], + top_gap: bool, + directory: &Entity, + 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 = 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 = 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, + root: EventId, + directory: &Entity, + 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, + root: EventId, + roots: fn(&RepoStore) -> &[Event], + comment_input: &Entity, + 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() +} diff --git a/crates/signed_app/src/views/issues/detail.rs b/crates/signed_app/src/views/issues/detail.rs new file mode 100644 index 0000000..fae1a50 --- /dev/null +++ b/crates/signed_app/src/views/issues/detail.rs @@ -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, + issue_id: EventId, + comment_input: Entity, + /// Profiles for the author, commenters and the repository owner. + directory: Entity, + scroll_handle: ScrollHandle, + _subscription: Subscription, +} + +impl IssueDetailView { + pub fn new(store: Entity, issue_id: EventId, cx: &mut Context) -> 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) { + let store = self.store.read(cx); + let mut keys: Vec = 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 { + 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 { + 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) -> 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() + } +} diff --git a/crates/signed_app/src/views/issues/mod.rs b/crates/signed_app/src/views/issues/mod.rs new file mode 100644 index 0000000..91a5374 --- /dev/null +++ b/crates/signed_app/src/views/issues/mod.rs @@ -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, + store: Entity, + repo_name: SharedString, + filter: IssueFilter, + visible_issues: Vec, + 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, + _subscription: Subscription, +} + +impl IssuesView { + pub fn new( + dock: WeakEntity, + store: Entity, + cx: &mut Context, + ) -> 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) { + 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 = 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) -> 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 { + Some(SharedString::from(format!("{}/issues", self.repo_name))) + } + + fn lead(&self, cx: &App) -> Option { + 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) -> 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() + } +} diff --git a/crates/signed_app/src/views/mod.rs b/crates/signed_app/src/views/mod.rs index 18c4e51..bc3091b 100644 --- a/crates/signed_app/src/views/mod.rs +++ b/crates/signed_app/src/views/mod.rs @@ -1,5 +1,8 @@ +pub mod discussion; pub mod explore; +pub mod issues; pub mod placeholder; pub mod repo; pub mod sidebar; +pub mod status_list; pub mod tree; diff --git a/crates/signed_app/src/views/repo/mod.rs b/crates/signed_app/src/views/repo/mod.rs index 15b2320..d6a17f0 100644 --- a/crates/signed_app/src/views/repo/mod.rs +++ b/crates/signed_app/src/views/repo/mod.rs @@ -44,6 +44,7 @@ pub mod refs; use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; use crate::dialogs; +use crate::views::issues::IssuesView; use banners::Banners; use files::RepoFilesView; use history::RepoHistoryView; @@ -83,6 +84,7 @@ pub struct RepoDetailView { share_open: bool, repo_menu_open: bool, clone_open: bool, + issues_menu_open: bool, _subscriptions: Vec, } @@ -177,6 +179,7 @@ impl RepoDetailView { share_open: false, repo_menu_open: false, clone_open: false, + issues_menu_open: false, _subscriptions: subscriptions, }; @@ -833,6 +836,24 @@ impl RepoDetailView { dialogs::init_repo::open(local_path, cx.entity().downgrade(), window, cx); } + fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { + 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) -> AnyElement { if self.store.read(cx).addr().is_none() { return self.render_local_header(cx); @@ -931,6 +952,43 @@ impl RepoDetailView { .flex_none() .gap_2() .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( DropdownMenu::new("share") .icon(Icon::named(IconRole::Copy)) diff --git a/crates/signed_app/src/views/repo/refs.rs b/crates/signed_app/src/views/repo/refs.rs index 5604cb1..91aa388 100644 --- a/crates/signed_app/src/views/repo/refs.rs +++ b/crates/signed_app/src/views/repo/refs.rs @@ -19,12 +19,12 @@ impl RefSwitcher { Combobox::new("branch-select", "Branch", Vec::new()) .placeholder("Branch") .on_change(move |value, window, cx| { - if let Some(name) = value { - if let Err(error) = branch_view.update(cx, |this, cx| { + if let Some(name) = value + && let Err(error) = branch_view.update(cx, |this, 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, @@ -38,12 +38,12 @@ impl RefSwitcher { Combobox::new("tag-select", "Tag", Vec::new()) .placeholder("Tag") .on_change(move |value, window, cx| { - if let Some(name) = value { - if let Err(error) = tag_view.update(cx, |this, cx| { + if let Some(name) = value + && let Err(error) = tag_view.update(cx, |this, 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, diff --git a/crates/signed_app/src/views/status_list.rs b/crates/signed_app/src/views/status_list.rs new file mode 100644 index 0000000..1381ba1 --- /dev/null +++ b/crates/signed_app/src/views/status_list.rs @@ -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, + status_of: impl Fn(&Event) -> RepoStatus, + keep: impl Fn(RepoStatus) -> bool, +) -> (Vec, 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) +}