From d6e948474b5d47a104d3f2fb819f1af7cca6b044 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 12 Nov 2026 08:36:55 +0700 Subject: [PATCH] add pull requests view with PR and patch panels --- crates/assets/src/lib.rs | 1 - crates/signed_app/src/explore/mod.rs | 8 +- crates/signed_app/src/issues/detail.rs | 10 +- crates/signed_app/src/issues/helpers.rs | 30 +- crates/signed_app/src/issues/mod.rs | 12 +- crates/signed_app/src/lib.rs | 1 + crates/signed_app/src/pull_requests/detail.rs | 628 ++++++++++++ .../src/pull_requests/dialogs/mod.rs | 1 + .../src/pull_requests/dialogs/update.rs | 86 ++ .../signed_app/src/pull_requests/helpers.rs | 79 ++ crates/signed_app/src/pull_requests/mod.rs | 455 +++++++++ crates/signed_app/src/pull_requests/new.rs | 922 ++++++++++++++++++ .../src/pull_requests/send_patch.rs | 189 ++++ crates/signed_app/src/repo/commit_diff.rs | 11 + crates/signed_app/src/repo/helpers.rs | 2 +- crates/signed_app/src/repo/mod.rs | 151 ++- 16 files changed, 2547 insertions(+), 39 deletions(-) create mode 100644 crates/signed_app/src/pull_requests/detail.rs create mode 100644 crates/signed_app/src/pull_requests/dialogs/mod.rs create mode 100644 crates/signed_app/src/pull_requests/dialogs/update.rs create mode 100644 crates/signed_app/src/pull_requests/helpers.rs create mode 100644 crates/signed_app/src/pull_requests/mod.rs create mode 100644 crates/signed_app/src/pull_requests/new.rs create mode 100644 crates/signed_app/src/pull_requests/send_patch.rs diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index bee031e..7462a1c 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -96,4 +96,3 @@ impl IconNamed for CustomIconName { .into() } } - diff --git a/crates/signed_app/src/explore/mod.rs b/crates/signed_app/src/explore/mod.rs index 7990a97..3390a31 100644 --- a/crates/signed_app/src/explore/mod.rs +++ b/crates/signed_app/src/explore/mod.rs @@ -42,7 +42,7 @@ impl ExploreView { let store = RepoListStore::global(cx); let directory = ProfileDirectory::new(cx); - let entity = cx.entity(); + let weak = cx.entity().downgrade(); let search = InputState::new(cx); let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { @@ -56,9 +56,9 @@ impl ExploreView { }); cx.defer(move |cx| { - entity.update(cx, |this, cx| { - this.rebuild_rows(cx); - }); + if let Err(error) = weak.update(cx, |this, cx| this.rebuild_rows(cx)) { + log::warn!("failed to load explore rows: {error}"); + } }); Self { diff --git a/crates/signed_app/src/issues/detail.rs b/crates/signed_app/src/issues/detail.rs index 8274498..dc9edd9 100644 --- a/crates/signed_app/src/issues/detail.rs +++ b/crates/signed_app/src/issues/detail.rs @@ -39,17 +39,19 @@ impl IssueDetailView { }); let directory = ProfileDirectory::new(cx); + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + let _ = weak.update(cx, |this, cx| this.track_participants(cx)); + }); - let mut view = Self { + Self { store, issue_id, comment_input, directory, scroll_handle: ScrollHandle::new(), _subscription: subscription, - }; - view.track_participants(cx); - view + } } /// Track the issue author, everyone who commented and the repository owner. diff --git a/crates/signed_app/src/issues/helpers.rs b/crates/signed_app/src/issues/helpers.rs index a1992a9..57fcfad 100644 --- a/crates/signed_app/src/issues/helpers.rs +++ b/crates/signed_app/src/issues/helpers.rs @@ -18,14 +18,14 @@ use signed_state::RepoStore; use utils::relative_time; #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum IssueFilter { +pub(crate) enum IssueFilter { All, Open, Closed, } impl IssueFilter { - pub(super) fn matches(self, status: RepoStatus) -> bool { + pub(crate) fn matches(self, status: RepoStatus) -> bool { match self { Self::All => true, Self::Open => status == RepoStatus::Open, @@ -36,12 +36,12 @@ impl IssueFilter { /// Root events counted by their resolved status. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub(super) struct StatusCounts { - pub(super) total: usize, - pub(super) open: usize, - pub(super) closed: usize, - pub(super) draft: usize, - pub(super) applied: usize, +pub(crate) struct StatusCounts { + pub(crate) total: usize, + pub(crate) open: usize, + pub(crate) closed: usize, + pub(crate) draft: usize, + pub(crate) applied: usize, } impl StatusCounts { @@ -57,7 +57,7 @@ impl StatusCounts { } /// Indices of `roots` whose status `keep` accepts, counting every root's status. -pub(super) fn filter_by_status<'a>( +pub(crate) fn filter_by_status<'a>( roots: impl IntoIterator, status_of: impl Fn(&Event) -> RepoStatus, keep: impl Fn(RepoStatus) -> bool, @@ -77,11 +77,15 @@ pub(super) fn filter_by_status<'a>( (visible, counts) } -pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { +pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] { &store.issues } -pub(super) fn sidebar_section( +pub(crate) fn pr_roots(store: &RepoStore) -> &[Event] { + &store.pull_requests +} + +pub(crate) fn sidebar_section( store: &Entity, id: EventId, roots: fn(&RepoStore) -> &[Event], @@ -153,7 +157,7 @@ pub(super) fn sidebar_section( .into_any_element() } -pub(super) fn comments_section( +pub(crate) fn comments_section( store: &Entity, root: EventId, directory: &Entity, @@ -214,7 +218,7 @@ pub(super) fn comments_section( .into_any_element() } -pub(super) fn comment_form( +pub(crate) fn comment_form( store: &Entity, root: EventId, roots: fn(&RepoStore) -> &[Event], diff --git a/crates/signed_app/src/issues/mod.rs b/crates/signed_app/src/issues/mod.rs index 0be8ab8..560f0a6 100644 --- a/crates/signed_app/src/issues/mod.rs +++ b/crates/signed_app/src/issues/mod.rs @@ -27,7 +27,7 @@ use crate::shared::panels; use crate::shared::widgets::{repo_tab_avatar, status_badge}; pub mod detail; -mod helpers; +pub(crate) mod helpers; pub mod new_issue; const ISSUE_ROW_HEIGHT: f32 = 73.; @@ -58,8 +58,12 @@ impl IssuesView { let subscription = cx.observe(&store, |this, _store, cx| { this.rebuild(cx); }); + let weak = cx.entity().downgrade(); + cx.defer(move |cx| { + let _ = weak.update(cx, |this, cx| this.rebuild(cx)); + }); - let mut view = Self { + Self { dock, store, repo_name, @@ -70,9 +74,7 @@ impl IssuesView { scroll_handle: UniformListScrollHandle::new(), directory: ProfileDirectory::new(cx), _subscription: subscription, - }; - view.rebuild(cx); - view + } } fn rebuild(&mut self, cx: &mut Context) { diff --git a/crates/signed_app/src/lib.rs b/crates/signed_app/src/lib.rs index 586bea3..86005c4 100644 --- a/crates/signed_app/src/lib.rs +++ b/crates/signed_app/src/lib.rs @@ -2,6 +2,7 @@ mod app; mod explore; mod identity; mod issues; +mod pull_requests; mod repo; mod shared; mod sidebar; diff --git a/crates/signed_app/src/pull_requests/detail.rs b/crates/signed_app/src/pull_requests/detail.rs new file mode 100644 index 0000000..8a8e384 --- /dev/null +++ b/crates/signed_app/src/pull_requests/detail.rs @@ -0,0 +1,628 @@ +use std::path::PathBuf; + +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, Styled as _, Subscription, UniformListScrollHandle, + WeakEntity, Window, div, uniform_list, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::button::Button; +use gpui_native_kit::clipboard::ClipboardButton; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::data::tabs::{Tab, Tabs}; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::input::InputState; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::scroll_area::ScrollArea; +use gpui_native_kit::scrollbar::Scrollbar; +use gpui_native_kit::spinner::Spinner; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::{EventId, Kind, PublicKey, Url}; +use signed_core::{GitEvent, PullRequest, RepoAddr}; +use signed_git::{FileCommit, PatchParser}; +use signed_state::{Backend, Mirrors, RepoStore}; +use utils::relative_time; + +use super::dialogs; +use crate::issues::helpers::{comment_form, comments_section, pr_roots, sidebar_section}; +use crate::repo::commit_diff::{CommitDiffView, DiffPane}; +use crate::repo::helpers::commit_row; +use crate::shared::widgets::{empty_state, repo_tab_avatar, status_badge}; + +const NOT_FOUND: &str = "Pull request not found"; + +#[derive(Clone, PartialEq, Eq)] +struct PrBinding { + description: String, + patch: String, + tip: Option, + base: Option, + clone_urls: Vec, + addr: RepoAddr, + has_patch_link: bool, +} + +pub struct PullRequestDetailView { + dock: WeakEntity, + store: Entity, + pr_id: EventId, + comment_input: Entity, + repo_name: SharedString, + worktree: Option, + description: SharedString, + current_commit: Option, + commits: Vec, + loading: bool, + error: Option, + bound: Option, + load_generation: u64, + active_tab: usize, + pane: Entity, + commit_scroll_handle: UniformListScrollHandle, + tasks: Vec>>, + directory: Entity, + _subscription: Subscription, +} + +impl PullRequestDetailView { + pub fn new( + dock: WeakEntity, + store: Entity, + pr_id: EventId, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let pane = cx.new(DiffPane::new); + + let comment_input = InputState::new(cx); + comment_input.update(cx, |state, cx| state.set_multiline(true, cx)); + + let subscription = cx.observe(&store, |this, _store, cx| { + this.sync(cx); + }); + + let weak = cx.entity().downgrade(); + let repo_name = store.read(cx).name(); + + cx.defer(move |cx| { + let _ = weak.update(cx, |this, cx| this.sync(cx)); + }); + + Self { + dock, + store, + pr_id, + comment_input, + repo_name, + worktree: None, + description: SharedString::default(), + current_commit: None, + commits: Vec::new(), + loading: true, + error: None, + bound: None, + load_generation: 0, + active_tab: 0, + pane, + commit_scroll_handle: UniformListScrollHandle::new(), + tasks: Vec::new(), + directory: ProfileDirectory::new(cx), + _subscription: subscription, + } + } + + fn track_participants(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + let mut keys: Vec = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| vec![root.pubkey]) + .unwrap_or_default(); + keys.extend(store.comments_of(&self.pr_id).map(|comment| comment.pubkey)); + if let Some(owner) = store.announcement.as_ref().map(|a| a.owner) { + keys.push(owner); + } + self.directory + .update(cx, |directory, cx| directory.track(keys, cx)); + } + + fn sync(&mut self, cx: &mut Context) { + self.track_participants(cx); + let loaded = self.store.read(cx).loaded; + let binding = { + let store = self.store.read(cx); + store.addr().and_then(|addr| { + store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + .map(|root| { + let update = PullRequest::latest_update(store.pull_requests.iter(), root); + let tip = update + .and_then(GitEvent::current_commit) + .or_else(|| root.current_commit()); + let base = update + .and_then(GitEvent::merge_base) + .or_else(|| root.merge_base()); + let clone_urls = root + .clone_urls() + .or_else(|| store.announcement.as_ref().map(|a| a.clone.clone())) + .unwrap_or_default(); + PrBinding { + description: root.content.clone(), + patch: PullRequest::new(root).patch(store.patches.iter()), + tip, + base, + clone_urls, + addr: addr.clone(), + has_patch_link: root.tags.event_ids().next().is_some(), + } + }) + }) + }; + + let Some(binding) = binding else { + self.bound = None; + if !loaded { + self.loading = true; + self.error = None; + } else if self.error.as_deref() != Some(NOT_FOUND) { + self.loading = false; + self.error = Some(NOT_FOUND.into()); + } + cx.notify(); + return; + }; + + if self.bound.as_ref() == Some(&binding) { + return; + } + self.bound = Some(binding.clone()); + self.load_diff(binding, cx); + } + + fn load_diff(&mut self, binding: PrBinding, cx: &mut Context) { + self.loading = true; + self.error = None; + self.description = binding.description.clone().into(); + self.current_commit = binding.tip.clone().map(SharedString::from); + cx.notify(); + + self.load_generation = self.load_generation.wrapping_add(1); + let generation = self.load_generation; + let PrBinding { + patch, + tip, + base, + clone_urls, + addr, + has_patch_link, + .. + } = binding; + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let nostr_diff = cx + .background_spawn({ + let patch = patch.clone(); + async move { PatchParser::patch_diffs(&patch) } + }) + .await; + let nostr_commits = cx + .background_spawn({ + let patch = patch.clone(); + async move { PatchParser::patch_commits(&patch) } + }) + .await; + + let use_nostr = match &nostr_diff { + Ok(diff) => has_patch_link || !diff.files.is_empty(), + Err(_) => true, + }; + + let git = if use_nostr { + None + } else { + let addr = addr.clone(); + let clone_urls = clone_urls.clone(); + let base = base.clone(); + let tip = tip.clone(); + Some( + cx.background_spawn(async move { + let repo = Mirrors::ensure(&addr, &clone_urls)?; + let gix_repo = repo.inner(); + let workdir = gix_repo + .workdir() + .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))? + .to_path_buf(); + let tip = + tip.ok_or_else(|| anyhow::anyhow!("pull request has no tip commit"))?; + let base = match base { + Some(base) => base, + None => { + let head = gix_repo + .head_id() + .map_err(|_| anyhow::anyhow!("repository has no HEAD"))?; + let tip_id = gix_repo.rev_parse_single(tip.as_bytes())?; + gix_repo.merge_base(tip_id, head)?.to_string() + } + }; + let diff = repo.range_diff(&base, &tip)?; + let commits = repo.commit_range(&base, &tip)?; + Ok::<_, anyhow::Error>((diff, commits, workdir)) + }) + .await, + ) + }; + + let (diff, commits, worktree) = match git { + Some(Ok((diff, commits, worktree))) => (Ok(diff), commits, Some(worktree)), + Some(Err(error)) => (Err(error), Vec::new(), None), + None => (nostr_diff, nostr_commits, None), + }; + + this.update(cx, |this, cx| { + if this.load_generation != generation { + return; + } + this.loading = false; + this.worktree = worktree; + this.commits = commits; + match diff { + Ok(diff) => this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)), + Err(error) => this.error = Some(error.to_string().into()), + } + cx.notify(); + })?; + Ok(()) + }); + self.tasks.push(task); + } + + fn open_commit_diff( + &mut self, + worktree: PathBuf, + commit_id: &str, + window: &mut Window, + cx: &mut Context, + ) { + let id = match self.store.read(cx).addr() { + Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")), + None => SharedString::from(format!("commit:{}:{commit_id}", worktree.display())), + }; + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + let repo_name = self.repo_name.clone(); + let commit_id = commit_id.to_owned(); + crate::shared::panels::open( + &dock, + id, + move |window, cx| { + cx.new(|cx| { + CommitDiffView::new( + store.clone(), + worktree.clone(), + repo_name.clone(), + commit_id.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + let _ = dock_for_build; + } + + fn render_tabs(&self, cx: &mut Context) -> AnyElement { + let files = self.pane.read(cx).diff().map(|diff| diff.files.len()); + let commits = (!self.commits.is_empty()).then_some(self.commits.len()); + let weak = cx.entity().downgrade(); + let files_label = match files { + Some(count) => format!("Files ({count})"), + None => "Files".to_owned(), + }; + let commits_label = match commits { + Some(count) => format!("Commits ({count})"), + None => "Commits".to_owned(), + }; + + Tabs::new("pr-tabs") + .selected(self.active_tab) + .tabs(move |_, _| { + vec![ + Tab::new("discussion", "Discussion"), + Tab::new("files", files_label.clone()), + Tab::new("commits", commits_label.clone()), + ] + }) + .on_change(move |index, _, cx| { + let _ = weak.update(cx, |this, cx| { + this.active_tab = index; + cx.notify(); + }); + }) + .into_any_element() + } + + fn render_discussion(&mut self, cx: &mut Context) -> AnyElement { + if self.loading { + return centered_spinner(); + } + if let Some(error) = self.error.clone() { + return empty_state(error); + } + let (author, picture, age, root_id, pubkey) = { + let store = self.store.read(cx); + let Some(root) = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + else { + return empty_state(NOT_FOUND); + }; + let profile = self.directory.read(cx).profile(&root.pubkey, cx); + ( + profile.name(), + profile.picture(), + relative_time(root.created_at), + root.id, + root.pubkey, + ) + }; + + h_flex() + .size_full() + .min_h_0() + .child( + ScrollArea::new("pr-discussion-scroll") + .flex_1() + .min_w_0() + .child( + v_flex() + .p_4() + .gap_6() + .child( + v_flex() + .gap_4() + .child( + h_flex() + .gap_2() + .text_sm() + .child( + h_flex() + .gap_1() + .child( + Avatar::new(pubkey) + .name(author.clone()) + .picture(picture) + .small(), + ) + .child(author), + ) + .child(SharedString::from("commented")) + .child( + div() + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(age)), + ), + ) + .when(!self.description.is_empty(), |this| { + this.child(div().text_sm().child(self.description.clone())) + }), + ) + .child(comments_section(&self.store, root_id, &self.directory, cx)) + .child(comment_form( + &self.store, + root_id, + pr_roots, + &self.comment_input, + "pr-comment", + cx, + )), + ), + ) + .child(sidebar_section( + &self.store, + root_id, + pr_roots, + true, + &self.directory, + cx, + )) + .into_any_element() + } + + fn render_files(&self, _cx: &mut Context) -> AnyElement { + if self.loading { + return centered_spinner(); + } + if let Some(error) = self.error.clone() { + return empty_state(error); + } + self.pane.clone().into_any_element() + } + + fn render_commits(&self, cx: &mut Context) -> AnyElement { + if self.loading { + return centered_spinner(); + } + if let Some(error) = self.error.clone() { + return empty_state(error); + } + if self.commits.is_empty() { + return empty_state("No commits found"); + } + let commits = self.commits.clone(); + let worktree = self.worktree.clone(); + let view = cx.entity().downgrade(); + + v_flex() + .relative() + .flex_1() + .min_h_0() + .child( + uniform_list("pr-commits", commits.len(), move |range, _, cx| { + range + .map(|ix| { + let commit = commits[ix].clone(); + let id = commit.id.clone(); + let worktree = worktree.clone(); + let view = view.clone(); + commit_row( + ix, + &commit, + move |window, cx| { + if let Some(worktree) = worktree.clone() { + let _ = view.update(cx, |this, cx| { + this.open_commit_diff(worktree, &id, window, cx); + }); + } + }, + cx, + ) + }) + .collect() + }) + .track_scroll(&self.commit_scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical( + "pr-commits-scroll", + self.commit_scroll_handle.clone(), + )), + ) + .into_any_element() + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + let current_commit = self.current_commit.clone(); + let (title, status, branch, author) = { + let store = self.store.read(cx); + let Some(root) = store + .pull_requests + .iter() + .find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest) + else { + return div().into_any_element(); + }; + ( + root.activity_subject(), + store.status_of(root), + root.branch_name(), + root.pubkey, + ) + }; + + let can_update = Backend::global(cx).read(cx).current_user() == Some(author); + let weak = cx.entity().downgrade(); + + v_flex() + .flex_none() + .px_4() + .py_2() + .gap_2() + .child( + h_flex().gap_2().child(status_badge(status)).child( + div() + .flex_1() + .min_w_0() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_ellipsis() + .child(title), + ), + ) + .child( + h_flex() + .gap_3() + .when_some(branch, |this, branch| { + this.child( + Button::new("branch") + .label(branch) + .icon(Icon::named(CustomIconName::GitBranch)) + .small(), + ) + }) + .when(can_update, |this| { + this.child(Button::new("update-pr").label("Update").small().on_click( + move |_, _, cx| { + let _ = weak.update(cx, |this, cx| { + let root = this + .store + .read(cx) + .pull_requests + .iter() + .find(|pr| { + pr.id == this.pr_id && pr.kind == Kind::GitPullRequest + }) + .cloned(); + if let Some(root) = root { + dialogs::update::open(this.store.clone(), root, cx); + } + }); + }, + )) + }) + .when_some(current_commit, |this, id| { + this.child( + h_flex() + .gap_1() + .font_family(cx.theme().mono_font) + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(id.clone()) + .child(ClipboardButton::new("pr-commit", &id)), + ) + }), + ) + .into_any_element() + } +} + +impl Panel for PullRequestDetailView { + fn title(&self, _cx: &App) -> Option { + let hex = self.pr_id.to_hex(); + Some(super::helpers::pr_label(&self.repo_name, &hex[..8])) + } + + fn lead(&self, cx: &App) -> Option { + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) + } +} + +impl Render for PullRequestDetailView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("pull-request-detail") + .size_full() + .min_h_0() + .child(self.render_header(cx)) + .child(self.render_tabs(cx)) + .map(|this| match self.active_tab { + 0 => this.child(self.render_discussion(cx)), + 1 => this.child(self.render_files(cx)), + _ => this.child(self.render_commits(cx)), + }) + } +} + +fn centered_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new("pr-loading")) + .into_any_element() +} diff --git a/crates/signed_app/src/pull_requests/dialogs/mod.rs b/crates/signed_app/src/pull_requests/dialogs/mod.rs new file mode 100644 index 0000000..1724c77 --- /dev/null +++ b/crates/signed_app/src/pull_requests/dialogs/mod.rs @@ -0,0 +1 @@ +pub mod update; diff --git a/crates/signed_app/src/pull_requests/dialogs/update.rs b/crates/signed_app/src/pull_requests/dialogs/update.rs new file mode 100644 index 0000000..76b75b4 --- /dev/null +++ b/crates/signed_app/src/pull_requests/dialogs/update.rs @@ -0,0 +1,86 @@ +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, + Styled as _, Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::v_flex; +use gpui_native_kit::dialog::Dialog; +use gpui_native_kit::field::Field; +use gpui_native_kit::input::{Input, InputState}; +use gpui_native_kit::theme::ActiveTheme as _; +use nostr::prelude::Event; +use signed_state::RepoStore; + +use crate::shared::dialogs::{close_global, open_global}; + +pub fn open(store: Entity, root: Event, cx: &mut App) { + open_global(UpdatePrDialog::new(store, root, cx), cx); +} + +pub struct UpdatePrDialog { + store: Entity, + root: Event, + patch: Entity, +} + +impl UpdatePrDialog { + pub fn new(store: Entity, root: Event, cx: &mut App) -> Entity { + let patch = InputState::new(cx); + patch.update(cx, |state, cx| state.set_multiline(true, cx)); + cx.new(|_| Self { store, root, patch }) + } + + fn submit(&mut self, cx: &mut Context) { + let patch = self.patch.read(cx).value().to_string(); + if patch.trim().is_empty() { + return; + } + self.store.update(cx, |store, cx| { + store.update_pull_request(&self.root, patch, cx); + }); + close_global(cx); + } +} + +impl Render for UpdatePrDialog { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let patch = self.patch.clone(); + let this = cx.entity().downgrade(); + + Dialog::new("update-pr-dialog") + .open(true) + .min_width(gpui::px(520.)) + .on_change(|open, _, cx| { + if !open { + close_global(cx); + } + }) + .title("Update pull request") + .content(move |_, cx| { + v_flex() + .gap_3() + .child( + div() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child("Publish a new revision with the output of `git format-patch`."), + ) + .child( + Field::new("update-patch", "Patch") + .child(Input::new("update-patch-input", patch.clone()).rows(6)), + ) + .into_any_element() + }) + .actions(move |_, _| { + let this = this.clone(); + vec![ + Button::new("submit-update") + .label("Update pull request") + .on_click(move |_, _, cx| { + let _ = this.update(cx, |this, cx| this.submit(cx)); + }) + .into_any_element() as AnyElement, + ] + }) + } +} diff --git a/crates/signed_app/src/pull_requests/helpers.rs b/crates/signed_app/src/pull_requests/helpers.rs new file mode 100644 index 0000000..b867a18 --- /dev/null +++ b/crates/signed_app/src/pull_requests/helpers.rs @@ -0,0 +1,79 @@ +use gpui::SharedString; +use signed_core::RepoStatus; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PullRequestFilter { + All, + Open, + Closed, + Draft, + Merged, +} + +impl PullRequestFilter { + pub fn matches(self, status: RepoStatus) -> bool { + match self { + Self::All => true, + Self::Open => status == RepoStatus::Open, + Self::Closed => status == RepoStatus::Closed, + Self::Draft => status == RepoStatus::Draft, + Self::Merged => status == RepoStatus::Applied, + } + } + + pub fn empty_message(self) -> &'static str { + match self { + Self::All => "No pull requests", + Self::Open => "No open pull requests", + Self::Closed => "No closed pull requests", + Self::Draft => "No draft pull requests", + Self::Merged => "No merged pull requests", + } + } +} + +pub fn pr_label(repo_name: &SharedString, pr_id: &str) -> SharedString { + if repo_name.is_empty() { + SharedString::from(pr_id) + } else { + SharedString::from(format!("{repo_name}/{pr_id}")) + } +} + +use std::path::PathBuf; + +use nostr::prelude::PublicKey; +use signed_core::Announcement; +use utils::middle_truncate; + +pub struct ForkCompare { + pub announcement: Announcement, + pub namespace: String, + pub mirror_path: PathBuf, +} + +impl ForkCompare { + pub fn base_ref(name: &str) -> String { + format!("refs/remotes/origin/{name}") + } + + pub fn compare_ref(&self, name: &str) -> String { + format!("refs/fork/{}/{}", self.namespace, name) + } +} + +pub fn fork_display_name(announcement: &Announcement) -> SharedString { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) +} + +pub fn shorten_owner(owner: &PublicKey) -> String { + owner.to_hex().chars().take(10).collect() +} + +pub fn truncate_label(label: &str) -> SharedString { + SharedString::from(middle_truncate(label, 18, 0)) +} diff --git a/crates/signed_app/src/pull_requests/mod.rs b/crates/signed_app/src/pull_requests/mod.rs new file mode 100644 index 0000000..1f79e96 --- /dev/null +++ b/crates/signed_app/src/pull_requests/mod.rs @@ -0,0 +1,455 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement as _, Styled as _, + Subscription, UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, +}; +use gpui_native_kit::alert::{Alert, AlertSeverity}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::dropdown_menu::DropdownMenu; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::menu::MenuItem; +use gpui_native_kit::scrollbar::Scrollbar; +use gpui_native_kit::segmented::{Segmented, SegmentedOption}; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::{Kind, PublicKey}; +use signed_core::GitEvent; +use signed_state::RepoStore; +use utils::relative_time; + +mod detail; +mod dialogs; +mod helpers; +mod new; +mod send_patch; + +pub use detail::PullRequestDetailView; +use helpers::PullRequestFilter; +pub use new::NewPullRequestView; +pub use send_patch::SendPatchView; + +use crate::issues::helpers::{StatusCounts, filter_by_status}; +use crate::shared::widgets::{empty_state, repo_tab_avatar, status_badge}; + +const ROW_HEIGHT: f32 = 73.; + +pub struct PullRequestsView { + dock: WeakEntity, + store: Entity, + repo_name: SharedString, + filter: PullRequestFilter, + visible_prs: Vec, + counts: StatusCounts, + synced_filter: PullRequestFilter, + scroll_handle: UniformListScrollHandle, + directory: Entity, + new_pr_menu_open: bool, + _subscription: Subscription, +} + +impl PullRequestsView { + pub fn new( + dock: WeakEntity, + store: Entity, + cx: &mut Context, + ) -> Self { + let weak = cx.entity().downgrade(); + let repo_name = store.read(cx).name(); + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild(cx); + }); + + cx.defer(move |cx| { + let _ = weak.update(cx, |this, cx| { + this.rebuild(cx); + }); + }); + + Self { + dock, + store, + repo_name, + filter: PullRequestFilter::Open, + visible_prs: Vec::new(), + counts: StatusCounts::default(), + synced_filter: PullRequestFilter::Open, + scroll_handle: UniformListScrollHandle::new(), + directory: ProfileDirectory::new(cx), + new_pr_menu_open: false, + _subscription: subscription, + } + } + + fn rebuild(&mut self, cx: &mut Context) { + let store = self.store.read(cx); + let filter = self.filter; + + let (visible_prs, counts) = { + let roots = store + .pull_requests + .iter() + .filter(|pr| pr.kind == Kind::GitPullRequest); + + filter_by_status( + roots, + |pr| store.status_of(pr), + |status| filter.matches(status), + ) + }; + + let mut keys: Vec = store + .pull_requests + .iter() + .filter(|pr| pr.kind == Kind::GitPullRequest) + .map(|pr| pr.pubkey) + .collect(); + + if let Some(owner) = store + .announcement + .as_ref() + .map(|announcement| announcement.owner) + { + keys.push(owner); + } + + self.directory.update(cx, |directory, cx| { + directory.track(keys, cx); + }); + + let filter_changed = self.synced_filter != filter; + if !filter_changed && self.visible_prs == visible_prs && self.counts == counts { + return; + } + self.synced_filter = filter; + self.visible_prs = visible_prs; + self.counts = counts; + cx.notify(); + } + + fn open_new(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("new-pr:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + NewPullRequestView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("send-patch:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + SendPatchView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn render_header(&self, cx: &mut Context) -> AnyElement { + let counts = self.counts; + let muted = cx.theme().colors.muted_foreground; + let weak = cx.entity().downgrade(); + let open_weak = cx.entity().downgrade(); + let menu_open_weak = cx.entity().downgrade(); + let patch_weak = cx.entity().downgrade(); + + h_flex() + .px_4() + .w_full() + .gap_3() + .border_b_1() + .border_color(cx.theme().colors.border) + .bg(cx.theme().colors.muted) + .child( + Segmented::new("pr-filter", self.filter) + .options(vec![ + SegmentedOption::new( + PullRequestFilter::All, + format!("All ({})", counts.total), + ) + .icon(Icon::named(CustomIconName::GitPullRequest).color(muted)), + SegmentedOption::new( + PullRequestFilter::Open, + format!("Open ({})", counts.open), + ) + .icon(Icon::named(CustomIconName::GitPullRequest).color(muted)), + SegmentedOption::new( + PullRequestFilter::Closed, + format!("Closed ({})", counts.closed), + ) + .icon(Icon::named(CustomIconName::GitPullRequestClosed).color(muted)), + SegmentedOption::new( + PullRequestFilter::Draft, + format!("Draft ({})", counts.draft), + ) + .icon(Icon::named(CustomIconName::GitPullRequestDraft).color(muted)), + SegmentedOption::new( + PullRequestFilter::Merged, + format!("Merged ({})", counts.applied), + ) + .icon(Icon::named(CustomIconName::GitPullRequestMerged).color(muted)), + ]) + .on_change(move |filter, _, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.filter = filter; + this.rebuild(cx); + }) { + log::warn!("failed to switch PR filter: {error}"); + } + }), + ) + .child(div().flex_1()) + .child( + DropdownMenu::new("new-pr-actions") + .label("New") + .icon(Icon::named(CustomIconName::GitPullRequest)) + .open(self.new_pr_menu_open) + .on_click(move |_, window, cx| { + if let Err(error) = + open_weak.update(cx, |this, cx| this.open_new(window, cx)) + { + log::warn!("failed to open new PR: {error}"); + } + }) + .on_open_change(move |open, _, cx| { + let _ = menu_open_weak.update(cx, |this, cx| { + this.new_pr_menu_open = open; + cx.notify(); + }); + }) + .items(move |_, _| { + vec![MenuItem::new("Send Patch").handler({ + let weak = patch_weak.clone(); + move |window, cx| { + let _ = weak.update(cx, |this, cx| { + this.open_send_patch(window, cx); + }); + } + })] + }), + ) + .into_any_element() + } +} + +impl Panel for PullRequestsView { + fn title(&self, _cx: &App) -> Option { + Some(SharedString::from(format!("{}/pulls", self.repo_name))) + } + + fn lead(&self, cx: &App) -> Option { + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) + } +} + +impl Render for PullRequestsView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let filter = self.filter; + let count = self.visible_prs.len(); + let visible = self.visible_prs.clone(); + let directory = self.directory.clone(); + let store = self.store.clone(); + let dock = self.dock.clone(); + + let (last_error, last_warning) = { + let store = self.store.read(cx); + (store.last_error.clone(), store.last_warning.clone()) + }; + + let dismiss_warning_store = self.store.clone(); + let dismiss_error_store = self.store.clone(); + + v_flex() + .size_full() + .child(self.render_header(cx)) + .when_some(last_warning, |this, warning| { + this.child( + Alert::new("pr-warning") + .severity(AlertSeverity::Warning) + .title("Pull request warning") + .message(warning) + .on_dismiss(move |cx| { + dismiss_warning_store.update(cx, |store, _| store.last_warning = None); + }), + ) + }) + .when_some(last_error, |this, error| { + this.child( + Alert::new("pr-error") + .severity(AlertSeverity::Danger) + .title("Pull request error") + .message(error) + .on_dismiss(move |cx| { + dismiss_error_store.update(cx, |store, _| store.last_error = None); + }), + ) + }) + .child( + v_flex() + .relative() + .flex_1() + .min_h_0() + .w_full() + .when(count > 0, |this| { + this.child( + uniform_list("prl", count, move |range, _, cx| { + range + .map(|ix| { + let pr_ix = visible[ix]; + let pr = &store.read(cx).pull_requests[pr_ix]; + let pr_id = pr.id; + let title = pr.activity_subject(); + let id_hex = pr.id.to_hex(); + let age = relative_time(pr.created_at); + let status = store.read(cx).status_of(pr); + let profile = directory.read(cx).profile(&pr.pubkey, cx); + let author = profile.name(); + let dock = dock.clone(); + let store = store.clone(); + + h_flex() + .id(ix) + .w_full() + .h(px(ROW_HEIGHT)) + .gap_4() + .px_4() + .border_b_1() + .border_color(cx.theme().colors.border) + .hover(|this| this.bg(cx.theme().colors.muted_hover)) + .on_click(move |_, window, cx| { + let id = SharedString::from(format!("pr:{pr_id}")); + let dock_for_build = dock.clone(); + let store = store.clone(); + crate::shared::panels::open( + &dock, + id, + move |window, cx| { + cx.new(|cx| { + PullRequestDetailView::new( + dock_for_build.clone(), + store.clone(), + pr_id, + window, + cx, + ) + }) + }, + window, + cx, + ); + }) + .child(status_badge(status)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .child( + div() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(title), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .child( + h_flex() + .gap_1() + .child( + Avatar::new(pr.pubkey) + .name(author.clone()) + .picture( + profile.picture(), + ) + .small(), + ) + .child(div().child(author)), + ) + .child(SharedString::from("opened")) + .child( + div() + .text_color( + cx.theme() + .colors + .muted_foreground, + ) + .child(SharedString::from( + &id_hex[..8], + )), + ) + .child(SharedString::from(age)), + ), + ) + .into_any_element() + }) + .collect() + }) + .track_scroll(&self.scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical( + "prl-scroll", + self.scroll_handle.clone(), + )), + ) + }) + .when(count == 0, |this| { + this.child(empty_state(filter.empty_message())) + }), + ) + .into_any_element() + } +} diff --git a/crates/signed_app/src/pull_requests/new.rs b/crates/signed_app/src/pull_requests/new.rs new file mode 100644 index 0000000..ffa5667 --- /dev/null +++ b/crates/signed_app/src/pull_requests/new.rs @@ -0,0 +1,922 @@ +use std::path::{Path, PathBuf}; + +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, Subscription, + UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::combobox::{Combobox, ComboboxState}; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::data::tabs::{Tab, Tabs}; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::input::{Input, InputState}; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::menu::{Menu, MenuItem}; +use gpui_native_kit::scrollbar::Scrollbar; +use gpui_native_kit::spinner::Spinner; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::Url; +use signed_core::Announcement; +use signed_git::{GitCache, Repo}; +use signed_state::{Backend, CheckoutsStore, Mirrors, RepoListStore, RepoStore}; + +use super::helpers::{ForkCompare, fork_display_name, shorten_owner, truncate_label}; +use crate::repo::commit_diff::{CommitDiffView, DiffPane}; +use crate::repo::helpers::commit_row; +use crate::shared::widgets::empty_state; + +pub struct NewPullRequestView { + dock: WeakEntity, + panel_id: SharedString, + store: Entity, + repo_name: SharedString, + repo_path: Option, + branches: Vec, + fork: Option, + base: SharedString, + compare: SharedString, + base_select: Entity>, + compare_select: Entity>, + subject: Entity, + description: Entity, + merge_base: Option, + commits: Option>, + loading: bool, + error: Option, + submitting: bool, + compare_generation: u64, + active_tab: usize, + pane: Entity, + scroll_handle: UniformListScrollHandle, + tasks: Vec>>, + directory: Entity, + source_open: bool, + _subscriptions: Vec, +} + +impl NewPullRequestView { + pub fn new( + dock: WeakEntity, + panel_id: SharedString, + store: Entity, + window: &mut Window, + cx: &mut Context, + ) -> Self { + let directory = ProfileDirectory::new(cx); + let repo_name = store.read(cx).name(); + let pane = cx.new(DiffPane::new); + + let weak = cx.entity().downgrade(); + let subject = InputState::new(cx); + + let description = InputState::new(cx); + description.update(cx, |state, cx| state.set_multiline(true, cx)); + + let base_select = cx.new(|cx| { + ComboboxState::new( + Combobox::new("pr-base", "Base", Vec::new()).on_change({ + let weak = weak.clone(); + move |value, _window, cx| { + if let Some(base) = value { + let _ = weak.update(cx, |this, cx| { + this.base = base; + this.reload_compare(cx); + }); + } + } + }), + window, + cx, + ) + }); + + let compare_select = cx.new(|cx| { + ComboboxState::new( + Combobox::new("pr-compare", "Compare", Vec::new()).on_change({ + let weak = weak.clone(); + move |value, _window, cx| { + if let Some(compare) = value { + let _ = weak.update(cx, |this, cx| { + this.compare = compare; + this.reload_compare(cx); + }); + } + } + }), + window, + cx, + ) + }); + + if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { + directory.update(cx, |directory, cx| directory.track([owner], cx)); + } + + window.defer(cx, move |window, cx| { + let _ = weak.update(cx, |this, cx| { + let Some(addr) = this.store.read(cx).addr().cloned() else { + return; + }; + + let Some(path) = CheckoutsStore::global(cx) + .read(cx) + .associations_of(&addr) + .into_iter() + .next() + else { + return; + }; + + this.apply_folder_path(path, window, cx); + }); + }); + + Self { + dock, + panel_id, + store, + repo_name, + repo_path: None, + branches: Vec::new(), + fork: None, + base: SharedString::default(), + compare: SharedString::default(), + base_select, + compare_select, + subject, + description, + merge_base: None, + commits: None, + loading: false, + error: None, + submitting: false, + compare_generation: 0, + active_tab: 0, + pane, + scroll_handle: UniformListScrollHandle::new(), + tasks: Vec::new(), + directory, + source_open: false, + _subscriptions: Vec::new(), + } + } + + fn has_source(&self) -> bool { + self.repo_path.is_some() || self.fork.is_some() + } + + fn work_path(&self) -> Option { + match &self.fork { + Some(fork) => Some(fork.mirror_path.clone()), + None => self.repo_path.clone(), + } + } + + fn base_ref(&self) -> String { + match &self.fork { + Some(_) => ForkCompare::base_ref(&self.base), + None => self.base.to_string(), + } + } + + fn compare_ref(&self) -> String { + match &self.fork { + Some(fork) => fork.compare_ref(&self.compare), + None => self.compare.to_string(), + } + } + + fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context) { + let prompt = cx.prompt_for_paths(PathPromptOptions { + files: false, + directories: true, + multiple: false, + prompt: Some("Choose local checkout".into()), + }); + let task: gpui::Task> = + cx.spawn_in(window, async move |this, cx| { + let picked = match prompt.await { + Ok(Ok(Some(mut paths))) => paths.pop(), + _ => None, + }; + let Some(path) = picked else { + return Ok(()); + }; + this.update_in(cx, |this, window, cx| { + this.apply_folder_path(path, window, cx) + })?; + Ok(()) + }); + self.tasks.push(task); + } + + fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { + let path_string = path.to_string_lossy().to_string(); + let info = Repo::try_open(&path).and_then(|repo| { + let branches = repo.branches().ok()?; + let current = repo.current_branch(); + Some((branches, current)) + }); + self.apply_checkout(path_string, info, window, cx); + } + + fn apply_checkout( + &mut self, + path: String, + info: Option<(Vec, Option)>, + window: &mut Window, + cx: &mut Context, + ) { + self.fork = None; + let Some((branches, current)) = info else { + self.error = Some("The chosen folder is not a git repository".into()); + self.repo_path = None; + self.branches.clear(); + self.merge_base = None; + self.commits = None; + self.pane.update(cx, |pane, cx| pane.clear(cx)); + cx.notify(); + return; + }; + if branches.is_empty() { + self.error = Some("The repository has no branches yet".into()); + self.repo_path = None; + self.branches.clear(); + cx.notify(); + return; + } + + let announced = self.store.read(cx).head.clone(); + let base = announced + .as_ref() + .filter(|branch| branches.contains(branch)) + .cloned() + .or_else(|| branches.iter().find(|branch| *branch == "main").cloned()) + .unwrap_or_else(|| branches[0].clone()); + let compare = current + .filter(|branch| branches.contains(branch)) + .unwrap_or_else(|| base.clone()); + + self.repo_path = Some(PathBuf::from(&path)); + self.error = None; + self.branches = branches.into_iter().map(SharedString::from).collect(); + + if let Some(addr) = self.store.read(cx).addr().cloned() { + CheckoutsStore::global(cx).update(cx, |store, cx| { + store.record(PathBuf::from(&path), addr, cx); + }); + } + + self.base = SharedString::from(base); + self.compare = SharedString::from(compare); + self.sync_selectors(window, cx); + self.reload_compare(cx); + } + + fn sync_selectors(&mut self, window: &mut Window, cx: &mut Context) { + let options: Vec<(SharedString, SharedString)> = self + .branches + .iter() + .map(|branch| (branch.clone(), branch.clone())) + .collect(); + let base = self.base.clone(); + let compare = self.compare.clone(); + + self.base_select.update(cx, |state, cx| { + state.set_options(options.clone(), cx); + state.set_selected(Some(base), window, cx); + }); + self.compare_select.update(cx, |state, cx| { + state.set_options(options, cx); + state.set_selected(Some(compare), window, cx); + }); + } + + fn fork_candidates(&self, cx: &App) -> Vec { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return Vec::new(); + }; + let euc = self + .store + .read(cx) + .announcement + .as_ref() + .and_then(|a| a.euc.clone()); + let user = Backend::global(cx).read(cx).current_user(); + let announcements = RepoListStore::global(cx).read(cx).announcements.clone(); + Announcement::forks_in(&announcements, &addr, euc.as_deref(), user) + .into_iter() + .cloned() + .collect() + } + + fn choose_fork( + &mut self, + announcement: Announcement, + window: &mut Window, + cx: &mut Context, + ) { + let Some(base) = self.store.read(cx).addr().cloned() else { + return; + }; + let mirror_path = Mirrors::path(&base); + let namespace = GitCache::fork_namespace(&announcement); + let clone_urls: Vec = announcement.clone.clone(); + let base_clone_urls: Vec = self + .store + .read(cx) + .announcement + .as_ref() + .map(|a| a.clone.clone()) + .unwrap_or_default(); + let expected = self.fork.as_ref().map(|fork| fork.announcement.addr()); + let handle = window.window_handle(); + + self.loading = true; + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn({ + let namespace = namespace.clone(); + let mirror_path = mirror_path.clone(); + async move { + Mirrors::ensure(&base, &base_clone_urls)?; + let repo = Repo::open(&mirror_path)?; + repo.delete_refs_with_prefix("refs/fork")?; + repo.fetch_refs( + &clone_urls, + &format!("+refs/heads/*:refs/fork/{namespace}/*"), + )?; + let strip = |refs: Vec, prefix: &str| { + let mut names: Vec = refs + .into_iter() + .filter_map(|name| { + name.strip_prefix(prefix) + .map(|rest| rest.trim_start_matches('/').to_owned()) + }) + .filter(|name| !name.is_empty()) + .collect(); + names.sort(); + names + }; + let base_branches = strip( + repo.refs_with_prefix("refs/remotes/origin")?, + "refs/remotes/origin", + ); + let compare_branches = strip( + repo.refs_with_prefix(&format!("refs/fork/{namespace}"))?, + &format!("refs/fork/{namespace}"), + ); + Ok::<_, anyhow::Error>((base_branches, compare_branches)) + } + }) + .await; + + if let Err(error) = cx.update_window(handle, |_, window, cx| { + this.update(cx, |this, cx| { + let applied = this.fork.as_ref().map(|fork| fork.announcement.addr()); + if applied != expected { + this.loading = false; + cx.notify(); + return; + } + this.apply_fork(announcement, mirror_path, namespace, result, window, cx); + }) + }) { + log::warn!("failed to apply fork: {error}"); + } + Ok(()) + }); + self.tasks.push(task); + } + + fn apply_fork( + &mut self, + announcement: Announcement, + mirror_path: PathBuf, + namespace: String, + result: Result<(Vec, Vec), anyhow::Error>, + window: &mut Window, + cx: &mut Context, + ) { + self.loading = false; + let (base_branches, compare_branches) = match result { + Ok(branches) => branches, + Err(error) => { + self.error = Some(format!("Could not compare against the fork: {error}").into()); + cx.notify(); + return; + } + }; + if compare_branches.is_empty() { + self.error = Some("The fork has no branches to compare".into()); + cx.notify(); + return; + } + if base_branches.is_empty() { + self.error = Some("Could not list the target repository's branches.".into()); + cx.notify(); + return; + } + + let announced = self.store.read(cx).head.clone(); + let base = announced + .filter(|branch| base_branches.contains(branch)) + .map(SharedString::from) + .or_else(|| { + base_branches + .iter() + .find(|branch| branch.as_str() == "main") + .map(|branch| SharedString::from(branch.clone())) + }) + .unwrap_or_else(|| SharedString::from(base_branches[0].clone())); + let compare = compare_branches + .iter() + .find(|branch| branch.as_str() == "main") + .map(|branch| SharedString::from(branch.clone())) + .unwrap_or_else(|| SharedString::from(compare_branches[0].clone())); + + self.fork = Some(ForkCompare { + announcement, + namespace, + mirror_path, + }); + self.error = None; + self.base = base; + self.compare = compare; + self.branches = base_branches.into_iter().map(SharedString::from).collect(); + self.sync_selectors(window, cx); + self.reload_compare(cx); + } + + fn reload_compare(&mut self, cx: &mut Context) { + let Some(repo_path) = self.work_path() else { + return; + }; + let base = self.base_ref(); + let compare = self.compare_ref(); + let base_name = self.base.to_string(); + let compare_name = self.compare.to_string(); + + self.loading = true; + self.error = None; + self.compare_generation += 1; + let generation = self.compare_generation; + cx.notify(); + + if base == compare { + self.loading = false; + self.merge_base = None; + self.commits = None; + self.pane.update(cx, |pane, cx| pane.clear(cx)); + self.error = Some("Choose different base and compare branches".into()); + cx.notify(); + return; + } + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let result = cx + .background_spawn({ + async move { + let repo = Repo::open(Path::new(&repo_path))?; + let merge_base = repo.merge_base(&base, &compare)?.ok_or_else(|| { + anyhow::anyhow!( + "{base_name} and {compare_name} share no common ancestor" + ) + })?; + let commits = repo.commit_range(&merge_base, &compare)?; + let diff = repo.range_diff(&merge_base, &compare)?; + Ok::<_, anyhow::Error>((merge_base, commits, diff)) + } + }) + .await; + + this.update(cx, |this, cx| { + if generation != this.compare_generation { + return; + } + this.loading = false; + match result { + Ok((merge_base, commits, diff)) => { + this.merge_base = Some(merge_base); + this.commits = Some(commits); + this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx)); + } + Err(error) => { + this.merge_base = None; + this.commits = None; + this.pane.update(cx, |pane, cx| pane.clear(cx)); + this.error = Some(error.to_string().into()); + } + } + cx.notify(); + })?; + Ok(()) + }); + self.tasks.push(task); + } + + fn submit(&mut self, _window: &mut Window, cx: &mut Context) { + if self.submitting || self.loading { + return; + } + let Some(merge_base) = self.merge_base.clone() else { + return; + }; + let Some(repo_path) = self.work_path() else { + return; + }; + let subject = self.subject.read(cx).value().to_string(); + let description = self.description.read(cx).value().to_string(); + let branch_name = self.compare.to_string(); + let compare_ref = self.compare_ref(); + let store = self.store.clone(); + let dock = self.dock.clone(); + let panel_id = self.panel_id.clone(); + + self.submitting = true; + self.error = None; + cx.notify(); + + let task: gpui::Task> = cx.spawn(async move |this, cx| { + let publish = store.update(cx, |store, cx| { + store.open_pull_request_from_refs( + repo_path, + merge_base, + compare_ref, + (!subject.is_empty()).then_some(subject), + description, + Some(branch_name), + false, + cx, + ) + }); + if let Err(error) = publish.await { + this.update(cx, |this, cx| { + this.submitting = false; + this.error = Some(error.to_string().into()); + cx.notify(); + })?; + return Ok(()); + } + this.update(cx, |this, _cx| { + this.submitting = false; + })?; + let _ = dock.update(cx, |dock, cx| dock.close_panel(&panel_id, cx)); + Ok(()) + }); + self.tasks.push(task); + } + + fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context) { + let Some(repo_path) = self.work_path() else { + return; + }; + let id = match self.store.read(cx).addr() { + Some(addr) => SharedString::from(format!("commit:{addr}:{commit_id}")), + None => SharedString::from(format!("commit:{}:{commit_id}", repo_path.display())), + }; + let dock = self.dock.clone(); + let store = self.store.clone(); + let repo_name = self.repo_name.clone(); + let commit_id = commit_id.to_owned(); + crate::shared::panels::open( + &dock, + id, + move |window, cx| { + cx.new(|cx| { + CommitDiffView::new( + store.clone(), + repo_path.clone(), + repo_name.clone(), + commit_id.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn submit_enabled(&self, cx: &App) -> bool { + self.has_source() + && !self.loading + && !self.submitting + && self.merge_base.is_some() + && self + .commits + .as_ref() + .is_some_and(|commits| !commits.is_empty()) + && !self.subject.read(cx).value().is_empty() + } + + fn render_compare_bar(&self, cx: &mut Context) -> AnyElement { + let weak = cx.entity().downgrade(); + let weak_menu = cx.entity().downgrade(); + let source_items = self.source_menu_items(cx, cx.entity().downgrade()); + let can_submit = self.submit_enabled(cx); + let source_label = match &self.fork { + Some(fork) => truncate_label(&fork_display_name(&fork.announcement)), + None => self.repo_path.as_ref().map_or_else( + || SharedString::from("No source"), + |path| truncate_label(&path.display().to_string()), + ), + }; + let refresh_fork = self.fork.as_ref().map(|fork| fork.announcement.clone()); + + h_flex() + .px_4() + .py_2() + .w_full() + .gap_3() + .items_end() + .child( + v_flex() + .gap_1() + .child(label("Merge Into")) + .child(div().w(px(160.)).child(self.base_select.clone())), + ) + .child( + v_flex() + .gap_1() + .child(label("Pull From")) + .child(div().w(px(160.)).child(self.compare_select.clone())), + ) + .child( + v_flex().gap_1().child(label("Source")).child( + Menu::new("pr-source") + .open(self.source_open) + .on_change(move |open, _, cx| { + let _ = weak_menu.update(cx, |this, cx| { + this.source_open = open; + cx.notify(); + }); + }) + .trigger(move |_, _| { + Button::new("compare-source") + .label(source_label.clone()) + .variant(gpui_native_kit::button::ButtonVariant::Secondary) + .into_any_element() + }) + .items(move |_, _| source_items.clone()), + ), + ) + .when_some(refresh_fork, |this, fork| { + this.child( + Button::new("refresh-fork") + .icon(Icon::named(CustomIconName::Refresh)) + .on_click(cx.listener(move |this, _, window, cx| { + this.choose_fork(fork.clone(), window, cx); + })), + ) + }) + .child(div().flex_1()) + .child( + Button::new("create-pr") + .label("Create pull request") + .icon(Icon::named(IconRole::Plus)) + .loading(self.submitting) + .disabled(!can_submit) + .on_click(move |_, window, cx| { + let _ = weak.update(cx, |this, cx| this.submit(window, cx)); + }), + ) + .into_any_element() + } + + fn source_menu_items(&self, cx: &App, view: WeakEntity) -> Vec { + let associated = self + .store + .read(cx) + .addr() + .map(|addr| CheckoutsStore::global(cx).read(cx).associations_of(addr)) + .unwrap_or_default(); + let active_path = (self.fork.is_none()) + .then(|| self.repo_path.clone()) + .flatten(); + let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr()); + + let mut items = Vec::new(); + for path in associated { + let label = path + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()); + let view = view.clone(); + items.push( + MenuItem::new(truncate_label(&label)) + .checked(active_path.as_ref() == Some(&path)) + .handler(move |window, cx| { + let path = path.clone(); + let _ = + view.update(cx, |this, cx| this.apply_folder_path(path, window, cx)); + }), + ); + } + let folder_view = view.clone(); + items.push( + MenuItem::new("Choose another folder…").handler(move |window, cx| { + let _ = folder_view.update(cx, |this, cx| this.choose_checkout(window, cx)); + }), + ); + items.push(MenuItem::separator()); + + let candidates = self.fork_candidates(cx); + if candidates.is_empty() { + items.push(MenuItem::new("No announced forks of this repository").disabled(true)); + } else { + let user = Backend::global(cx).read(cx).current_user(); + for candidate in candidates { + let subtitle = if Some(candidate.owner) == user { + SharedString::from("Your fork") + } else { + SharedString::from(format!("by {}", shorten_owner(&candidate.owner))) + }; + let title = format!("{} — {}", fork_display_name(&candidate), subtitle); + let view = view.clone(); + items.push( + MenuItem::new(truncate_label(&title)) + .checked(active_fork == Some(candidate.addr())) + .handler(move |window, cx| { + let announcement = candidate.clone(); + let _ = view + .update(cx, |this, cx| this.choose_fork(announcement, window, cx)); + }), + ); + } + } + items + } + + fn render_tabs(&self, cx: &mut Context) -> AnyElement { + let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len()); + let commits = self.commits.as_ref().map_or(0, |commits| commits.len()); + let weak = cx.entity().downgrade(); + + Tabs::new("new-pr-tabs") + .selected(self.active_tab) + .tabs(move |_, _| { + vec![ + Tab::new("files", format!("Files ({files})")) + .lead(Icon::named(CustomIconName::GitFile)), + Tab::new("commits", format!("Commits ({commits})")) + .lead(Icon::named(CustomIconName::GitCommit)), + ] + }) + .on_change(move |index, _, cx| { + let _ = weak.update(cx, |this, cx| { + this.active_tab = index; + cx.notify(); + }); + }) + .into_any_element() + } + + fn render_content(&self, cx: &mut Context) -> AnyElement { + if self.loading { + return v_flex() + .size_full() + .items_center() + .justify_center() + .child(Spinner::new("new-pr-loading")) + .into_any_element(); + } + if !self.has_source() { + return empty_state("Choose a local checkout or an announced fork to compare"); + } + if self.commits.is_none() && self.error.is_some() { + return empty_state("Nothing to compare"); + } + match self.active_tab { + 0 => self.pane.clone().into_any_element(), + _ => self.render_commits(cx), + } + } + + fn render_commits(&self, cx: &mut Context) -> AnyElement { + let Some(commits) = self.commits.as_ref() else { + return empty_state("No commits"); + }; + if commits.is_empty() { + return empty_state("No commits between the branches"); + } + let commits = commits.clone(); + let view = cx.entity().downgrade(); + + v_flex() + .relative() + .flex_1() + .min_h_0() + .child( + uniform_list("new-pr-commits", commits.len(), move |range, _, cx| { + range + .map(|ix| { + let commit = commits[ix].clone(); + let id = commit.id.clone(); + let view = view.clone(); + commit_row( + ix, + &commit, + move |window, cx| { + let _ = view.update(cx, |this, cx| { + this.open_commit_diff(&id, window, cx); + }); + }, + cx, + ) + }) + .collect() + }) + .track_scroll(&self.scroll_handle) + .size_full(), + ) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical( + "new-pr-commits-scroll", + self.scroll_handle.clone(), + )), + ) + .into_any_element() + } +} + +impl Panel for NewPullRequestView { + fn title(&self, _cx: &gpui::App) -> Option { + Some(SharedString::from(format!( + "{}/new-pull-request", + self.repo_name + ))) + } + + fn lead(&self, cx: &gpui::App) -> Option { + Some(crate::shared::widgets::repo_tab_avatar( + self.store.read(cx), + &self.directory, + cx, + )) + } +} + +impl Render for NewPullRequestView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("new-pr") + .size_full() + .child( + v_flex() + .gap_3() + .child(self.render_compare_bar(cx)) + .child( + v_flex() + .px_4() + .gap_2() + .child( + Input::new("pr-title", self.subject.clone()).placeholder("Title"), + ) + .child( + Input::new("pr-description", self.description.clone()) + .rows(3) + .placeholder("Describe..."), + ) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .text_xs() + .text_color(cx.theme().ramps.destructive.base) + .child(error), + ) + }), + ) + .child(self.render_tabs(cx)), + ) + .child( + v_flex() + .flex_1() + .min_h_0() + .w_full() + .child(self.render_content(cx)), + ) + } +} + +fn label(text: &'static str) -> AnyElement { + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(text) + .into_any_element() +} diff --git a/crates/signed_app/src/pull_requests/send_patch.rs b/crates/signed_app/src/pull_requests/send_patch.rs new file mode 100644 index 0000000..79d6319 --- /dev/null +++ b/crates/signed_app/src/pull_requests/send_patch.rs @@ -0,0 +1,189 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + Render, SharedString, Styled as _, WeakEntity, Window, div, +}; +use gpui_native_kit::button::Button; +use gpui_native_kit::common::{h_flex, v_flex}; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::input::{Input, InputState}; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use signed_state::RepoStore; + +use crate::shared::widgets::repo_tab_avatar; + +pub struct SendPatchView { + dock: WeakEntity, + panel_id: SharedString, + store: Entity, + repo_name: SharedString, + subject: Entity, + description: Entity, + patch: Entity, + submitting: bool, + error: Option, + directory: Entity, +} + +impl SendPatchView { + pub fn new( + dock: WeakEntity, + panel_id: SharedString, + store: Entity, + _window: &mut Window, + cx: &mut Context, + ) -> Self { + let repo_name = store.read(cx).name(); + let directory = ProfileDirectory::new(cx); + + if let Some(owner) = store.read(cx).announcement.as_ref().map(|a| a.owner) { + directory.update(cx, |directory, cx| directory.track([owner], cx)); + } + + let subject = InputState::new(cx); + + let description = InputState::new(cx); + description.update(cx, |state, cx| state.set_multiline(true, cx)); + + let patch = InputState::new(cx); + patch.update(cx, |state, cx| state.set_multiline(true, cx)); + + Self { + dock, + panel_id, + store, + repo_name, + subject, + description, + patch, + submitting: false, + error: None, + directory, + } + } + + fn submit(&mut self, cx: &mut Context) { + if self.submitting { + return; + } + + let subject = self.subject.read(cx).value().to_string(); + let description = self.description.read(cx).value().to_string(); + + let patch = self.patch.read(cx).value().to_string(); + if patch.is_empty() { + return; + } + + self.submitting = true; + self.error = None; + cx.notify(); + + let sync_error = self.store.update(cx, |store, cx| { + store.open_pull_request( + (!subject.is_empty()).then_some(subject), + description, + None, + patch, + false, + None, + None, + cx, + ); + store.last_error.clone() + }); + + if let Some(error) = sync_error { + self.submitting = false; + self.error = Some(error.into()); + cx.notify(); + return; + } + + let panel_id = self.panel_id.clone(); + let _ = self + .dock + .update(cx, |dock, cx| dock.close_panel(&panel_id, cx)); + } + + fn render_footer(&self, cx: &Context) -> AnyElement { + let can_submit = !self.submitting + && !self.subject.read(cx).value().is_empty() + && !self.patch.read(cx).value().is_empty(); + let weak = cx.entity().downgrade(); + + h_flex() + .px_4() + .py_2() + .w_full() + .justify_end() + .border_t_1() + .border_color(cx.theme().colors.border) + .child( + Button::new("send-patch") + .label("Send patch") + .icon(Icon::named(CustomIconName::ArrowUp)) + .loading(self.submitting) + .disabled(!can_submit) + .on_click(move |_, _, cx| { + let _ = weak.update(cx, |this, cx| this.submit(cx)); + }), + ) + .into_any_element() + } +} + +impl Panel for SendPatchView { + fn title(&self, _cx: &App) -> Option { + Some(SharedString::from(format!("{}/send-patch", self.repo_name))) + } + + fn lead(&self, cx: &App) -> Option { + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) + } +} + +impl Render for SendPatchView { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + v_flex() + .id("send-patch") + .size_full() + .child( + v_flex() + .p_4() + .flex_1() + .gap_2() + .child(Input::new("patch-subject", self.subject.clone()).placeholder("Title")) + .child( + Input::new("patch-description", self.description.clone()) + .rows(2) + .placeholder("Describe the change"), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child( + "You can paste a git diff or a git format-patch patch series here.", + ), + ) + .child( + Input::new("patch-body", self.patch.clone()) + .rows(12) + .placeholder("diff --git a/file.txt b/file.txt"), + ) + .when_some(self.error.clone(), |this, error| { + this.child( + div() + .text_xs() + .text_color(cx.theme().ramps.destructive.base) + .child(error), + ) + }), + ) + .child(self.render_footer(cx)) + } +} diff --git a/crates/signed_app/src/repo/commit_diff.rs b/crates/signed_app/src/repo/commit_diff.rs index a69e4ac..9fe6890 100644 --- a/crates/signed_app/src/repo/commit_diff.rs +++ b/crates/signed_app/src/repo/commit_diff.rs @@ -92,6 +92,17 @@ impl DiffPane { cx.notify(); } + pub fn clear(&mut self, cx: &mut Context) { + self.diff = None; + self.seeds.clear(); + self.expanded.clear(); + self.rows.clear(); + self.selected_file = None; + self.selected_index = None; + self.diff_rows.clear(); + cx.notify(); + } + fn select_file(&mut self, path: &str, cx: &mut Context) { self.selected_file = Some(path.into()); self.selected_index = self.rows.iter().position(|row| row.id == path); diff --git a/crates/signed_app/src/repo/helpers.rs b/crates/signed_app/src/repo/helpers.rs index fbd0fc8..3d25378 100644 --- a/crates/signed_app/src/repo/helpers.rs +++ b/crates/signed_app/src/repo/helpers.rs @@ -419,7 +419,7 @@ fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { .into_any_element() } -pub(super) fn commit_row( +pub(crate) fn commit_row( ix: usize, commit: &FileCommit, on_click: impl Fn(&mut Window, &mut gpui::App) + 'static, diff --git a/crates/signed_app/src/repo/mod.rs b/crates/signed_app/src/repo/mod.rs index 2f1442b..a7d22c4 100644 --- a/crates/signed_app/src/repo/mod.rs +++ b/crates/signed_app/src/repo/mod.rs @@ -42,9 +42,6 @@ pub mod helpers; pub mod history; pub mod refs; -use crate::issues::IssuesView; -use crate::shared::tree::{build_tree_items, sorted_worktree_paths}; -use crate::shared::widgets::repo_tab_avatar; use banners::Banners; use files::RepoFilesView; use helpers::{ @@ -54,6 +51,11 @@ use helpers::{ use history::RepoHistoryView; use refs::RefSwitcher; +use crate::issues::IssuesView; +use crate::pull_requests::{NewPullRequestView, PullRequestsView, SendPatchView}; +use crate::shared::tree::{build_tree_items, sorted_worktree_paths}; +use crate::shared::widgets::repo_tab_avatar; + #[derive(Clone, Copy, PartialEq, Eq)] pub enum RefKind { /// HEAD stays attached. @@ -89,6 +91,7 @@ pub struct RepoDetailView { repo_menu_open: bool, clone_open: bool, issues_menu_open: bool, + pulls_menu_open: bool, _subscriptions: Vec, } @@ -146,13 +149,15 @@ impl RepoDetailView { } })]; + let store_for_attach = store.clone(); window.defer(cx, move |window, cx| { - if let Err(error) = weak.update(cx, |this, cx| this.load_repo(window, cx)) { - log::warn!("failed to load repository: {error}"); - } + let _ = weak.update(cx, |this, cx| { + this.attach_store(&store_for_attach, window, cx); + this.load_repo(window, cx); + }); }); - let mut view = Self { + Self { dock, store: store.clone(), repo_started: false, @@ -172,11 +177,9 @@ impl RepoDetailView { repo_menu_open: false, clone_open: false, issues_menu_open: false, + pulls_menu_open: false, _subscriptions: subscriptions, - }; - - view.attach_store(&store, window, cx); - view + } } pub(crate) fn apply_announcement( @@ -828,6 +831,85 @@ impl RepoDetailView { dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); } + fn open_pulls(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("pulls:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id, + move |_, cx| { + cx.new(|cx| PullRequestsView::new(dock_for_build.clone(), store.clone(), cx)) + }, + window, + cx, + ); + } + + fn open_new_pull_request(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("new-pr:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + NewPullRequestView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + + fn open_send_patch(&mut self, window: &mut Window, cx: &mut Context) { + let Some(addr) = self.store.read(cx).addr().cloned() else { + return; + }; + + let id = SharedString::from(format!("send-patch:{addr}")); + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + let store = self.store.clone(); + + crate::shared::panels::open( + &dock, + id.clone(), + move |window, cx| { + cx.new(|cx| { + SendPatchView::new( + dock_for_build.clone(), + id.clone(), + store.clone(), + window, + cx, + ) + }) + }, + window, + cx, + ); + } + fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { let Some(addr) = self.store.read(cx).addr().cloned() else { return; @@ -944,6 +1026,53 @@ impl RepoDetailView { .flex_none() .gap_2() .justify_end() + .child({ + let weak_pulls = cx.entity().downgrade(); + let weak_pulls_open = cx.entity().downgrade(); + let _store_for_pulls = self.store.clone(); + let pulls_count = self.store.read(cx).pull_request_count(); + + DropdownMenu::new("pulls") + .label(format!("Pull requests {pulls_count}")) + .icon(Icon::named(CustomIconName::GitPullRequest)) + .variant(ButtonVariant::Secondary) + .open(self.pulls_menu_open) + .on_click(move |_, window, cx| { + if let Err(error) = weak_pulls.update(cx, |this, cx| { + this.open_pulls(window, cx); + }) { + log::warn!("failed to open pull requests: {error}"); + } + }) + .on_open_change(move |open, _, cx| { + if let Err(error) = weak_pulls_open.update(cx, |this, cx| { + this.pulls_menu_open = open; + cx.notify(); + }) { + log::warn!("failed to update pulls menu: {error}"); + } + }) + .items({ + let weak_new = cx.entity().downgrade(); + let weak_patch = cx.entity().downgrade(); + move |_, _| { + let weak_new = weak_new.clone(); + let weak_patch = weak_patch.clone(); + vec![ + MenuItem::new("New pull request").handler(move |window, cx| { + let _ = weak_new.update(cx, |this, cx| { + this.open_new_pull_request(window, cx); + }); + }), + MenuItem::new("Send patch").handler(move |window, cx| { + let _ = weak_patch.update(cx, |this, cx| { + this.open_send_patch(window, cx); + }); + }), + ] + } + }) + }) .child({ let weak_issues = cx.entity().downgrade(); let weak_issues_open = cx.entity().downgrade();