diff --git a/Cargo.lock b/Cargo.lock index 764ce2a..37ce9d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7902,6 +7902,7 @@ name = "signed_state" version = "1.0.0" dependencies = [ "anyhow", + "bitcoin_hashes 1.2.0", "flume 0.11.1", "gpui", "log", diff --git a/crates/assets/assets/icons/git-pull-request-closed.svg b/crates/assets/assets/icons/git-pull-request-closed.svg new file mode 100644 index 0000000..dc3def8 --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-closed.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request-draft.svg b/crates/assets/assets/icons/git-pull-request-draft.svg new file mode 100644 index 0000000..26b93ae --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-draft.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request-merged.svg b/crates/assets/assets/icons/git-pull-request-merged.svg new file mode 100644 index 0000000..cb3e92b --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request-merged.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/assets/icons/git-pull-request.svg b/crates/assets/assets/icons/git-pull-request.svg new file mode 100644 index 0000000..404abd6 --- /dev/null +++ b/crates/assets/assets/icons/git-pull-request.svg @@ -0,0 +1,3 @@ + + + diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 959bccf..751b9f4 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -55,6 +55,10 @@ pub enum CustomIconName { GitIssueOpen, GitIssueClosed, GitIssueOngoing, + GitPullRequest, + GitPullRequestClosed, + GitPullRequestDraft, + GitPullRequestMerged, GitClone, GitBranch, Tag, @@ -70,6 +74,10 @@ impl IconNamed for CustomIconName { CustomIconName::GitIssueOpen => "icons/git-issue-open.svg", CustomIconName::GitIssueClosed => "icons/git-issue-close.svg", CustomIconName::GitIssueOngoing => "icons/git-issue-ongoing.svg", + CustomIconName::GitPullRequest => "icons/git-pull-request.svg", + CustomIconName::GitPullRequestClosed => "icons/git-pull-request-closed.svg", + CustomIconName::GitPullRequestDraft => "icons/git-pull-request-draft.svg", + CustomIconName::GitPullRequestMerged => "icons/git-pull-request-merged.svg", CustomIconName::GitClone => "icons/git-clone.svg", CustomIconName::GitBranch => "icons/git-branch.svg", CustomIconName::Tag => "icons/tag.svg", diff --git a/crates/signed_state/Cargo.toml b/crates/signed_state/Cargo.toml index 5192a08..0bd56f0 100644 --- a/crates/signed_state/Cargo.toml +++ b/crates/signed_state/Cargo.toml @@ -14,6 +14,8 @@ nostr.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true +bitcoin_hashes = "1" + gpui.workspace = true flume.workspace = true anyhow.workspace = true diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 8a06b37..a4eec77 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -297,6 +297,39 @@ impl RepoStore { 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. + /// + /// The branch metadata (branch name, clone URL, merge base, root patch) + /// isn't known to the UI yet and is left empty; the proposed commit is + /// parsed from the patch's `From ` header, falling back to an + /// empty hash for hand-written content. + pub fn open_pull_request( + &mut self, + subject: Option, + content: String, + cx: &mut Context, + ) { + let current_commit = patch_current_commit(&content) + .and_then(|hex| hex.parse().ok()) + .unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20])); + + let builder = GitPullRequest { + repository: self.addr.clone(), + content, + subject, + labels: Vec::new(), + branch_name: None, + clone: Vec::new(), + current_commit, + root_patch_event: None, + merge_base: None, + } + .into_event_builder(); + + self.send(builder, cx); + } + /// Send a root patch (`git format-patch` output) to this repository. pub fn send_root_patch(&mut self, patch: String, cx: &mut Context) { let Ok(root_marker) = Tag::parse(["t", "root"]) else { @@ -356,3 +389,32 @@ where fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(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> { + let line = patch.lines().next()?; + let hex = line.strip_prefix("From ")?; + hex.split_whitespace().next().filter(|hex| hex.len() == 40) +} + +#[cfg(test)] +mod tests { + use super::patch_current_commit; + + #[test] + fn parses_format_patch_header() { + let patch = "From 1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a Mon Sep 17 00:00:00 2001\nFrom: A \nSubject: [PATCH] fix\n\n---\n"; + assert_eq!( + patch_current_commit(patch), + Some("1f6c0c5f3f1a1a1a1a1a1a1a1a1a1a1a1a1a1a1a") + ); + } + + #[test] + fn no_commit_without_header() { + assert_eq!(patch_current_commit(""), None); + assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None); + assert_eq!(patch_current_commit("From short\n"), None); + } +} diff --git a/crates/workspace/src/image_cache.rs b/crates/workspace/src/image_cache.rs index ef093b8..a846f5f 100644 --- a/crates/workspace/src/image_cache.rs +++ b/crates/workspace/src/image_cache.rs @@ -1,23 +1,3 @@ -//! The shared image cache used by every `img` element in the app. -//! -//! Without an explicit cache, images (the profile pictures shown by -//! `Avatar`s) fall back to the window's asset cache and are retained for the -//! lifetime of the window: every avatar that was ever shown stays decoded in -//! memory. This module installs one bounded LRU cache that the app controls -//! instead: -//! -//! * The cache holds at most [`MAX_IMAGES`] entries; loading a new image -//! evicts the least recently used one. Entries remember the [`Resource`] -//! they were loaded from, so eviction drops the image from the sprite -//! atlas *and* removes the asset from the asset system — freeing the raw -//! fetched bytes alongside the decoded image. -//! * [`clear_on_release`] drops the whole cache when an image-heavy view -//! (`RepoDetailView`, `IssuesView`) is released, i.e. its panel closes. -//! * [`clear`] drops everything on demand from anywhere in the app. -//! -//! Images that get cleared are re-fetched and re-decoded the next time they -//! are rendered, so clearing trades a little bandwidth/CPU for memory. - use std::collections::{HashMap, VecDeque}; use std::mem::take; use std::sync::Arc; diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 7f252aa..5beb9e7 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -33,6 +33,7 @@ mod commits; mod diff; mod helpers; mod issues; +mod pull_requests; use browser::{ CodeView, FileContent, MAX_PREVIEW_BYTES, MAX_PREVIEW_CACHE_BYTES, MAX_PREVIEWED_FILES, @@ -42,6 +43,7 @@ use commits::COMMIT_ROW_HEIGHT; use diff::CommitDiffView; use helpers::{TreeItemSeed, build_tree_items, is_markdown_path, track, tree_items}; use issues::IssuesView; +use pull_requests::PullRequestsView; /// What kind of ref the header selectors switch to. #[derive(Clone, Copy, PartialEq, Eq)] @@ -667,10 +669,18 @@ impl RepoDetailView { }); } - /// Open the pull request detail view (not implemented yet). - fn open_pull_request_detail(&mut self, _window: &mut Window, _cx: &mut Context) { - // TODO: open a per-PR detail view in the dock area, like - // [`Self::open_commit_diff`]. + /// Open the pull requests panel at the bottom of the dock area. + fn open_pull_request_detail(&mut self, window: &mut Window, cx: &mut Context) { + let Some(dock_area) = self.dock_area.upgrade() else { + return; + }; + + let panel = cx + .new(|cx| PullRequestsView::new(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, None, window, cx); + }); } /// Check out `name` (a branch or tag picked in the header) and refresh diff --git a/crates/workspace/src/views/repo_detail/pull_requests.rs b/crates/workspace/src/views/repo_detail/pull_requests.rs new file mode 100644 index 0000000..2da897a --- /dev/null +++ b/crates/workspace/src/views/repo_detail/pull_requests.rs @@ -0,0 +1,470 @@ +//! Pull requests panel: a bottom panel listing every pull request of the +//! repository with its title, event id, author, age and status, filterable +//! by status via the header's All/Open/Closed/Draft/Merged filter. + +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::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, IconName, Selectable, Sizable, StyledExt, VirtualListScrollHandle, + WindowExt, h_flex, v_flex, v_virtual_list, +}; +use nostr::prelude::{Event, Kind}; +use signed_core::{RepoStatus, activity_subject}; +use signed_state::{ProfileStore, RepoStore}; +use utils::relative_time; + +use super::helpers::placeholder; + +/// Height of one pull request row in the virtual list: same layout as an +/// issue row (12px padding on top and bottom, a 14px title line and a 24px +/// meta line), so the row totals ~71px. +const PR_ROW_HEIGHT: f32 = 71.; + +/// Status filter of the pull request list, chosen via the header's filter +/// buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PullRequestFilter { + /// Every pull request, regardless of status. + All, + /// Pull requests whose resolved status is [`RepoStatus::Open`]. + Open, + /// Pull requests whose resolved status is [`RepoStatus::Closed`]. + Closed, + /// Pull requests whose resolved status is [`RepoStatus::Draft`]. + Draft, + /// Pull requests whose resolved status is [`RepoStatus::Applied`] + /// (i.e. merged). + Merged, +} + +impl PullRequestFilter { + /// Whether `pr` (of `store`) is included by this filter. + fn matches(self, store: &RepoStore, pr: &Event) -> bool { + match self { + Self::All => true, + Self::Open => store.status_of(pr) == RepoStatus::Open, + Self::Closed => store.status_of(pr) == RepoStatus::Closed, + Self::Draft => store.status_of(pr) == RepoStatus::Draft, + Self::Merged => store.status_of(pr) == RepoStatus::Applied, + } + } +} + +pub struct PullRequestsView { + focus_handle: FocusHandle, + /// Repo store holding the pull requests 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: PullRequestFilter, + /// Per-row heights of the virtual list. + item_sizes: Rc>>, + /// Number of rows [`Self::item_sizes`] was built for (the filtered + /// pull request count); rebuilt on change. + pr_len: usize, + /// Indices into the store's `pull_requests` matching [`Self::filter`] + /// (root PR events only; updates are revisions of the root and are not + /// listed separately), rebuilt every render; the virtual list renders + /// this slice. + visible_prs: Vec, + /// Virtual list state of the pull requests list. + scroll_handle: VirtualListScrollHandle, +} + +impl PullRequestsView { + pub fn new( + store: Entity, + repo_name: SharedString, + window: &mut Window, + cx: &mut Context, + ) -> Self { + // PR author avatars stay in the shared cache until the panel + // closes; free them then. + crate::image_cache::clear_on_release(&cx.entity(), window, cx); + + Self { + focus_handle: cx.focus_handle(), + store, + repo_name, + filter: PullRequestFilter::Open, + item_sizes: Rc::new(Vec::new()), + pr_len: 0, + visible_prs: Vec::new(), + scroll_handle: VirtualListScrollHandle::new(), + } + } + + fn render_row(&self, ix: usize, pr: &Event, cx: &App) -> AnyElement { + let title = activity_subject(pr); + let id_hex = pr.id.to_hex(); + let profile = ProfileStore::global(cx).read(cx).get(&pr.pubkey); + let author = profile.name(); + let picture = profile.picture(); + let age = relative_time(pr.created_at); + let status = self.store.read(cx).status_of(pr); + + h_flex() + .id(ix) + .h(px(PR_ROW_HEIGHT)) + .w_full() + .gap_4() + .p_3() + .border_b_1() + .border_color(cx.theme().border) + .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() + .child( + Avatar::new() + .name(author.clone()) + .when_some(picture, |this, url| this.src(url)) + .xsmall(), + ) + .child(div().child(author)), + ) + .child(SharedString::from("opened")) + .child( + div() + .text_color(cx.theme().muted_foreground) + .child(SharedString::from(&id_hex[..8])), + ) + .child(div().child(age)), + ), + ) + .into_any_element() + } + + fn render_status(status: RepoStatus, cx: &App) -> AnyElement { + let (icon, label, tooltip, bg, fg) = match status { + RepoStatus::Open => ( + CustomIconName::GitPullRequest, + "open", + "Pull request is open", + cx.theme().secondary, + cx.theme().secondary_foreground, + ), + RepoStatus::Closed => ( + CustomIconName::GitPullRequestClosed, + "closed", + "Pull request is closed", + cx.theme().warning, + cx.theme().warning_foreground, + ), + RepoStatus::Draft => ( + CustomIconName::GitPullRequestDraft, + "draft", + "Pull request is a draft", + cx.theme().accent, + cx.theme().accent_foreground, + ), + RepoStatus::Applied => ( + CustomIconName::GitPullRequestMerged, + "merged", + "Pull request is merged", + 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() + } + + 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("Pull Requests"), + ) + .child( + h_flex() + .gap_1() + .child( + Button::new("all") + .icon(CustomIconName::GitPullRequest) + .label("All") + .ghost() + .selected(self.filter == PullRequestFilter::All) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::All; + cx.notify(); + })), + ) + .child( + Button::new("open") + .icon(CustomIconName::GitPullRequest) + .label("Open") + .ghost() + .selected(self.filter == PullRequestFilter::Open) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Open; + cx.notify(); + })), + ) + .child( + Button::new("closed") + .icon(CustomIconName::GitPullRequestClosed) + .label("Closed") + .ghost() + .selected(self.filter == PullRequestFilter::Closed) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Closed; + cx.notify(); + })), + ) + .child( + Button::new("draft") + .icon(CustomIconName::GitPullRequestDraft) + .label("Draft") + .ghost() + .selected(self.filter == PullRequestFilter::Draft) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Draft; + cx.notify(); + })), + ) + .child( + Button::new("merged") + .icon(CustomIconName::GitPullRequestMerged) + .label("Merged") + .ghost() + .selected(self.filter == PullRequestFilter::Merged) + .on_click(cx.listener(|this, _event, _window, cx| { + this.filter = PullRequestFilter::Merged; + cx.notify(); + })), + ), + ) + // Spacer: pushes the button to the right edge. + .child(div().flex_1()) + .child( + Button::new("new-pr") + .icon(IconName::Plus) + .label("New pull request") + .primary() + .on_click(cx.listener(|this, _event, window, cx| { + open_new_pull_request_dialog(this.store.clone(), window, cx); + })), + ) + .into_any_element() + } +} + +/// Open the "new pull request" dialog: a title and a patch input that +/// submit through [`RepoStore::open_pull_request`] when confirmed. +fn open_new_pull_request_dialog(store: Entity, window: &mut Window, cx: &mut App) { + let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); + let patch = + cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…")); + + window.open_dialog(cx, move |dialog, _window, _cx| { + let subject = subject.clone(); + let patch = patch.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 pull request")) + .child( + DialogDescription::new() + .child("Propose a change with the output of `git format-patch`."), + ), + ) + .child( + v_form() + .child( + field() + .label("Title") + .required(true) + .child(Input::new(&subject)), + ) + .child( + field() + .label("Patch") + .child(Textarea::new(&patch).h(px(160.))), + ), + ) + .child( + DialogFooter::new().justify_end().child( + Button::new("submit") + .primary() + .label("Create pull request") + .tooltip("Create pull request") + .on_click({ + let subject = subject.clone(); + let patch = patch.clone(); + let store = store.clone(); + + move |_event, window, cx| { + let subject = subject.read(cx).value().to_string(); + let patch = patch.read(cx).value().to_string(); + let subject = (!subject.is_empty()).then_some(subject); + + store.update(cx, |store, cx| { + store.open_pull_request(subject, patch, cx); + }); + + window.close_dialog(cx); + } + }), + ), + ) + }) + }); +} + +impl Panel for PullRequestsView { + fn panel_name(&self) -> &'static str { + "pull-requests" + } + + fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().child(SharedString::from(format!( + "{}/pull-requests", + self.repo_name + ))) + } +} + +impl EventEmitter for PullRequestsView {} + +impl Focusable for PullRequestsView { + fn focus_handle(&self, _cx: &App) -> FocusHandle { + self.focus_handle.clone() + } +} + +impl Render for PullRequestsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let filter = self.filter; + + // Indices of the root pull requests matching the active filter + // (updates are revisions of the root and are not listed + // separately); the virtual list renders this filtered slice. + self.visible_prs = { + let store = self.store.read(cx); + store + .pull_requests + .iter() + .enumerate() + .filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr)) + .map(|(ix, _)| ix) + .collect() + }; + + let count = self.visible_prs.len(); + + // The virtual list's item count comes from `item_sizes`; rebuild it + // whenever the filtered pull request count changes. + if count != self.pr_len { + self.pr_len = count; + self.item_sizes = Rc::new(vec![size(px(0.), px(PR_ROW_HEIGHT)); count]); + } + + let sizes = self.item_sizes.clone(); + let scroll_handle = self.scroll_handle.clone(); + let view = cx.entity().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( + v_virtual_list(view, "prl", sizes, move |this, range, _window, cx| { + let prs = &this.store.read(cx).pull_requests; + range + .map(|ix| { + let pr_ix = this.visible_prs[ix]; + this.render_row(pr_ix, &prs[pr_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 { + PullRequestFilter::All => "No pull requests", + PullRequestFilter::Open => "No open pull requests", + PullRequestFilter::Closed => "No closed pull requests", + PullRequestFilter::Draft => "No draft pull requests", + PullRequestFilter::Merged => "No merged pull requests", + }; + this.child(placeholder(message, cx)) + }), + ) + .into_any_element() + } +}