diff --git a/Cargo.lock b/Cargo.lock index 09882ba..ef3cb86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1268,7 +1268,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "gpui_util", "indexmap", @@ -1734,7 +1734,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "proc-macro2", "quote", @@ -1887,9 +1887,9 @@ checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" [[package]] name = "either" -version = "1.17.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "embed-resource" @@ -3494,7 +3494,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "accesskit", "anyhow", @@ -3567,7 +3567,7 @@ dependencies = [ [[package]] name = "gpui-base" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#270cf3ce8cf6f4cdac28db5c30dd92631849ed6e" +source = "git+https://github.com/longbridge/gpui-component#14359a0cdd00657eb68fa946bfd99cd83c26e002" dependencies = [ "aho-corasick", "anyhow", @@ -3599,7 +3599,7 @@ dependencies = [ [[package]] name = "gpui-component" version = "0.5.2" -source = "git+https://github.com/longbridge/gpui-component#270cf3ce8cf6f4cdac28db5c30dd92631849ed6e" +source = "git+https://github.com/longbridge/gpui-component#14359a0cdd00657eb68fa946bfd99cd83c26e002" dependencies = [ "anyhow", "chrono", @@ -3679,7 +3679,7 @@ dependencies = [ [[package]] name = "gpui-component-assets" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#270cf3ce8cf6f4cdac28db5c30dd92631849ed6e" +source = "git+https://github.com/longbridge/gpui-component#14359a0cdd00657eb68fa946bfd99cd83c26e002" dependencies = [ "anyhow", "gpui", @@ -3693,7 +3693,7 @@ dependencies = [ [[package]] name = "gpui-component-macros" version = "0.5.1" -source = "git+https://github.com/longbridge/gpui-component#270cf3ce8cf6f4cdac28db5c30dd92631849ed6e" +source = "git+https://github.com/longbridge/gpui-component#14359a0cdd00657eb68fa946bfd99cd83c26e002" dependencies = [ "proc-macro2", "quote", @@ -3703,7 +3703,7 @@ dependencies = [ [[package]] name = "gpui_apple" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "block", @@ -3726,7 +3726,7 @@ dependencies = [ [[package]] name = "gpui_linux" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "accesskit", "accesskit_unix", @@ -3772,7 +3772,7 @@ dependencies = [ [[package]] name = "gpui_macos" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "accesskit", "accesskit_macos", @@ -3818,7 +3818,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -3829,7 +3829,7 @@ dependencies = [ [[package]] name = "gpui_platform" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "console_error_panic_hook", "gpui", @@ -3842,7 +3842,7 @@ dependencies = [ [[package]] name = "gpui_shared_string" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "schemars", "serde", @@ -3852,7 +3852,7 @@ dependencies = [ [[package]] name = "gpui_util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "log", @@ -3862,7 +3862,7 @@ dependencies = [ [[package]] name = "gpui_web" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3886,7 +3886,7 @@ dependencies = [ [[package]] name = "gpui_wgpu" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "bytemuck", @@ -3912,7 +3912,7 @@ dependencies = [ [[package]] name = "gpui_windows" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "accesskit", "accesskit_windows", @@ -3949,9 +3949,9 @@ dependencies = [ [[package]] name = "h2" -version = "0.4.17" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f877e75f39e9827ec50a572dd592684ac28c029578726c85f1b2aa6ab807449" +checksum = "839c0e8a181239723652be9062bb56ca5bf5f64011f73b623f6f4fc59086a228" dependencies = [ "atomic-waker", "bytes", @@ -4239,7 +4239,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "async-compression", @@ -4259,7 +4259,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "rustls", "rustls-platform-verifier 0.5.3", @@ -4434,9 +4434,9 @@ checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.3.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -5235,7 +5235,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "bindgen", @@ -6282,7 +6282,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "collections", "serde", @@ -7079,7 +7079,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "derive_refineable", ] @@ -7162,7 +7162,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "bytes", @@ -7574,7 +7574,7 @@ dependencies = [ [[package]] name = "scheduler" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "async-task", "backtrace", @@ -8301,7 +8301,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "heapless 0.9.3", "log", @@ -9674,7 +9674,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "perf", "quote", @@ -11454,9 +11454,9 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.5" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f212a141d820099d57ffafb9569be9617a6f27d3dc881fbee8fb56642f917a9" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", @@ -11472,7 +11472,7 @@ checksum = "34b31d188d9d685a4f9c7b46d6e36631b07058d2cfe190267adce54dc230bf12" [[package]] name = "zlog" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "anyhow", "chrono", @@ -11489,7 +11489,7 @@ checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" [[package]] name = "ztracing" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" dependencies = [ "tracing", "tracing-subscriber", @@ -11500,7 +11500,7 @@ dependencies = [ [[package]] name = "ztracing_macro" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#1b04e4caf01e376624fb514ef85b0e6d8ee5d930" +source = "git+https://github.com/zed-industries/zed#ec18126b1dbd32b089e51d7edee1e20b3bd53637" [[package]] name = "zune-core" diff --git a/crates/signed_core/src/filters.rs b/crates/signed_core/src/filters.rs index 6d2875b..d5ab78d 100644 --- a/crates/signed_core/src/filters.rs +++ b/crates/signed_core/src/filters.rs @@ -3,11 +3,12 @@ use nostr::prelude::*; use crate::RepoAddr; /// Kinds that make up the activity of a repository. -pub const ACTIVITY_KINDS: [Kind; 8] = [ +pub const ACTIVITY_KINDS: [Kind; 9] = [ Kind::GitPatch, Kind::GitPullRequest, Kind::GitPullRequestUpdate, Kind::GitIssue, + Kind::Comment, Kind::GitStatusOpen, Kind::GitStatusApplied, Kind::GitStatusClosed, @@ -30,7 +31,8 @@ pub fn state(addr: &RepoAddr) -> Filter { .identifier(addr.identifier.clone()) } -/// All NIP-34 activity addressed to a repository (`#a` tag). +/// All NIP-34 activity addressed to a repository (`#a` tag): issues, PRs, +/// patches, statuses and comments (kind 1111). /// /// Note: the `a` tag on status events is optional per NIP-34, so statuses /// published without it won't be matched here. diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index a4eec77..a89bdf8 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -11,8 +11,9 @@ use crate::backend::{Backend, BackendEvent}; /// events (e.g. per-event `NostrUpdate`s) collapse into one query. const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); -/// Per-repository store: announcement, state, issues, patches, PRs and -/// their resolved statuses. Always derived from the local database. +/// Per-repository store: announcement, state, issues, patches, PRs, +/// comments and their resolved statuses. Always derived from the local +/// database. pub struct RepoStore { addr: RepoAddr, pub announcement: Option, @@ -23,6 +24,8 @@ pub struct RepoStore { pub issues: Vec, pub patches: Vec, pub pull_requests: Vec, + /// Comments on issues / PRs, oldest first. + pub comments: Vec, statuses: Vec, /// Error of the last action initiated from this store, if any. pub last_error: Option, @@ -73,6 +76,7 @@ impl RepoStore { issues: Vec::new(), patches: Vec::new(), pull_requests: Vec::new(), + comments: Vec::new(), statuses: Vec::new(), last_error: None, refreshing: false, @@ -172,8 +176,8 @@ impl RepoStore { let all_states = states.into_iter().filter(|e| !deletions.is_deleted(e)); let state = latest(all_states).map(|state| parse_state(&state)); - let (mut issues, mut patches, mut pull_requests, mut statuses) = - (Vec::new(), Vec::new(), Vec::new(), Vec::new()); + let (mut issues, mut patches, mut pull_requests, mut statuses, mut comments) = + (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); for event in activity { if deletions.is_deleted(&event) { @@ -183,6 +187,7 @@ impl RepoStore { Kind::GitIssue => issues.push(event), Kind::GitPatch => patches.push(event), Kind::GitPullRequest | Kind::GitPullRequestUpdate => pull_requests.push(event), + Kind::Comment => comments.push(event), kind if RepoStatus::from_kind(kind).is_some() => statuses.push(event), _ => {} } @@ -191,6 +196,7 @@ impl RepoStore { sort_newest_first(&mut issues); sort_newest_first(&mut patches); sort_newest_first(&mut pull_requests); + sort_oldest_first(&mut comments); Ok::<_, Error>(( announcement, @@ -199,21 +205,23 @@ impl RepoStore { patches, pull_requests, statuses, + comments, )) }); self.tasks.retain(|task| !task.is_ready()); self.tasks.push(cx.spawn(async move |this, cx| { - let (announcement, state, issues, patches, pull_requests, statuses) = match work.await { - Ok(data) => data, - Err(e) => { - return this.update(cx, |this, cx| { - this.refreshing = false; - this.last_error = Some(e.to_string()); - cx.notify(); - }); - } - }; + let (announcement, state, issues, patches, pull_requests, statuses, comments) = + match work.await { + Ok(data) => data, + Err(e) => { + return this.update(cx, |this, cx| { + this.refreshing = false; + this.last_error = Some(e.to_string()); + cx.notify(); + }); + } + }; let again = this.update(cx, |this, cx| { this.announcement = announcement; @@ -226,6 +234,7 @@ impl RepoStore { this.issues = issues; this.patches = patches; this.pull_requests = pull_requests; + this.comments = comments; this.statuses = statuses; cx.notify(); @@ -297,6 +306,28 @@ impl RepoStore { self.send(builder, cx); } + /// Comments on a root event (issue / PR), oldest first. + pub fn comments_of(&self, root: &EventId) -> impl Iterator { + self.comments + .iter() + .filter(move |e| signed_core::references_root(e, root)) + } + + /// Comment on a root event (issue / PR) per NIP-34 (kind 1111). + pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context) { + let Ok(root_ref) = Tag::parse(["e", &root.id.to_hex(), "", "root"]) else { + return; + }; + + let builder = EventBuilder::new(Kind::Comment, content).tags([ + root_ref, + Tag::public_key(root.pubkey), + Tag::coordinate(self.addr.clone(), None), + ]); + + self.send(builder, cx); + } + /// Open a pull request on this repository: a root PR event whose content /// is the `git format-patch` output of the proposed changes. /// @@ -390,6 +421,10 @@ fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); } +fn sort_oldest_first(events: &mut [Event]) { + events.sort_by_key(|e| e.created_at); +} + /// The proposed commit of a `git format-patch` output: the `From ` /// header on its first line. fn patch_current_commit(patch: &str) -> Option<&str> { diff --git a/crates/workspace/src/views/repo_detail/browser.rs b/crates/workspace/src/views/repo_detail/browser.rs index b6b9f2b..212441a 100644 --- a/crates/workspace/src/views/repo_detail/browser.rs +++ b/crates/workspace/src/views/repo_detail/browser.rs @@ -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() } } diff --git a/crates/workspace/src/views/repo_detail/helpers.rs b/crates/workspace/src/views/repo_detail/helpers.rs index d4dbc8f..a79d07c 100644 --- a/crates/workspace/src/views/repo_detail/helpers.rs +++ b/crates/workspace/src/views/repo_detail/helpers.rs @@ -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::*; diff --git a/crates/workspace/src/views/repo_detail/issue_detail.rs b/crates/workspace/src/views/repo_detail/issue_detail.rs new file mode 100644 index 0000000..4cd1c49 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/issue_detail.rs @@ -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, + issue_id: EventId, + /// Input state of the "leave a comment" textarea. + comment_input: Entity, +} + +impl IssueDetailView { + pub fn new( + store: Entity, + issue_id: EventId, + window: &mut Window, + cx: &mut Context, + ) -> 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) -> 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) -> 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) -> 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 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) -> 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() + } +} diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs index 62069fb..706c65b 100644 --- a/crates/workspace/src/views/repo_detail/issues.rs +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -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, /// Repo store holding the issues and their statuses. store: Entity, /// Display name of the repository, for the panel title. @@ -83,6 +86,7 @@ pub struct IssuesView { impl IssuesView { pub fn new( + dock_area: WeakEntity, store: Entity, 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, + ) { + 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) -> 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) -> 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() }, diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index e0bd819..38b9718 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -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);