diff --git a/Cargo.lock b/Cargo.lock index 33f55ac..315a19b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10808,6 +10808,7 @@ dependencies = [ "gix", "gpui", "gpui-component", + "nostr", "signed_core", "signed_git", "signed_state", diff --git a/crates/assets/assets/icons/README.md b/crates/assets/assets/icons/README.md deleted file mode 100644 index 62d9f35..0000000 --- a/crates/assets/assets/icons/README.md +++ /dev/null @@ -1,115 +0,0 @@ -# Icons - -Icon set for the Signed app. Each file mirrors the name of the equivalent -[gpui-component](https://github.com/longbridge/gpui-component/tree/main/crates/assets/assets/icons) -icon, but the artwork comes from -[Remix Icon](https://remixicon.com/) v4.9.1 (Apache-2.0) unless noted. - -All SVGs are 24×24 (`viewBox="0 0 24 24"`) and use `fill="currentColor"`, so -gpui renders them with the requested color. - -## Mapping (gpui-component icon → Remix Icon) - -| File | Remix Icon | -| --- | --- | -| `a-large-small.svg` | `font-size` | -| `arrow-down.svg` | `arrow-down-line` | -| `arrow-left.svg` | `arrow-left-line` | -| `arrow-right.svg` | `arrow-right-line` | -| `arrow-up.svg` | `arrow-up-line` | -| `asterisk.svg` | `asterisk` | -| `battery.svg` | `battery-line` | -| `battery-charging.svg` | `battery-charge-line` | -| `battery-low.svg` | `battery-low-line` | -| `bell.svg` | `bell-line` | -| `book-open.svg` | `book-open-line` | -| `bot.svg` | `robot-line` | -| `building-2.svg` | `building-2-line` | -| `calendar.svg` | `calendar-line` | -| `chart-pie.svg` | `pie-chart-2-line` | -| `check.svg` | `check-line` | -| `chevron-down.svg` | `arrow-down-s-line` | -| `chevron-left.svg` | `arrow-left-s-line` | -| `chevron-right.svg` | `arrow-right-s-line` | -| `chevron-up.svg` | `arrow-up-s-line` | -| `chevrons-up-down.svg` | `expand-up-down-line` | -| `circle-check.svg` | `checkbox-circle-line` | -| `circle-user.svg` | `account-circle-line` | -| `circle-x.svg` | `close-circle-line` | -| `close.svg` | `close-line` | -| `copy.svg` | `file-copy-line` | -| `cpu.svg` | `cpu-line` | -| `dash.svg` | `subtract-line` | -| `delete.svg` | `delete-bin-line` | -| `ellipsis.svg` | `more-line` | -| `ellipsis-vertical.svg` | `more-2-line` | -| `external-link.svg` | `external-link-line` | -| `eye.svg` | `eye-line` | -| `eye-off.svg` | `eye-off-line` | -| `file.svg` | `file-line` | -| `folder.svg` | `folder-line` | -| `folder-closed.svg` | `folder-2-line` | -| `folder-open.svg` | `folder-open-line` | -| `github.svg` | `github-line` | -| `globe.svg` | `global-line` | -| `hard-drive.svg` | `hard-drive-2-line` | -| `heart.svg` | `heart-line` | -| `inbox.svg` | `inbox-line` | -| `info.svg` | `information-line` | -| `layout-dashboard.svg` | `dashboard-line` | -| `loader.svg` | `loader-line` | -| `loader-circle.svg` | `loader-4-line` | -| `map.svg` | `map-2-line` | -| `maximize.svg` | `fullscreen-line` | -| `menu.svg` | `menu-line` | -| `minimize.svg` | `fullscreen-exit-line` | -| `minus.svg` | `subtract-line` | -| `moon.svg` | `moon-line` | -| `network.svg` | `share-line` | -| `palette.svg` | `palette-line` | -| `panel-bottom.svg` | `layout-bottom-line` | -| `panel-left.svg` | `layout-left-line` | -| `panel-right.svg` | `layout-right-line` | -| `pause.svg` | `pause-line` | -| `play.svg` | `play-line` | -| `plus.svg` | `add-line` | -| `redo.svg` | `corner-up-right-line` | -| `redo-2.svg` | `arrow-go-forward-line` | -| `replace.svg` | `swap-box-line` | -| `resize-corner.svg` | `corner-right-down-line` | -| `search.svg` | `search-line` | -| `settings.svg` | `settings-line` | -| `settings-2.svg` | `equalizer-line` | -| `sort-ascending.svg` | `sort-asc` | -| `sort-descending.svg` | `sort-desc` | -| `square-terminal.svg` | `terminal-box-line` | -| `star.svg` | `star-line` | -| `star-fill.svg` | `star-fill` | -| `star-off.svg` | `star-off-line` | -| `sun.svg` | `sun-line` | -| `thumbs-down.svg` | `thumb-down-line` | -| `thumbs-up.svg` | `thumb-up-line` | -| `triangle-alert.svg` | `alert-line` | -| `undo.svg` | `corner-up-left-line` | -| `undo-2.svg` | `arrow-go-back-line` | -| `user.svg` | `user-line` | -| `window-close.svg` | `close-line` | -| `window-maximize.svg` | `checkbox-blank-line` | -| `window-minimize.svg` | `subtract-line` | -| `window-restore.svg` | `picture-in-picture-line` | - -## Kept as original (no Remix equivalent) - -These have no close Remix Icon counterpart and keep the original lucide -artwork from gpui-component: - -`battery-full.svg`, `battery-medium.svg`, `battery-warning.svg`, -`case-sensitive.svg`, `frame.svg`, `gallery-vertical-end.svg`, -`heart-off.svg`, `inspector.svg`, `memory-stick.svg`, -`panel-bottom-open.svg`, `panel-left-close.svg`, `panel-left-open.svg`, -`panel-right-close.svg`, `panel-right-open.svg` - -## Licensing - -- Remix Icon SVGs: [Apache-2.0](https://github.com/Remix-Design/RemixIcon/blob/master/License) -- Lucide SVGs (kept originals): [ISC](https://github.com/lucide-icons/lucide/blob/main/LICENSE) diff --git a/crates/assets/assets/icons/git-issue-close.svg b/crates/assets/assets/icons/git-issue-close.svg new file mode 100644 index 0000000..e41c605 --- /dev/null +++ b/crates/assets/assets/icons/git-issue-close.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-issue-ongoing.svg b/crates/assets/assets/icons/git-issue-ongoing.svg new file mode 100644 index 0000000..b98dd2f --- /dev/null +++ b/crates/assets/assets/icons/git-issue-ongoing.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-issue-open.svg b/crates/assets/assets/icons/git-issue-open.svg new file mode 100644 index 0000000..7f8a402 --- /dev/null +++ b/crates/assets/assets/icons/git-issue-open.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 75fa882..959bccf 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -52,6 +52,9 @@ pub enum CustomIconName { Filter, GlobalOn, GlobalOff, + GitIssueOpen, + GitIssueClosed, + GitIssueOngoing, GitClone, GitBranch, Tag, @@ -64,6 +67,9 @@ impl IconNamed for CustomIconName { CustomIconName::Filter => "icons/filter.svg", CustomIconName::GlobalOn => "icons/global-on.svg", CustomIconName::GlobalOff => "icons/global-off.svg", + CustomIconName::GitIssueOpen => "icons/git-issue-open.svg", + CustomIconName::GitIssueClosed => "icons/git-issue-close.svg", + CustomIconName::GitIssueOngoing => "icons/git-issue-ongoing.svg", CustomIconName::GitClone => "icons/git-clone.svg", CustomIconName::GitBranch => "icons/git-branch.svg", CustomIconName::Tag => "icons/tag.svg", diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 6736a64..338bee3 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -9,6 +9,6 @@ pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use deletions::Deletions; -pub use model::Announcement; +pub use model::{activity_subject, Announcement}; pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 633f5f8..0be9773 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -26,6 +26,30 @@ pub struct Announcement { pub hashtags: Vec, } +/// Subject of a NIP-34 issue or pull request event: the `subject` tag, +/// falling back to the first non-empty line of the content. +pub fn activity_subject(event: &Event) -> SharedString { + let subject = event + .tags + .iter() + .find_map(|tag| match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Subject(subject)) => Some(subject), + _ => None, + }); + + subject + .map(SharedString::from) + .or_else(|| { + event + .content + .lines() + .map(str::trim) + .find(|line| !line.is_empty()) + .map(SharedString::from) + }) + .unwrap_or(SharedString::from("Untitled")) +} + impl Announcement { /// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. pub fn from_event(event: &Event) -> Option { diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 0202456..8a06b37 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -265,6 +265,25 @@ impl RepoStore { signed_core::resolve_status(events, &root.pubkey, maintainers) } + /// Number of open issues: issues whose resolved status is + /// [`RepoStatus::Open`] (issues without status events default to open). + pub fn open_issue_count(&self) -> usize { + self.issues + .iter() + .filter(|issue| self.status_of(issue) == RepoStatus::Open) + .count() + } + + /// Number of open pull requests: root PR events (not PR updates, whose + /// status is carried by the root) with a resolved status of + /// [`RepoStatus::Open`]. + pub fn open_pull_request_count(&self) -> usize { + self.pull_requests + .iter() + .filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open) + .count() + } + /// Open an issue on this repository. pub fn open_issue(&mut self, subject: Option, content: String, cx: &mut Context) { let builder = GitIssue { diff --git a/crates/workspace/Cargo.toml b/crates/workspace/Cargo.toml index 59949fb..933f140 100644 --- a/crates/workspace/Cargo.toml +++ b/crates/workspace/Cargo.toml @@ -14,5 +14,6 @@ utils = { path = "../utils" } gpui.workspace = true gpui-component.workspace = true gix.workspace = true +nostr.workspace = true anyhow.workspace = true diff --git a/crates/workspace/src/views/repo_detail/issues.rs b/crates/workspace/src/views/repo_detail/issues.rs new file mode 100644 index 0000000..0d115f4 --- /dev/null +++ b/crates/workspace/src/views/repo_detail/issues.rs @@ -0,0 +1,227 @@ +//! 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. + +use std::rc::Rc; + +use assets::CustomIconName; +use gpui::prelude::*; +use gpui::{ + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Window, div, px, size, +}; +use gpui_component::avatar::Avatar; +use gpui_component::dock::{Panel, PanelEvent}; +use gpui_component::scroll::Scrollbar; +use gpui_component::tooltip::Tooltip; +use gpui_component::{ + ActiveTheme, Icon, Sizable, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; +use nostr::prelude::Event; +use signed_core::{RepoStatus, activity_subject}; +use signed_state::{ProfileStore, RepoStore}; +use utils::relative_time; + +use super::helpers::placeholder; + +/// Height of one issue row in the virtual list: two stacked text lines +/// (14px title + 12px meta, ~1.4x line height each) plus a little padding. +const ISSUE_ROW_HEIGHT: f32 = 40.; + +/// Panel listing all issues of a repository (no filters). The list stays +/// live by reading the store during `render`. +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, + /// Per-row heights of the virtual list. + item_sizes: Rc>>, + /// Issue count [`Self::item_sizes`] was built for; rebuilt on change. + issue_len: usize, + /// Virtual list state of the issues list. + scroll_handle: VirtualListScrollHandle, +} + +impl IssuesView { + pub fn new(store: Entity, repo_name: SharedString, cx: &mut Context) -> Self { + Self { + focus_handle: cx.focus_handle(), + store, + repo_name, + item_sizes: Rc::new(Vec::new()), + issue_len: 0, + 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(); + let profile = ProfileStore::global(cx).read(cx).get(&issue.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(issue.created_at); + let status = self.store.read(cx).status_of(issue); + + h_flex() + .id(ix) + .h(px(ISSUE_ROW_HEIGHT)) + .w_full() + .gap_4() + .px_3() + .items_start() + .child(Self::render_status(status, cx)) + .child( + v_flex() + .flex_1() + .child( + div() + .min_w_0() + .text_ellipsis() + .whitespace_nowrap() + .line_clamp(1) + .text_sm() + .child(title), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + h_flex() + .gap_1() + .items_center() + .child( + Avatar::new() + .name(author.clone()) + .when_some(picture, |this, url| this.src(url)) + .xsmall(), + ) + .child(div().child(author)), + ) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&id_hex[..8])), + ) + .child(div().child(age)), + ), + ) + .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 => ( + CustomIconName::GitIssueOpen, + "open", + "Issue is open", + cx.theme().secondary, + cx.theme().secondary_foreground, + ), + RepoStatus::Closed => ( + CustomIconName::GitIssueClosed, + "closed", + "Issue is closed", + cx.theme().warning, + cx.theme().warning_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_6() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(bg) + .child(Icon::new(icon).xsmall().text_color(fg)) + .tooltip(move |window, cx| Tooltip::new(tooltip).build(window, cx)) + .into_any_element() + } +} + +impl Panel for IssuesView { + fn panel_name(&self) -> &'static str { + "issues" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div() + .text_sm() + .child(SharedString::from(format!("{}/issues", self.repo_name))) + } +} + +impl EventEmitter for IssuesView {} + +impl Focusable for IssuesView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +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(); + + if count == 0 { + return placeholder("No issues", cx).into_any_element(); + } + + // The virtual list's item count comes from `item_sizes`; rebuild it + // whenever the store's 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]); + } + + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let view = cx.entity().clone(); + + v_flex() + .relative() + .size_full() + .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)), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 99fcf54..b5c11a4 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 issues; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, @@ -40,6 +41,7 @@ use browser::{ use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; +use issues::IssuesView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -217,9 +219,9 @@ impl RepoDetailView { tag_select, switching_ref: false, ref_generation: 0, - _subscriptions: subscriptions, focus_handle: cx.focus_handle(), tasks: Vec::new(), + _subscriptions: subscriptions, } } @@ -647,10 +649,17 @@ impl RepoDetailView { }); } - /// Open the issue detail view (not implemented yet). - fn open_issue_detail(&mut self, _window: &mut Window, _cx: &mut Context) { - // TODO: open a per-issue detail view in the dock area, like - // [`Self::open_commit_diff`]. + /// Open the issues panel at the bottom of the dock area. + fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx.new(|cx| IssuesView::new(self.store.clone(), self.display_name(cx), cx)); + + dock_area.update(cx, |dock_area, cx| { + dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, None, window, cx); + }); } /// Open the pull request detail view (not implemented yet). @@ -906,8 +915,8 @@ impl RepoDetailView { fn render_header(&self, cx: &mut Context) -> AnyElement { let store = self.store.read(cx); let announcement = store.announcement.as_ref().unwrap_or(&self.initial); - let issue_count = store.issues.len(); - let pull_request_count = store.pull_requests.len(); + let issue_count = store.open_issue_count(); + let pull_request_count = store.open_pull_request_count(); let name = self.display_name(cx); let description = announcement.description(); diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index c4808c5..5bca10b 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -21,7 +21,10 @@ pub struct Workspace { impl Workspace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { - let dock = cx.new(|cx| DockArea::new("dock", Some(1), window, cx)); + let dock = cx.new(|cx| { + DockArea::new("dock", Some(1), window, cx) + .panel_style(gpui_component::dock::PanelStyle::TabBar) + }); let weak_dock = dock.downgrade(); let sidebar = cx.new(|cx| SidebarPanel::new(weak_dock.clone(), cx));