diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index f1cf180..f375cba 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -1,6 +1,6 @@ //! Issues panel: a bottom panel listing every issue of the repository with -//! its title, event id, author, age and status. Minimal placeholder UI; the -//! presentation is expected to be redesigned later. +//! its title, event id, author, age and status, filterable by status via +//! the header's All/Open/Closed filter. use std::rc::Rc; @@ -11,11 +11,16 @@ use gpui::{ SharedString, Size, Window, div, px, size, }; use gpui_component::avatar::Avatar; +use gpui_component::button::{Button, ButtonVariants}; +use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::dock::{Panel, PanelEvent}; +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, h_flex, v_flex, v_virtual_list, + ActiveTheme, Icon, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle, + WindowExt, h_flex, v_flex, v_virtual_list, }; use nostr::prelude::Event; use signed_core::{RepoStatus, activity_subject}; @@ -30,18 +35,44 @@ use super::helpers::placeholder; /// line is ~22.7px; the row totals ~71px. const ISSUE_ROW_HEIGHT: f32 = 71.; -/// Panel listing all issues of a repository (no filters). The list stays -/// live by reading the store during `render`. +/// Status filter of the issues list, chosen via the header's filter buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum IssueFilter { + /// Every issue, regardless of status. + All, + /// Issues whose resolved status is [`RepoStatus::Open`]. + Open, + /// Issues whose resolved status is [`RepoStatus::Closed`]. + Closed, +} + +impl IssueFilter { + /// Whether `issue` (of `store`) is included by this filter. + fn matches(self, store: &RepoStore, issue: &Event) -> bool { + match self { + Self::All => true, + Self::Open => store.status_of(issue) == RepoStatus::Open, + Self::Closed => store.status_of(issue) == RepoStatus::Closed, + } + } +} + pub struct IssuesView { focus_handle: FocusHandle, /// Repo store holding the issues and their statuses. store: Entity, /// Display name of the repository, for the panel title. repo_name: SharedString, + /// Filter selected in the header filter buttons. + filter: IssueFilter, /// Per-row heights of the virtual list. item_sizes: Rc>>, - /// Issue count [`Self::item_sizes`] was built for; rebuilt on change. + /// Number of rows [`Self::item_sizes`] was built for (the filtered + /// issue count); rebuilt on change. issue_len: usize, + /// Indices into the store's `issues` matching [`Self::filter`], rebuilt + /// every render; the virtual list renders this slice. + visible_issues: Vec, /// Virtual list state of the issues list. scroll_handle: VirtualListScrollHandle, } @@ -61,13 +92,14 @@ impl IssuesView { focus_handle: cx.focus_handle(), store, repo_name, + filter: IssueFilter::Open, item_sizes: Rc::new(Vec::new()), issue_len: 0, + visible_issues: Vec::new(), scroll_handle: VirtualListScrollHandle::new(), } } - /// One issue row: title, event id, author, age and status. fn render_row(&self, ix: usize, issue: &Event, cx: &App) -> AnyElement { let title = activity_subject(issue); let id_hex = issue.id.to_hex(); @@ -126,7 +158,6 @@ impl IssuesView { .into_any_element() } - /// Small status tag: open (green), closed (red), applied (blue), draft (yellow). fn render_status(status: RepoStatus, cx: &App) -> AnyElement { let (icon, label, tooltip, bg, fg) = match status { RepoStatus::Open => ( @@ -171,6 +202,139 @@ impl IssuesView { .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) .into_any_element() } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + h_flex() + .w_full() + .items_center() + .gap_3() + .px_3() + .pb_2() + .border_b_1() + .border_color(cx.theme().border) + .child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .font_semibold() + .child("Issues"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("all") + .icon(CustomIconName::GitIssueOpen) + .label("All") + .ghost() + .selected(self.filter == IssueFilter::All) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::All; + cx.notify(); + })), + ) + .child( + Button::new("open") + .icon(CustomIconName::GitIssueOpen) + .label("Open") + .ghost() + .selected(self.filter == IssueFilter::Open) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::Open; + cx.notify(); + })), + ) + .child( + Button::new("closed") + .icon(CustomIconName::GitIssueClosed) + .label("Closed") + .ghost() + .selected(self.filter == IssueFilter::Closed) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = IssueFilter::Closed; + cx.notify(); + })), + ), + ) + // Spacer: pushes the button to the right edge. + .child(div().flex_1()) + .child( + Button::new("new-issue") + .icon(IconName::Plus) + .label("New issue") + .primary() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_issue_dialog(this.store.clone(), window, cx); + })), + ) + .into_any_element() + } +} + +/// Open the "new issue" dialog: a title and a content input that submit +/// through [`RepoStore::open_issue`] when confirmed. +fn open_new_issue_dialog(store: Entity, window: &mut Window, cx: &mut App) { + let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Issue title")); + let content = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the issue…")); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let subject = subject.clone(); + let content = content.clone(); + let store = store.clone(); + + dialog + .width(px(520.)) + .margin_top(px(50.)) + .content(move |body, _window, _cx| { + body.child( + DialogHeader::new() + .child(DialogTitle::new().child("New issue")) + .child( + DialogDescription::new() + .child("Report a bug, ask a question, or propose a change."), + ), + ) + .child( + v_form() + .child( + field() + .label("Title") + .required(true) + .child(Input::new(&subject)), + ) + .child( + field() + .label("Content") + .child(Textarea::new(&content).h(px(160.))), + ), + ) + .child( + DialogFooter::new().justify_end().child( + Button::new("submit") + .primary() + .label("Create issue") + .tooltip("Create issue") + .on_click({ + let subject = subject.clone(); + let content = content.clone(); + let store = store.clone(); + + move |_event, window, cx| { + let subject = subject.read(cx).value().to_string(); + let content = content.read(cx).value().to_string(); + let subject = (!subject.is_empty()).then_some(subject); + + store.update(cx, |store, cx| { + store.open_issue(subject, content, cx); + }); + + window.close_dialog(cx); + } + }), + ), + ) + }) + }); } impl Panel for IssuesView { @@ -193,15 +357,25 @@ impl Focusable for IssuesView { impl Render for IssuesView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let store = self.store.read(cx); - let count = store.issues.len(); + let filter = self.filter; - if count == 0 { - return placeholder("No issues", cx).into_any_element(); - } + // Indices of the issues matching the active filter; the virtual + // list renders this filtered slice. + self.visible_issues = { + let store = self.store.read(cx); + store + .issues + .iter() + .enumerate() + .filter(|(_, issue)| filter.matches(store, issue)) + .map(|(ix, _)| ix) + .collect() + }; + + let count = self.visible_issues.len(); // The virtual list's item count comes from `item_sizes`; rebuild it - // whenever the store's issue count changes. + // whenever the filtered issue count changes. if count != self.issue_len { self.issue_len = count; self.item_sizes = Rc::new(vec![size(px(0.), px(ISSUE_ROW_HEIGHT)); count]); @@ -212,26 +386,46 @@ impl Render for IssuesView { let view = cx.entity().clone(); v_flex() - .relative() .size_full() + .child(self.render_header(cx)) .child( - v_virtual_list(view, "issues", sizes, move |this, range, _window, cx| { - let issues = &this.store.read(cx).issues; - range - .map(|ix| this.render_row(ix, &issues[ix], cx)) - .collect() - }) - .track_scroll(&scroll_handle) - .size_full(), - ) - .child( - div() - .absolute() - .top_0() - .left_0() - .right_0() - .bottom_0() - .child(Scrollbar::vertical(&scroll_handle)), + v_flex() + .relative() + .flex_1() + .min_h_0() + .w_full() + .when(count > 0, |this| { + this.child( + v_virtual_list(view, "il", sizes, move |this, range, _window, cx| { + let issues = &this.store.read(cx).issues; + range + .map(|ix| { + let issue_ix = this.visible_issues[ix]; + this.render_row(issue_ix, &issues[issue_ix], cx) + }) + .collect() + }) + .track_scroll(&scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical(&scroll_handle)), + ) + }) + .when(count == 0, |this| { + let message = match filter { + IssueFilter::All => "No issues", + IssueFilter::Open => "No open issues", + IssueFilter::Closed => "No closed issues", + }; + this.child(placeholder(message, cx)) + }), ) .into_any_element() }