Files
signed/crates/workspace/src/views/pull_requests/new.rs
T
reya a74c166391
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
chore: remove unnecessary optimization (#20)
Reviewed-on: #20
2026-09-13 14:48:50 +00:00

1374 lines
48 KiB
Rust

use std::path::{Path, PathBuf};
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, Panel, PanelEvent, add_center_panel, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{Combobox, ComboboxEvent, ComboboxState};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::menu::{DropdownMenu, PopupMenu, PopupMenuItem};
use gpui_component::scroll::Scrollbar;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::spinner::Spinner;
use gpui_component::{
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list,
};
use nostr::prelude::*;
use signed_core::{Announcement, RepoAddr, fork_candidates};
use signed_git::{
delete_refs_with_prefix, fetch_repo_refs, fork_namespace, merge_base, refs_with_prefix,
worktree_commit_range_commits, worktree_commit_range_diff,
};
use signed_state::{
Backend, CheckoutsStore, RepoListStore, RepoStore, ensure_repo_mirror, repo_mirror_path,
};
use signed_ui::{CountBadge, placeholder, ref_selector_trigger};
use crate::views::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView, DiffPane, commit_row};
pub struct NewPullRequestView {
focus_handle: FocusHandle,
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
repo_name: SharedString,
/// The user's local checkout.
repo_path: Option<PathBuf>,
/// Backs both selectors in checkout mode.
branches: Vec<SharedString>,
fork: Option<ForkCompare>,
/// Selected base branch, the PR target, stored as a short name.
base: SharedString,
/// Selected compare branch, the PR source, stored as a short name.
compare: SharedString,
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
subject: Entity<InputState>,
description: Entity<TextareaState>,
/// Merge base of the selected branches, `None` until the compare loads.
merge_base: Option<String>,
/// Commits in `merge_base..compare`, newest first.
commits: Option<Vec<signed_git::FileCommit>>,
loading: bool,
error: Option<SharedString>,
/// A submit, patch generation and publish, is in flight.
submitting: bool,
/// Bumped on every branch switch, stale compare results are discarded.
compare_generation: u64,
/// 0 = Files, 1 = Commits.
active_tab: usize,
/// The compare diff, the Files tab body.
pane: Entity<DiffPane>,
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
_subscriptions: Vec<Subscription>,
}
struct ForkCompare {
/// Fork announcement the compare branch is imported from.
announcement: Announcement,
/// Import namespace of the form `<owner-hex>/<sanitized-id>`.
namespace: String,
/// Path of the target repository's mirror.
mirror_path: PathBuf,
}
impl ForkCompare {
fn base_ref(name: &str) -> String {
format!("refs/remotes/origin/{name}")
}
fn compare_ref(&self, name: &str) -> String {
format!("refs/fork/{}/{}", self.namespace, name)
}
}
fn fork_display_name(announcement: &Announcement) -> SharedString {
announcement
.name
.as_deref()
.map(SharedString::from)
.unwrap_or_else(|| SharedString::from(announcement.id.clone()))
}
fn shorten_owner(owner: &PublicKey) -> String {
let hex = owner.to_hex();
hex.chars().take(10).collect()
}
/// Truncate a label for the fixed-width controls of the compare bar.
fn truncate_label(label: &str) -> SharedString {
const MAX: usize = 18;
let mut chars = label.chars();
let (prefix, rest) = (chars.by_ref().take(MAX).collect::<String>(), chars.next());
let label = if rest.is_some() {
format!("{}…", &prefix[..prefix.len().saturating_sub(1)])
} else {
prefix
};
SharedString::from(label)
}
fn checkout_source_item(
view: WeakEntity<NewPullRequestView>,
path: PathBuf,
active: bool,
) -> PopupMenuItem {
let subtitle = path.display().to_string();
let title = path
.file_name()
.map(|name| name.to_string_lossy().into_owned())
.unwrap_or_else(|| subtitle.clone());
PopupMenuItem::element(move |_window, cx| {
source_row(
IconName::Folder,
truncate_label(&title),
truncate_label(&subtitle),
cx,
)
})
.checked(active)
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.apply_folder_path(path.clone(), window, cx)
});
}
})
}
fn choose_folder_source_item(view: WeakEntity<NewPullRequestView>) -> PopupMenuItem {
PopupMenuItem::element(move |_window, cx| {
source_row(
IconName::FolderOpen,
"Choose another folder…",
"Pick any local checkout",
cx,
)
})
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.choose_checkout(window, cx));
}
})
}
fn fork_source_item(
view: WeakEntity<NewPullRequestView>,
announcement: Announcement,
subtitle: SharedString,
_active: bool,
) -> PopupMenuItem {
let title = truncate_label(&fork_display_name(&announcement));
PopupMenuItem::element(move |_window, cx| {
source_row(
CustomIconName::GitBranch,
title.clone(),
truncate_label(&subtitle),
cx,
)
})
.on_click(move |_event, window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.choose_fork(announcement.clone(), window, cx)
});
}
})
}
fn source_row<T>(icon: impl Into<Icon>, title: T, subtitle: T, cx: &App) -> AnyElement
where
T: Into<SharedString>,
{
let title = title.into();
let subtitle = subtitle.into();
h_flex()
.gap_2()
.w_full()
.min_w_0()
.items_center()
.child(Icon::new(icon).small().flex_shrink_0())
.child(
v_flex()
.min_w_0()
.flex_1()
.child(
div()
.w_full()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_sm()
.line_height(relative(1.25))
.child(title),
)
.child(
div()
.w_full()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.text_xs()
.text_color(cx.theme().muted_foreground)
.line_height(relative(1.25))
.child(subtitle),
),
)
.into_any_element()
}
impl NewPullRequestView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new);
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe..."));
let base_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let compare_select = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let subscriptions = vec![
cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.base = name.clone();
this.reload_compare(window, cx);
}
}),
cx.subscribe_in(
&compare_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.compare = name.clone();
this.reload_compare(window, cx);
}
},
),
];
cx.defer_in(window, |this, window, 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 {
focus_handle: cx.focus_handle(),
dock_area,
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: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
_subscriptions: subscriptions,
}
}
fn has_source(&self) -> bool {
self.repo_path.is_some() || self.fork.is_some()
}
/// The path git ops run against.
///
/// The target's mirror in fork mode, the user's checkout otherwise.
fn work_path(&self) -> Option<PathBuf> {
match &self.fork {
Some(fork) => Some(fork.mirror_path.clone()),
None => self.repo_path.clone(),
}
}
/// The full ref the selected base branch resolves to.
/// The mirror's remote-tracking ref in fork mode.
///
/// The plain branch name in checkout mode, git resolves it through `refs/heads`.
fn base_ref(&self) -> String {
match &self.fork {
Some(_) => ForkCompare::base_ref(&self.base),
None => self.base.to_string(),
}
}
/// The full ref the selected compare branch resolves to.
/// The imported `refs/fork/<namespace>` ref in fork mode.
///
/// The plain branch name in checkout mode.
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| {
// `Ok(Ok(Some(paths)))` means the user picked a folder.
// A cancel or picker failure resolves to anything else.
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(())
});
task.detach();
}
/// Branches and the current branch are read off the UI thread, then applied.
fn apply_folder_path(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context<Self>) {
let path = path.to_string_lossy().to_string();
let task: gpui::Task<Result<(), anyhow::Error>> =
cx.spawn_in(window, async move |this, cx| {
// Branches and the current branch are read off the UI thread.
let info = cx
.background_spawn({
let path = path.clone();
async move {
let repo = gix::open(Path::new(&path)).ok()?;
let branches =
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
let current = signed_git::current_branch(&repo).ok().flatten();
Some((branches, current))
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.apply_checkout(path, info, window, cx);
})?;
Ok(())
});
task.detach();
}
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;
}
// Defaults, the announced HEAD branch when the checkout has it.
// Falling back to `main`, then the first branch.
// The checkout's current branch is the compare side default.
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();
// Remember this folder as a checkout of the target repository.
// The next panel pre-fills it.
if let Some(addr) = self.store.read(cx).addr().cloned() {
let checkout_store = CheckoutsStore::global(cx);
checkout_store.update(cx, |store, cx| {
store.record(PathBuf::from(&path), addr, cx);
});
}
let branches = self.branches.clone();
let base = SharedString::from(base.clone());
let compare = SharedString::from(compare.clone());
self.base = base.clone();
self.compare = compare.clone();
self.base_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches.clone()), window, cx);
state.set_selected_values(&[base], window, cx);
});
self.compare_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
state.set_selected_values(&[compare], window, cx);
});
self.reload_compare(window, cx);
}
/// Used to find fork candidates. `None` while the repository is not announced.
fn base_repo(&self, cx: &App) -> Option<(RepoAddr, Option<String>)> {
let store = self.store.read(cx);
let addr = store.addr()?.clone();
let euc = store.announcement.as_ref().and_then(|a| a.euc.clone());
Some((addr, euc))
}
/// Announced forks of the target repository a compare can use, own first.
///
/// Re-read whenever the picker opens.
fn fork_candidates(&self, cx: &App) -> Vec<Announcement> {
let Some((base, euc)) = self.base_repo(cx) else {
return Vec::new();
};
let user = Backend::global(cx).read(cx).current_user();
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
fork_candidates(&announcements, &base, euc.as_deref(), user)
.into_iter()
.cloned()
.collect()
}
fn choose_fork(
&mut self,
announcement: Announcement,
window: &mut Window,
cx: &mut Context<Self>,
) {
let refresh = self
.fork
.as_ref()
.is_some_and(|fork| fork.announcement.addr() == announcement.addr());
let Some((base, _euc)) = self.base_repo(cx) else {
return;
};
let mirror_path = repo_mirror_path(&base);
let namespace = fork_namespace(&announcement);
let clone_urls = announcement.clone.clone();
let base_clone_urls: Vec<Url> = self
.store
.read(cx)
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default();
// Keep the current compare and base when the fork is already applied.
// apply_fork drops them when the branch no longer exists.
let keep_compare = refresh.then(|| self.compare.clone());
let keep_base = refresh.then(|| self.base.clone());
// The fork applied when the fetch started.
// A source switch mid-flight must not let the stale result clobber the newer state.
let expected_fork = self.fork.as_ref().map(|fork| fork.announcement.addr());
self.loading = true;
self.error = None;
cx.notify();
let task: gpui::Task<Result<(), anyhow::Error>> =
cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn({
let base = base.clone();
let base_clone_urls = base_clone_urls.clone();
let namespace = namespace.clone();
let clone_urls = clone_urls.clone();
let mirror_path = mirror_path.clone();
async move {
ensure_repo_mirror(&base, &base_clone_urls)?;
// Prune stale imports of any fork. Then import this fork's heads under its namespace.
delete_refs_with_prefix(&mirror_path, "refs/fork")?;
// Fetch the fork's refs and import them under the fork's namespace.
fetch_repo_refs(
&mirror_path,
&clone_urls,
&format!("+refs/heads/*:refs/fork/{namespace}/*"),
)?;
// Both branch lists are short names, sorted like the checkout's.
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(
refs_with_prefix(&mirror_path, "refs/remotes/origin")?,
"refs/remotes/origin",
);
let compare_branches = strip(
refs_with_prefix(&mirror_path, &format!("refs/fork/{namespace}"))?,
&format!("refs/fork/{namespace}"),
);
Ok::<_, anyhow::Error>((base_branches, compare_branches))
}
})
.await;
this.update_in(cx, |this, window, cx| {
// A source switch mid-flight discards the stale result.
// E.g. the user picked a folder while the fork was fetching.
let applied = this.fork.as_ref().map(|fork| fork.announcement.addr());
if applied != expected_fork {
this.loading = false;
cx.notify();
return;
}
this.apply_fork(
announcement,
mirror_path,
namespace,
result,
keep_base,
keep_compare,
window,
cx,
);
})?;
Ok(())
});
task.detach();
}
#[allow(clippy::too_many_arguments)]
fn apply_fork(
&mut self,
announcement: Announcement,
mirror_path: PathBuf,
namespace: String,
result: Result<(Vec<String>, Vec<String>), anyhow::Error>,
keep_base: Option<SharedString>,
keep_compare: Option<SharedString>,
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 base_branches: Vec<SharedString> =
base_branches.into_iter().map(SharedString::from).collect();
let compare_branches: Vec<SharedString> = compare_branches
.into_iter()
.map(SharedString::from)
.collect();
let announced = self.store.read(cx).head.clone();
let contains =
|name: &str, list: &[SharedString]| list.iter().any(|branch| branch.as_ref() == name);
let keep_base = keep_base.filter(|name| contains(name, &base_branches));
let keep_compare = keep_compare.filter(|name| contains(name, &compare_branches));
let base = keep_base
.or_else(|| {
announced
.as_ref()
.filter(|branch| contains(branch, &base_branches))
.map(SharedString::from)
})
.or_else(|| {
base_branches
.iter()
.find(|branch| branch.as_ref() == "main")
.cloned()
})
.unwrap_or_else(|| base_branches[0].clone());
let compare = keep_compare
.or_else(|| {
compare_branches
.iter()
.find(|branch| branch.as_ref() == "main")
.cloned()
})
.unwrap_or_else(|| compare_branches[0].clone());
self.fork = Some(ForkCompare {
announcement,
namespace,
mirror_path,
});
self.error = None;
self.base = base.clone();
self.compare = compare.clone();
self.base_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(base_branches), window, cx);
state.set_selected_values(&[base], window, cx);
});
self.compare_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(compare_branches), window, cx);
state.set_selected_values(&[compare], window, cx);
});
self.reload_compare(window, cx);
}
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.work_path() else {
return;
};
let base = self.base_ref();
let compare = self.compare_ref();
// Short names for the error copy, the full refs go to git.
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_in(window, async move |this, cx| {
let result = cx
.background_spawn({
let repo_path = repo_path.clone();
let base = base.clone();
let compare = compare.clone();
let base_name = base_name.clone();
let compare_name = compare_name.clone();
async move {
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
.ok_or_else(|| {
anyhow::anyhow!(
"{base_name} and {compare_name} share no common ancestor"
)
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
})
.await;
this.update_in(cx, |this, _window, cx| {
// A stale result, branches changed mid-flight, must not clobber a newer compare.
if generation != this.compare_generation {
return;
}
this.loading = false;
match result {
Ok((merge_base, commits, diff)) => {
this.merge_base = Some(merge_base);
let count = commits.len();
this.item_sizes =
Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
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(())
});
task.detach();
}
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();
// The published `branch-name` is the compare branch's short name.
let branch_name = self.compare.to_string();
// The patch comes from the compare ref.
// Plain branch name in checkout mode, imported `refs/fork/…` ref in fork mode.
let compare_ref = self.compare_ref();
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
self.submitting = true;
self.error = None;
cx.notify();
let task: gpui::Task<Result<(), anyhow::Error>> =
cx.spawn_in(window, async move |this, cx| {
// Regenerate the series at submit time.
// The published patch covers the current tip of the compare branch.
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_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some(error.to_string().into());
cx.notify();
})?;
return Ok(());
}
this.update_in(cx, |this, window, cx| {
this.submitting = false;
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
let entity = entity.clone();
move |_, window, cx| {
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock, cx| {
dock.remove_panel(entity, window, cx);
});
}
}
});
cx.notify();
})?;
Ok(())
});
task.detach();
}
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 Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| {
CommitDiffView::new(
repo_path,
self.repo_name.clone(),
commit_id.into(),
window,
cx,
)
});
dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let has_source = self.has_source();
let can_submit = 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();
// Source-picker data snapshotted when the menu is built.
// Each open rebuilds the items from the live announcements.
let source_menu = self.source_menu(cx);
let source_label = self.source_trigger();
let source_tooltip = match &self.fork {
Some(fork) => {
format!(
"Comparing against {}",
fork_display_name(&fork.announcement)
)
}
None => self.repo_path.as_ref().map_or_else(
|| "Choose a compare source".into(),
|p| p.display().to_string(),
),
};
let refresh_fork = self.fork.as_ref().map(|fork| fork.announcement.clone());
h_flex()
.px_4()
.h_16()
.w_full()
.gap_2()
.items_end()
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Merge Into"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.base_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_source)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Pull From"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.compare_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_source)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
ref_selector_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Source"),
)
.child(
Button::new("compare-source")
.ghost()
.w(px(190.))
.child(div().text_sm().child(source_label))
.dropdown_caret(true)
.tooltip(source_tooltip)
.dropdown_menu(source_menu),
),
)
.when_some(refresh_fork, |this, fork| {
this.child(
v_flex().gap_1().child(div()).child(
Button::new("refresh-fork")
.icon(CustomIconName::Refresh)
.ghost()
.tooltip("Re-fetch the fork")
.on_click(cx.listener(move |this, _event, window, cx| {
this.choose_fork(fork.clone(), window, cx);
})),
),
)
})
.child(div().flex_1())
.child(
Button::new("create-pr")
.primary()
.icon(IconName::Plus)
.loading(self.submitting)
.disabled(!can_submit)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
)
.into_any_element()
}
fn source_trigger(&self) -> SharedString {
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()),
),
}
}
fn source_menu(
&self,
cx: &Context<Self>,
) -> impl Fn(PopupMenu, &mut Window, &mut Context<PopupMenu>) -> PopupMenu + 'static {
let view = cx.entity().downgrade();
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 candidates = self.fork_candidates(cx);
let user = Backend::global(cx).read(cx).current_user();
let active_fork = self.fork.as_ref().map(|fork| fork.announcement.addr());
move |mut menu, _window, _cx| {
for path in &associated {
menu = menu.item(checkout_source_item(
view.clone(),
path.clone(),
active_path.as_ref() == Some(path),
));
}
menu = menu.item(choose_folder_source_item(view.clone()));
menu = menu.item(PopupMenuItem::separator());
if candidates.is_empty() {
menu = menu.item(PopupMenuItem::label(
"No announced forks of this repository",
));
} else {
for candidate in candidates.iter() {
let subtitle: SharedString = if Some(candidate.owner) == user {
"Your fork".into()
} else {
SharedString::from(format!("by {}", shorten_owner(&candidate.owner)))
};
menu = menu.item(fork_source_item(
view.clone(),
candidate.clone(),
subtitle,
active_fork == Some(candidate.addr()),
));
}
}
menu
}
}
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
.w_full()
.gap_2()
.child(Input::new(&self.subject))
.child(Textarea::new(&self.description).h_24())
.into_any_element()
}
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());
h_flex()
.px_4()
.pb_4()
.w_full()
.gap_2()
.items_center()
.border_b_1()
.border_color(cx.theme().border)
.child(
BaseButton::new("files-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitFile).small())
.child("Files"),
)
.child(CountBadge::new(files))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 0)
.when(self.active_tab == 0, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 0;
cx.notify();
})),
)
.child(
BaseButton::new("commits-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitCommit).small())
.child("Commits"),
)
.child(CountBadge::new(commits))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 1)
.when(self.active_tab == 1, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 1;
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().small())
.into_any_element();
}
if !self.has_source() {
return placeholder(
"Choose a local checkout or an announced fork to compare",
cx,
);
}
if self.commits.is_none() && self.error.is_some() {
return placeholder("Nothing to compare", cx);
}
match self.active_tab {
0 => self.pane.clone().into_any_element(),
_ => self.render_commits_tab(cx),
}
}
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(commits) = self.commits.as_ref() else {
return placeholder("No commits", cx);
};
if commits.is_empty() {
return placeholder("No commits between the branches", cx);
}
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
v_virtual_list(
view,
"pr-commits",
sizes,
move |this, range, _window, cx| {
let commits = this.commits.as_deref().unwrap_or(&[]);
let view = cx.entity().downgrade();
range
.map(|ix| {
let id = commits[ix].id.clone();
let view = view.clone();
commit_row(
ix,
&commits[ix],
move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx)
});
}
},
cx,
)
})
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
.into_any_element()
}
}
pub(crate) fn open_new_pull_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let panel = cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
add_center_panel(dock_area, panel_handle(panel), window, cx);
});
}
impl BasePanel for NewPullRequestView {
fn panel_name(&self) -> &'static str {
"new-pull-request"
}
}
impl Panel for NewPullRequestView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/new-pull-request",
self.repo_name
)))
}
}
impl EventEmitter<PanelEvent> for NewPullRequestView {}
impl Focusable for NewPullRequestView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
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_4()
.child(self.render_compare_bar(cx))
.child(self.render_inputs(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
h_flex()
.px_4()
.py_1()
.w_full()
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(self.render_tabs(cx)),
)
.child(
v_flex()
.flex_1()
.min_h_0()
.w_full()
.child(self.render_content(cx)),
)
}
}