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
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:
@@ -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))
|
||||
}
|
||||
@@ -243,8 +243,7 @@ fn choose_folder(folder_input: &Entity<InputState>, 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<InputState>, 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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,4 +1,5 @@
|
||||
mod app;
|
||||
pub mod components;
|
||||
mod dialogs;
|
||||
mod panels;
|
||||
mod views;
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<Subscription>,
|
||||
}
|
||||
|
||||
@@ -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<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 {
|
||||
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))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user