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
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:
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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<Item = &'a Event>,
|
||||
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<RepoStore>,
|
||||
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<RepoStore>,
|
||||
root: EventId,
|
||||
directory: &Entity<ProfileDirectory>,
|
||||
@@ -214,7 +218,7 @@ pub(super) fn comments_section(
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
pub(super) fn comment_form(
|
||||
pub(crate) fn comment_form(
|
||||
store: &Entity<RepoStore>,
|
||||
root: EventId,
|
||||
roots: fn(&RepoStore) -> &[Event],
|
||||
|
||||
@@ -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<Self>) {
|
||||
|
||||
@@ -2,6 +2,7 @@ mod app;
|
||||
mod explore;
|
||||
mod identity;
|
||||
mod issues;
|
||||
mod pull_requests;
|
||||
mod repo;
|
||||
mod shared;
|
||||
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))
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -92,6 +92,17 @@ impl DiffPane {
|
||||
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>) {
|
||||
self.selected_file = Some(path.into());
|
||||
self.selected_index = self.rows.iter().position(|row| row.id == path);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<Subscription>,
|
||||
}
|
||||
|
||||
@@ -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<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>) {
|
||||
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();
|
||||
|
||||
Reference in New Issue
Block a user