add issue detail
This commit is contained in:
@@ -262,6 +262,7 @@ impl RepoDetailView {
|
||||
.selectable(true)
|
||||
.scrollable(true)
|
||||
.p_4()
|
||||
.text_xs()
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -305,7 +306,7 @@ impl RepoDetailView {
|
||||
.bordered(false)
|
||||
.rounded_none()
|
||||
.h_full()
|
||||
.text_sm()
|
||||
.text_xs()
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,11 +5,14 @@ use std::collections::HashMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use anyhow::Error;
|
||||
use assets::CustomIconName;
|
||||
use gpui::prelude::*;
|
||||
use gpui::{AnyElement, App, Task, Window, div, px};
|
||||
use gpui_component::list::ListItem;
|
||||
use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::tree::{TreeEntry, TreeItem};
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
|
||||
use signed_core::RepoStatus;
|
||||
|
||||
/// A `Send` file-tree node: the tree is built on a background thread and
|
||||
/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
|
||||
@@ -227,6 +230,53 @@ pub(super) fn placeholder(message: &str, cx: &App) -> AnyElement {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
/// The status badge shown next to an issue or pull request: icon + colored
|
||||
/// square, with a tooltip describing the status.
|
||||
pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -0,0 +1,238 @@
|
||||
use dock::{Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, Window, div,
|
||||
px,
|
||||
};
|
||||
use gpui_component::avatar::Avatar;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::{Textarea, TextareaState};
|
||||
use gpui_component::{ActiveTheme, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::prelude::{Event, EventId};
|
||||
use signed_core::activity_subject;
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::{placeholder, status_badge};
|
||||
|
||||
/// Detail panel of a single issue.
|
||||
pub struct IssueDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
/// Input state of the "leave a comment" textarea.
|
||||
comment_input: Entity<TextareaState>,
|
||||
}
|
||||
|
||||
impl IssueDetailView {
|
||||
pub fn new(
|
||||
store: Entity<RepoStore>,
|
||||
issue_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
// Issue author avatars stay in the shared cache until the panel closes.
|
||||
crate::image_cache::clear_on_release(&cx.entity(), window, cx);
|
||||
|
||||
let comment_input =
|
||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…"));
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
}
|
||||
}
|
||||
|
||||
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();
|
||||
|
||||
v_flex()
|
||||
.gap_3()
|
||||
.children(comments.iter().map(|comment| {
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(comment.created_at);
|
||||
|
||||
v_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new()
|
||||
.name(author.clone())
|
||||
.when_some(picture, |this, url| this.src(url))
|
||||
.xsmall(),
|
||||
)
|
||||
.child(author),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_color(cx.theme().muted_foreground)
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.child(SharedString::from(comment.content.clone())),
|
||||
)
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_form(&mut self, id: &EventId, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let comment_input = self.comment_input.clone();
|
||||
let store = self.store.clone();
|
||||
let id = id.to_owned();
|
||||
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(Textarea::new(&self.comment_input).h(px(96.)))
|
||||
.child(
|
||||
h_flex().justify_end().child(
|
||||
Button::new("comment")
|
||||
.primary()
|
||||
.label("Comment")
|
||||
.tooltip("Post comment")
|
||||
.on_click(move |_event, window, cx| {
|
||||
let content = comment_input.read(cx).value().trim().to_string();
|
||||
if content.is_empty() {
|
||||
return;
|
||||
}
|
||||
let Some(root) = store
|
||||
.read(cx)
|
||||
.issues
|
||||
.iter()
|
||||
.find(|issue| issue.id == id)
|
||||
.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()
|
||||
}
|
||||
}
|
||||
|
||||
impl Panel for IssueDetailView {
|
||||
fn panel_name(&self) -> &'static str {
|
||||
"issue_detail"
|
||||
}
|
||||
|
||||
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
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"));
|
||||
|
||||
div().text_sm().child(short_id)
|
||||
}
|
||||
}
|
||||
|
||||
impl EventEmitter<PanelEvent> for IssueDetailView {}
|
||||
|
||||
impl Focusable for IssueDetailView {
|
||||
fn focus_handle(&self, _cx: &App) -> FocusHandle {
|
||||
self.focus_handle.clone()
|
||||
}
|
||||
}
|
||||
|
||||
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 (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);
|
||||
(
|
||||
activity_subject(issue),
|
||||
profile.name(),
|
||||
profile.picture(),
|
||||
store.status_of(issue),
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
issue.content.clone(),
|
||||
)
|
||||
};
|
||||
|
||||
v_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()
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_2()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.text_sm()
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Avatar::new()
|
||||
.name(author.clone())
|
||||
.when_some(picture, |this, url| this.src(url))
|
||||
.small(),
|
||||
)
|
||||
.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_sm().child(SharedString::from(&content))),
|
||||
)
|
||||
.child(self.render_comments(&issue_id, cx))
|
||||
.child(self.render_form(&issue_id, cx)),
|
||||
)
|
||||
.into_any_element()
|
||||
}
|
||||
}
|
||||
@@ -3,13 +3,14 @@
|
||||
//! the header's All/Open/Closed filter.
|
||||
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{Panel, PanelEvent};
|
||||
use dock::{DockArea, DockPlacement, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||
SharedString, Size, Window, div, px, relative, size,
|
||||
SharedString, Size, WeakEntity, Window, div, px, relative, size,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
use gpui_component::avatar::Avatar;
|
||||
@@ -18,16 +19,16 @@ use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, Dial
|
||||
use gpui_component::form::{field, v_form};
|
||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||
use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::tooltip::Tooltip;
|
||||
use gpui_component::{
|
||||
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
|
||||
};
|
||||
use nostr::prelude::Event;
|
||||
use nostr::prelude::{Event, EventId};
|
||||
use signed_core::{RepoStatus, activity_subject};
|
||||
use signed_state::{ProfileStore, RepoStore};
|
||||
use utils::relative_time;
|
||||
|
||||
use super::helpers::placeholder;
|
||||
use super::helpers::{placeholder, status_badge};
|
||||
use super::issue_detail::IssueDetailView;
|
||||
|
||||
/// Height of one issue row in the virtual list: 8px vertical padding
|
||||
/// (`py_2`) on top and bottom, a 32px title line (`h_8`) and a 24px meta
|
||||
@@ -63,6 +64,8 @@ impl IssueFilter {
|
||||
|
||||
pub struct IssuesView {
|
||||
focus_handle: FocusHandle,
|
||||
/// Dock area the issue detail panel is opened in.
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
/// Repo store holding the issues and their statuses.
|
||||
store: Entity<RepoStore>,
|
||||
/// Display name of the repository, for the panel title.
|
||||
@@ -83,6 +86,7 @@ pub struct IssuesView {
|
||||
|
||||
impl IssuesView {
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
store: Entity<RepoStore>,
|
||||
repo_name: SharedString,
|
||||
window: &mut Window,
|
||||
@@ -94,6 +98,7 @@ impl IssuesView {
|
||||
|
||||
Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
store,
|
||||
repo_name,
|
||||
filter: IssueFilter::Open,
|
||||
@@ -104,7 +109,28 @@ impl IssuesView {
|
||||
}
|
||||
}
|
||||
|
||||
fn render_row(&self, ix: usize, issue: &Event, cx: &App) -> AnyElement {
|
||||
/// Open the detail panel of `issue_id` at the bottom of the dock area.
|
||||
fn open_issue_detail(
|
||||
&mut self,
|
||||
issue_id: EventId,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
let Some(dock_area) = self.dock_area.upgrade() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel = cx.new(|cx| IssueDetailView::new(self.store.clone(), issue_id, window, cx));
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, window, cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// Render one row of the issue list; `ix` is the row index and
|
||||
/// `issue_ix` the index of the issue in the store's `issues`.
|
||||
fn render_row(&self, ix: usize, issue_ix: usize, cx: &mut Context<Self>) -> AnyElement {
|
||||
let issue = &self.store.read(cx).issues[issue_ix];
|
||||
let title = activity_subject(issue);
|
||||
let id_hex = issue.id.to_hex();
|
||||
let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey);
|
||||
@@ -112,6 +138,7 @@ impl IssuesView {
|
||||
let picture = profile.picture();
|
||||
let age = relative_time(issue.created_at);
|
||||
let status = self.store.read(cx).status_of(issue);
|
||||
let issue_id = issue.id;
|
||||
|
||||
h_flex()
|
||||
.id(ix)
|
||||
@@ -122,7 +149,10 @@ impl IssuesView {
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().border)
|
||||
.items_start()
|
||||
.child(Self::render_status(status, cx))
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.open_issue_detail(issue_id, window, cx);
|
||||
}))
|
||||
.child(status_badge(status, cx))
|
||||
.child(
|
||||
v_flex()
|
||||
.flex_1()
|
||||
@@ -148,7 +178,7 @@ impl IssuesView {
|
||||
Avatar::new()
|
||||
.name(author.clone())
|
||||
.when_some(picture, |this, url| this.src(url))
|
||||
.xsmall(),
|
||||
.small(),
|
||||
)
|
||||
.child(div().child(author)),
|
||||
)
|
||||
@@ -165,51 +195,6 @@ impl IssuesView {
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_status(status: RepoStatus, cx: &App) -> AnyElement {
|
||||
let (icon, label, tooltip, bg, fg) = match status {
|
||||
RepoStatus::Open => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"open",
|
||||
"Issue is open",
|
||||
cx.theme().secondary,
|
||||
cx.theme().secondary_foreground,
|
||||
),
|
||||
RepoStatus::Closed => (
|
||||
CustomIconName::GitIssueClosed,
|
||||
"closed",
|
||||
"Issue is closed",
|
||||
cx.theme().danger,
|
||||
cx.theme().danger_foreground,
|
||||
),
|
||||
RepoStatus::Draft => (
|
||||
CustomIconName::GitIssueOngoing,
|
||||
"draft",
|
||||
"Issue is draft",
|
||||
cx.theme().accent,
|
||||
cx.theme().accent_foreground,
|
||||
),
|
||||
RepoStatus::Applied => (
|
||||
CustomIconName::GitIssueOpen,
|
||||
"applied",
|
||||
"Issue is completed",
|
||||
cx.theme().primary,
|
||||
cx.theme().primary_foreground,
|
||||
),
|
||||
};
|
||||
|
||||
v_flex()
|
||||
.id(label)
|
||||
.flex_shrink_0()
|
||||
.size_7()
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.rounded(cx.theme().radius)
|
||||
.bg(bg)
|
||||
.child(Icon::new(icon).small().text_color(fg))
|
||||
.tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let (total, open, closed) =
|
||||
@@ -494,11 +479,10 @@ impl Render for IssuesView {
|
||||
"issues",
|
||||
sizes,
|
||||
move |this, range, _window, cx| {
|
||||
let issues = &this.store.read(cx).issues;
|
||||
range
|
||||
.map(|ix| {
|
||||
let issue = this.visible_issues[ix];
|
||||
this.render_row(issue, &issues[issue], cx)
|
||||
this.render_row(ix, issue, cx)
|
||||
})
|
||||
.collect()
|
||||
},
|
||||
|
||||
@@ -32,6 +32,7 @@ mod browser;
|
||||
mod commits;
|
||||
mod diff;
|
||||
mod helpers;
|
||||
mod issue_detail;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
|
||||
@@ -661,8 +662,15 @@ impl RepoDetailView {
|
||||
return;
|
||||
};
|
||||
|
||||
let panel =
|
||||
cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), window, cx));
|
||||
let panel = cx.new(|cx| {
|
||||
IssuesView::new(
|
||||
self.dock_area.clone(),
|
||||
self.store.clone(),
|
||||
self.display_name(cx),
|
||||
window,
|
||||
cx,
|
||||
)
|
||||
});
|
||||
|
||||
dock_area.update(cx, |dock_area, cx| {
|
||||
dock_area.add_panel(Arc::new(panel), DockPlacement::Center, window, cx);
|
||||
|
||||
Reference in New Issue
Block a user