add pull requests view with PR and patch panels
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-12 08:36:55 +07:00
parent 530be762ff
commit d6e948474b
16 changed files with 2547 additions and 39 deletions
-1
View File
@@ -96,4 +96,3 @@ impl IconNamed for CustomIconName {
.into() .into()
} }
} }
+4 -4
View File
@@ -42,7 +42,7 @@ impl ExploreView {
let store = RepoListStore::global(cx); let store = RepoListStore::global(cx);
let directory = ProfileDirectory::new(cx); let directory = ProfileDirectory::new(cx);
let entity = cx.entity(); let weak = cx.entity().downgrade();
let search = InputState::new(cx); let search = InputState::new(cx);
let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { let search_subscription = cx.subscribe(&search, |this, _search, event, cx| {
@@ -56,9 +56,9 @@ impl ExploreView {
}); });
cx.defer(move |cx| { cx.defer(move |cx| {
entity.update(cx, |this, cx| { if let Err(error) = weak.update(cx, |this, cx| this.rebuild_rows(cx)) {
this.rebuild_rows(cx); log::warn!("failed to load explore rows: {error}");
}); }
}); });
Self { Self {
+6 -4
View File
@@ -39,17 +39,19 @@ impl IssueDetailView {
}); });
let directory = ProfileDirectory::new(cx); 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, store,
issue_id, issue_id,
comment_input, comment_input,
directory, directory,
scroll_handle: ScrollHandle::new(), scroll_handle: ScrollHandle::new(),
_subscription: subscription, _subscription: subscription,
}; }
view.track_participants(cx);
view
} }
/// Track the issue author, everyone who commented and the repository owner. /// Track the issue author, everyone who commented and the repository owner.
+17 -13
View File
@@ -18,14 +18,14 @@ use signed_state::RepoStore;
use utils::relative_time; use utils::relative_time;
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum IssueFilter { pub(crate) enum IssueFilter {
All, All,
Open, Open,
Closed, Closed,
} }
impl IssueFilter { impl IssueFilter {
pub(super) fn matches(self, status: RepoStatus) -> bool { pub(crate) fn matches(self, status: RepoStatus) -> bool {
match self { match self {
Self::All => true, Self::All => true,
Self::Open => status == RepoStatus::Open, Self::Open => status == RepoStatus::Open,
@@ -36,12 +36,12 @@ impl IssueFilter {
/// Root events counted by their resolved status. /// Root events counted by their resolved status.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) struct StatusCounts { pub(crate) struct StatusCounts {
pub(super) total: usize, pub(crate) total: usize,
pub(super) open: usize, pub(crate) open: usize,
pub(super) closed: usize, pub(crate) closed: usize,
pub(super) draft: usize, pub(crate) draft: usize,
pub(super) applied: usize, pub(crate) applied: usize,
} }
impl StatusCounts { impl StatusCounts {
@@ -57,7 +57,7 @@ impl StatusCounts {
} }
/// Indices of `roots` whose status `keep` accepts, counting every root's status. /// 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<Item = &'a Event>, roots: impl IntoIterator<Item = &'a Event>,
status_of: impl Fn(&Event) -> RepoStatus, status_of: impl Fn(&Event) -> RepoStatus,
keep: impl Fn(RepoStatus) -> bool, keep: impl Fn(RepoStatus) -> bool,
@@ -77,11 +77,15 @@ pub(super) fn filter_by_status<'a>(
(visible, counts) (visible, counts)
} }
pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { pub(crate) fn issue_roots(store: &RepoStore) -> &[Event] {
&store.issues &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<RepoStore>, store: &Entity<RepoStore>,
id: EventId, id: EventId,
roots: fn(&RepoStore) -> &[Event], roots: fn(&RepoStore) -> &[Event],
@@ -153,7 +157,7 @@ pub(super) fn sidebar_section(
.into_any_element() .into_any_element()
} }
pub(super) fn comments_section( pub(crate) fn comments_section(
store: &Entity<RepoStore>, store: &Entity<RepoStore>,
root: EventId, root: EventId,
directory: &Entity<ProfileDirectory>, directory: &Entity<ProfileDirectory>,
@@ -214,7 +218,7 @@ pub(super) fn comments_section(
.into_any_element() .into_any_element()
} }
pub(super) fn comment_form( pub(crate) fn comment_form(
store: &Entity<RepoStore>, store: &Entity<RepoStore>,
root: EventId, root: EventId,
roots: fn(&RepoStore) -> &[Event], roots: fn(&RepoStore) -> &[Event],
+7 -5
View File
@@ -27,7 +27,7 @@ use crate::shared::panels;
use crate::shared::widgets::{repo_tab_avatar, status_badge}; use crate::shared::widgets::{repo_tab_avatar, status_badge};
pub mod detail; pub mod detail;
mod helpers; pub(crate) mod helpers;
pub mod new_issue; pub mod new_issue;
const ISSUE_ROW_HEIGHT: f32 = 73.; const ISSUE_ROW_HEIGHT: f32 = 73.;
@@ -58,8 +58,12 @@ impl IssuesView {
let subscription = cx.observe(&store, |this, _store, cx| { let subscription = cx.observe(&store, |this, _store, cx| {
this.rebuild(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, dock,
store, store,
repo_name, repo_name,
@@ -70,9 +74,7 @@ impl IssuesView {
scroll_handle: UniformListScrollHandle::new(), scroll_handle: UniformListScrollHandle::new(),
directory: ProfileDirectory::new(cx), directory: ProfileDirectory::new(cx),
_subscription: subscription, _subscription: subscription,
}; }
view.rebuild(cx);
view
} }
fn rebuild(&mut self, cx: &mut Context<Self>) { fn rebuild(&mut self, cx: &mut Context<Self>) {
+1
View File
@@ -2,6 +2,7 @@ mod app;
mod explore; mod explore;
mod identity; mod identity;
mod issues; mod issues;
mod pull_requests;
mod repo; mod repo;
mod shared; mod shared;
mod sidebar; mod sidebar;
@@ -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<String>,
base: Option<String>,
clone_urls: Vec<Url>,
addr: RepoAddr,
has_patch_link: bool,
}
pub struct PullRequestDetailView {
dock: WeakEntity<DockState>,
store: Entity<RepoStore>,
pr_id: EventId,
comment_input: Entity<InputState>,
repo_name: SharedString,
worktree: Option<PathBuf>,
description: SharedString,
current_commit: Option<SharedString>,
commits: Vec<FileCommit>,
loading: bool,
error: Option<SharedString>,
bound: Option<PrBinding>,
load_generation: u64,
active_tab: usize,
pane: Entity<DiffPane>,
commit_scroll_handle: UniformListScrollHandle,
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
directory: Entity<ProfileDirectory>,
_subscription: Subscription,
}
impl PullRequestDetailView {
pub fn new(
dock: WeakEntity<DockState>,
store: Entity<RepoStore>,
pr_id: EventId,
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
let store = self.store.read(cx);
let mut keys: Vec<PublicKey> = 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>) {
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>) {
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<Result<(), anyhow::Error>> = 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<Self>,
) {
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<Self>) -> 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<SharedString> {
let hex = self.pr_id.to_hex();
Some(super::helpers::pr_label(&self.repo_name, &hex[..8]))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
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<Self>) -> 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()
}
@@ -0,0 +1 @@
pub mod update;
@@ -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<RepoStore>, root: Event, cx: &mut App) {
open_global(UpdatePrDialog::new(store, root, cx), cx);
}
pub struct UpdatePrDialog {
store: Entity<RepoStore>,
root: Event,
patch: Entity<InputState>,
}
impl UpdatePrDialog {
pub fn new(store: Entity<RepoStore>, root: Event, cx: &mut App) -> Entity<Self> {
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<Self>) {
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<Self>) -> 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,
]
})
}
}
@@ -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))
}
+455
View File
@@ -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<DockState>,
store: Entity<RepoStore>,
repo_name: SharedString,
filter: PullRequestFilter,
visible_prs: Vec<usize>,
counts: StatusCounts,
synced_filter: PullRequestFilter,
scroll_handle: UniformListScrollHandle,
directory: Entity<ProfileDirectory>,
new_pr_menu_open: bool,
_subscription: Subscription,
}
impl PullRequestsView {
pub fn new(
dock: WeakEntity<DockState>,
store: Entity<RepoStore>,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<PublicKey> = 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<Self>) {
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<Self>) {
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<Self>) -> 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<SharedString> {
Some(SharedString::from(format!("{}/pulls", self.repo_name)))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
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<Self>) -> 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()
}
}
+922
View File
@@ -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<DockState>,
panel_id: SharedString,
store: Entity<RepoStore>,
repo_name: SharedString,
repo_path: Option<PathBuf>,
branches: Vec<SharedString>,
fork: Option<ForkCompare>,
base: SharedString,
compare: SharedString,
base_select: Entity<ComboboxState<SharedString>>,
compare_select: Entity<ComboboxState<SharedString>>,
subject: Entity<InputState>,
description: Entity<InputState>,
merge_base: Option<String>,
commits: Option<Vec<signed_git::FileCommit>>,
loading: bool,
error: Option<SharedString>,
submitting: bool,
compare_generation: u64,
active_tab: usize,
pane: Entity<DiffPane>,
scroll_handle: UniformListScrollHandle,
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
directory: Entity<ProfileDirectory>,
source_open: bool,
_subscriptions: Vec<Subscription>,
}
impl NewPullRequestView {
pub fn new(
dock: WeakEntity<DockState>,
panel_id: SharedString,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> 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<PathBuf> {
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<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Choose local checkout".into()),
});
let task: gpui::Task<Result<(), anyhow::Error>> =
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<Self>) {
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<String>, Option<String>)>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Announcement> {
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<Self>,
) {
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<Url> = announcement.clone.clone();
let base_clone_urls: Vec<Url> = 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<Result<(), anyhow::Error>> = 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<String>, prefix: &str| {
let mut names: Vec<String> = 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<String>, Vec<String>), anyhow::Error>,
window: &mut Window,
cx: &mut Context<Self>,
) {
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<Self>) {
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<Result<(), anyhow::Error>> = 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<Self>) {
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<Result<(), anyhow::Error>> = 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<Self>) {
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<Self>) -> 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<Self>) -> Vec<MenuItem> {
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<Self>) -> 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<Self>) -> 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<Self>) -> 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<SharedString> {
Some(SharedString::from(format!(
"{}/new-pull-request",
self.repo_name
)))
}
fn lead(&self, cx: &gpui::App) -> Option<AnyElement> {
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<Self>) -> 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()
}
@@ -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<DockState>,
panel_id: SharedString,
store: Entity<RepoStore>,
repo_name: SharedString,
subject: Entity<InputState>,
description: Entity<InputState>,
patch: Entity<InputState>,
submitting: bool,
error: Option<SharedString>,
directory: Entity<ProfileDirectory>,
}
impl SendPatchView {
pub fn new(
dock: WeakEntity<DockState>,
panel_id: SharedString,
store: Entity<RepoStore>,
_window: &mut Window,
cx: &mut Context<Self>,
) -> 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<Self>) {
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<Self>) -> 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<SharedString> {
Some(SharedString::from(format!("{}/send-patch", self.repo_name)))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
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<Self>) -> 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))
}
}
+11
View File
@@ -92,6 +92,17 @@ impl DiffPane {
cx.notify(); cx.notify();
} }
pub fn clear(&mut self, cx: &mut Context<Self>) {
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>) { fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into()); self.selected_file = Some(path.into());
self.selected_index = self.rows.iter().position(|row| row.id == path); self.selected_index = self.rows.iter().position(|row| row.id == path);
+1 -1
View File
@@ -419,7 +419,7 @@ fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
pub(super) fn commit_row( pub(crate) fn commit_row(
ix: usize, ix: usize,
commit: &FileCommit, commit: &FileCommit,
on_click: impl Fn(&mut Window, &mut gpui::App) + 'static, on_click: impl Fn(&mut Window, &mut gpui::App) + 'static,
+140 -11
View File
@@ -42,9 +42,6 @@ pub mod helpers;
pub mod history; pub mod history;
pub mod refs; 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 banners::Banners;
use files::RepoFilesView; use files::RepoFilesView;
use helpers::{ use helpers::{
@@ -54,6 +51,11 @@ use helpers::{
use history::RepoHistoryView; use history::RepoHistoryView;
use refs::RefSwitcher; 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)] #[derive(Clone, Copy, PartialEq, Eq)]
pub enum RefKind { pub enum RefKind {
/// HEAD stays attached. /// HEAD stays attached.
@@ -89,6 +91,7 @@ pub struct RepoDetailView {
repo_menu_open: bool, repo_menu_open: bool,
clone_open: bool, clone_open: bool,
issues_menu_open: bool, issues_menu_open: bool,
pulls_menu_open: bool,
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
} }
@@ -146,13 +149,15 @@ impl RepoDetailView {
} }
})]; })];
let store_for_attach = store.clone();
window.defer(cx, move |window, cx| { window.defer(cx, move |window, cx| {
if let Err(error) = weak.update(cx, |this, cx| this.load_repo(window, cx)) { let _ = weak.update(cx, |this, cx| {
log::warn!("failed to load repository: {error}"); this.attach_store(&store_for_attach, window, cx);
} this.load_repo(window, cx);
});
}); });
let mut view = Self { Self {
dock, dock,
store: store.clone(), store: store.clone(),
repo_started: false, repo_started: false,
@@ -172,11 +177,9 @@ impl RepoDetailView {
repo_menu_open: false, repo_menu_open: false,
clone_open: false, clone_open: false,
issues_menu_open: false, issues_menu_open: false,
pulls_menu_open: false,
_subscriptions: subscriptions, _subscriptions: subscriptions,
}; }
view.attach_store(&store, window, cx);
view
} }
pub(crate) fn apply_announcement( pub(crate) fn apply_announcement(
@@ -828,6 +831,85 @@ impl RepoDetailView {
dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); dialogs::init::open(local_path, cx.entity().downgrade(), window, cx);
} }
fn open_pulls(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
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<Self>) {
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<Self>) { fn open_issues(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(addr) = self.store.read(cx).addr().cloned() else { let Some(addr) = self.store.read(cx).addr().cloned() else {
return; return;
@@ -944,6 +1026,53 @@ impl RepoDetailView {
.flex_none() .flex_none()
.gap_2() .gap_2()
.justify_end() .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({ .child({
let weak_issues = cx.entity().downgrade(); let weak_issues = cx.entity().downgrade();
let weak_issues_open = cx.entity().downgrade(); let weak_issues_open = cx.entity().downgrade();