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, GitStore, RepoListStore, RepoStore}; 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, store: Entity, repo_name: SharedString, /// The user's local checkout. repo_path: Option, /// Backs both selectors in checkout mode. branches: Vec, fork: Option, /// 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>>, compare_select: Entity>>, subject: Entity, description: Entity, /// Merge base of the selected branches, `None` until the compare loads. merge_base: Option, /// Commits in `merge_base..compare`, newest first. commits: Option>, loading: bool, error: Option, /// 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, scroll_handle: VirtualListScrollHandle, item_sizes: Rc>>, _subscriptions: Vec, } struct ForkCompare { /// Fork announcement the compare branch is imported from. announcement: Announcement, /// Import namespace of the form `/`. namespace: String, /// Path of the target repository's GitCache 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::(), 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, 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) -> 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, 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(icon: impl Into, title: T, subtitle: T, cx: &App) -> AnyElement where T: Into, { 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, store: Entity, window: &mut Window, cx: &mut Context, ) -> 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::::new()), Vec::new(), window, cx, ) .searchable(true) }); let compare_select = cx.new(|cx| { ComboboxState::new( SearchableVec::new(Vec::::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 { 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/` 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) { let prompt = cx.prompt_for_paths(PathPromptOptions { files: false, directories: true, multiple: false, prompt: Some("Choose local checkout".into()), }); let task: gpui::Task> = cx.spawn_in(window, async move |this, cx| { // `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) { let path = path.to_string_lossy().to_string(); let task: gpui::Task> = 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, Option)>, window: &mut Window, cx: &mut Context, ) { self.fork = None; let Some((branches, current)) = info else { self.error = Some("The chosen folder is not a git repository".into()); self.repo_path = None; self.branches.clear(); self.merge_base = None; self.commits = None; self.pane.update(cx, |pane, cx| pane.clear(cx)); cx.notify(); return; }; if branches.is_empty() { self.error = Some("The repository has no branches yet".into()); self.repo_path = None; self.branches.clear(); cx.notify(); return; } // 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)> { 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 { 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, ) { 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 cache = GitStore::global(cx).cache().clone(); let mirror_path = cache.repo_path(&base); let namespace = fork_namespace(&announcement); let clone_urls = announcement.clone.clone(); let base_clone_urls: Vec = 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> = cx.spawn_in(window, async move |this, cx| { // The fork and base must share history for a merge-base to exist. // The target's mirror is the object store both sides land in. // `ensure_clone` fetches `origin` when the mirror already exists. let result = cx .background_spawn({ let cache = cache.clone(); 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 { cache.ensure_clone(&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_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, prefix: &str| { let mut names: Vec = refs .into_iter() .filter_map(|name| { name.strip_prefix(prefix) .map(|rest| rest.trim_start_matches('/').to_owned()) }) .filter(|name| !name.is_empty()) .collect(); names.sort(); names }; let base_branches = strip( 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, Vec), anyhow::Error>, keep_base: Option, keep_compare: Option, window: &mut Window, cx: &mut Context, ) { self.loading = false; let (base_branches, compare_branches) = match result { Ok(branches) => branches, Err(error) => { // Keep the previous source, if any. // The error shows inline next to the compare bar. 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; try again later".into()); cx.notify(); return; } let base_branches: Vec = base_branches.into_iter().map(SharedString::from).collect(); let compare_branches: Vec = compare_branches .into_iter() .map(SharedString::from) .collect(); // Base defaults to the announced HEAD branch when the mirror has it. // Otherwise `main`, then the first branch. // The fork's `main` is the compare default, else the first branch. // A refresh keeps the previous selection when the branch still exists. 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) { 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> = 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) { 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> = 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) { 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) -> 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, ) -> impl Fn(PopupMenu, &mut Window, &mut Context) -> 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) -> 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) -> 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) -> 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) -> 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, store: Entity, 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) -> impl IntoElement { div().child(SharedString::from(format!( "{}/new-pull-request", self.repo_name ))) } } impl EventEmitter 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) -> 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)), ) } }