Files
signed/crates/workspace/src/views/pull_requests/detail.rs
T
2026-09-13 13:00:20 +07:00

875 lines
31 KiB
Rust

use std::path::PathBuf;
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner;
use gpui_component::tab::{Tab, TabBar};
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind, Url};
use signed_core::{
RepoAddr, activity_subject, branch_name_of, clone_urls_of, current_commit_of, latest_update,
merge_base_of, pull_request_patch,
};
use signed_git::{FileCommit, patch_commits, patch_diffs};
use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
use signed_ui::{CountBadge, UserAvatar, placeholder, status_badge};
use utils::{relative_time, relative_time_secs};
use crate::views::commit_diff::{CommitDiffView, DiffPane};
use crate::views::repo::helpers::{comment_form, comments_section, pr_roots, sidebar_section};
/// Height of one commit row in the commits tab's virtual list.
const ROW_HEIGHT: f32 = 37.;
/// Shown once the store's first pass is applied and the root PR is still absent.
const NOT_FOUND: &str = "Pull request not found";
/// Root PR inputs one diff load is keyed to.
///
/// A store refresh re-binds the panel, and reloads only when these change.
#[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,
}
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
/// Dock area where new panels, e.g. commit diffs, are added.
dock_area: WeakEntity<DockArea>,
/// Repo store holding the PR, its status and comments.
store: Entity<RepoStore>,
/// Event id of the root PR event, kind 1618.
/// Updates are revisions.
pr_id: EventId,
/// Input state of the comment textarea.
comment_input: Entity<TextareaState>,
/// Display name of the repository, for panels opened from here.
repo_name: SharedString,
/// Local clone the PR's git changes come from.
worktree: Option<PathBuf>,
/// Root PR's content, shown as plain text.
description: SharedString,
/// Tip commit of the PR, the latest update's `c` tag or the root's.
current_commit: Option<SharedString>,
/// Commits of the patch series, in patch order, oldest first.
commits: Vec<FileCommit>,
/// The patch is being parsed on a background task.
loading: bool,
error: Option<SharedString>,
/// Root PR inputs the in-flight diff load was started for.
bound: Option<PrBinding>,
/// Generation of the in-flight diff load. Stale results are discarded.
load_generation: u64,
/// Active header tab, 0 = Discussion, 1 = Files, 2 = Commits.
active_tab: usize,
/// Changed-files explorer and per-file diff, like the commit and compare views.
pane: Entity<DiffPane>,
/// Per-row heights of the commits tab's virtual list, built when the patch series loads.
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// Re-render when the store's first pass or a later refresh lands.
/// Item panels are cached by the dock, so without this observer a panel
/// opened before the store loaded would stay on its placeholder.
_subscription: Subscription,
}
impl PullRequestDetailView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
pr_id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new);
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment..."));
let subscription = cx.observe(&store, |this, _store, cx| this.sync(cx));
// Defer loading until the window is ready, like the commit diff view.
cx.defer_in(window, |this, _window, cx| {
this.sync(cx);
});
Self {
focus_handle: cx.focus_handle(),
dock_area,
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_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
_subscription: subscription,
}
}
/// Snapshot the root PR from the store and reload the diff when it changed.
///
/// Re-runs on construction and on every store refresh. Item panels are
/// cached by the dock, so this is the only way a panel opened before the
/// store's first pass learns about its PR.
fn sync(&mut self, cx: &mut Context<Self>) {
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 = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
let base = update
.and_then(merge_base_of)
.or_else(|| merge_base_of(root));
let clone_urls = clone_urls_of(root)
.or_else(|| store.announcement.as_ref().map(|a| a.clone.clone()))
.unwrap_or_default();
PrBinding {
description: root.content.clone(),
patch: pull_request_patch(root, 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.sync_missing(loaded, cx);
return;
};
if self.bound.as_ref() == Some(&binding) {
return;
}
self.bound = Some(binding.clone());
self.load_diff(binding, cx);
}
/// The store does not hold the root PR yet, or at all.
///
/// Loading until the first pass is applied, not found afterwards.
fn sync_missing(&mut self, loaded: bool, cx: &mut Context<Self>) {
self.bound = None;
if !loaded {
if !self.loading || self.error.is_some() {
self.loading = true;
self.error = None;
cx.notify();
}
return;
}
if self.error.as_deref() != Some(NOT_FOUND) {
self.loading = false;
self.error = Some(NOT_FOUND.into());
cx.notify();
}
}
/// Load the bound PR's changed files and commits.
///
/// Nostr-backed pull requests parse the patch series, git-backed ones fetch
/// the clone and diff the `merge-base..tip` range.
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();
let cache = GitStore::global(cx).cache().clone();
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 { patch_diffs(&patch) }
})
.await;
let nostr_commits = cx
.background_spawn({
let patch = patch.clone();
async move { patch_commits(&patch) }
})
.await;
// PRs without patch events, e.g. published by ngit, carry their changes in git.
// Fetch the clone and diff the `merge-base..tip` range.
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 cache = cache.clone();
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 = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = 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,
// No `merge-base` tag. Use the merge base of the tip and the default branch.
None => {
let head = repo
.head_id()
.map_err(|_| anyhow::anyhow!("repository has no HEAD"))?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
repo.merge_base(tip_id, head)?.to_string()
}
};
let diff = signed_git::worktree_commit_range_diff(&workdir, &base, &tip)?;
let commits =
signed_git::worktree_commit_range_commits(&workdir, &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| {
// A newer binding superseded this load.
if this.load_generation != generation {
return;
}
this.loading = false;
this.worktree = worktree;
this.commit_item_sizes = Rc::new(vec![size(px(0.), px(ROW_HEIGHT)); commits.len()]);
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(())
});
task.detach();
}
/// Open the diff of `commit_id` in the bottom dock of the area.
fn open_commit_diff(
&mut self,
worktree: PathBuf,
commit_id: &str,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| {
CommitDiffView::new(
worktree,
self.repo_name.clone(),
commit_id.into(),
window,
cx,
)
});
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Bottom, None, window, cx);
});
}
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let active = self.active_tab;
let files_count = self.pane.read(cx).diff().map(|diff| diff.files.len());
let commits_count = if self.commits.is_empty() {
None
} else {
Some(self.commits.len())
};
TabBar::new("pr-tabs")
.underline()
.small()
.px_4()
.w_full()
.selected_index(active)
.on_click(cx.listener(|this, index, _window, cx| {
this.active_tab = *index;
cx.notify();
}))
.child(Tab::new().label("Discussion"))
.child(
Tab::new()
.label("Files")
.when_some(files_count, |this, count| {
this.suffix(CountBadge::new(count))
}),
)
.child(
Tab::new()
.label("Commits")
.when_some(commits_count, |this, count| {
this.suffix(CountBadge::new(count))
}),
)
.into_any_element()
}
fn render_discussion(&mut 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 let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
let (author, picture, age, root_id) = {
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 placeholder("Pull request not found", cx);
};
let profile = ProfileStore::global(cx).read(cx).get(&root.pubkey);
(
profile.name(),
profile.picture(),
relative_time(root.created_at),
root.id,
)
};
h_flex()
.flex_1()
.w_full()
.min_h_0()
.child(
v_flex()
.flex_1()
.min_w_0()
.p_4()
.gap_6()
.overflow_y_scrollbar()
.child(
v_flex()
.px_4()
.gap_8()
.child(
v_flex()
.gap_4()
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
UserAvatar::new(author.clone())
.picture(picture),
)
.child(author),
)
.child(SharedString::from("commented"))
.child(
div()
.text_color(cx.theme().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, 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, cx))
.into_any_element()
}
fn render_files_tab(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.flex_1()
.w_full()
.min_h_0()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
h_flex()
.flex_1()
.w_full()
.min_h_0()
.overflow_hidden()
.child(self.pane.clone())
.into_any_element()
}
/// Full-height Commits tab.
///
/// Every commit of the patch series, or a status message while loading or empty.
fn render_commits_tab(&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 let Some(error) = self.error.clone() {
return placeholder(&error, cx);
}
if self.commits.is_empty() {
return placeholder("No commits found", cx);
}
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
v_virtual_list(
cx.entity().clone(),
"pr-commits",
self.commit_item_sizes.clone(),
move |this, range, _window, cx| {
range
.map(|ix| this.render_commit_row(ix, &this.commits[ix], cx))
.collect()
},
)
.track_scroll(&self.commit_scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.commit_scroll_handle)),
)
.into_any_element()
}
/// One row of the commits tab, id, summary, author and time.
///
/// Clicking a row opens the commit's diff in the bottom dock.
fn render_commit_row(
&self,
ix: usize,
commit: &FileCommit,
cx: &mut Context<Self>,
) -> AnyElement {
let meta = commit_meta(commit);
let id = commit.id.clone();
h_flex()
.id(ix)
.px_4()
.h(px(ROW_HEIGHT))
.gap_2()
.items_center()
.text_sm()
.border_b(px(1.))
.border_color(cx.theme().border)
.hover(|this| this.bg(cx.theme().list_hover))
.child(
div()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(commit.id.clone()),
)
.child(
div()
.flex_1()
.min_w_0()
.text_ellipsis()
.whitespace_nowrap()
.child(commit.summary.clone()),
)
.when(!meta.is_empty(), |this| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(meta)),
)
})
// Commits parsed from the nostr patch set may not exist in any local clone.
// Only git-backed PRs open a diff viewer.
.when_some(self.worktree.clone(), |this, worktree| {
this.on_click(cx.listener(move |this, _event, window, cx| {
this.open_commit_diff(worktree.clone(), &id, window, cx);
}))
})
.into_any_element()
}
/// Always-visible header with a status badge and title, like the issue panel.
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();
};
(
activity_subject(root),
store.status_of(root),
branch_name_of(root),
root.pubkey,
)
};
// Only the PR author may publish revisions, NIP-34 kind 1619.
let backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author);
v_flex()
.px_4()
.mb_4()
.child(
h_flex()
.w_full()
.min_h_16()
.gap_2()
.child(status_badge(status, cx))
.child(
div()
.flex_1()
.min_w_0()
.font_semibold()
.line_height(relative(1.2))
.child(title),
),
)
.child(
h_flex()
.gap_3()
.when_some(branch, |this, branch| {
this.child(
Button::new("branch")
.ghost()
.small()
.icon(CustomIconName::GitBranch)
.label(branch),
)
})
.when(can_update, |this| {
this.child(
Button::new("update-pr")
.ghost()
.small()
.icon(CustomIconName::GitPullRequest)
.label("Update")
.tooltip("Publish a new revision of this pull request")
.on_click(cx.listener({
let store = self.store.clone();
let pr_id = self.pr_id;
move |_this, _event, window, cx| {
let root = store
.read(cx)
.pull_requests
.iter()
.find(|pr| {
pr.id == pr_id && pr.kind == Kind::GitPullRequest
})
.cloned();
if let Some(root) = root {
open_update_pull_request_dialog(
store.clone(),
root,
window,
cx,
);
}
}
})),
)
})
.when_some(current_commit, |this, id| {
this.child(
h_flex()
.gap_1()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(id.clone())
.child(Clipboard::new("pr-commit").value(&id)),
)
}),
)
.into_any_element()
}
}
/// Open the update pull request dialog.
fn open_update_pull_request_dialog(
store: Entity<RepoStore>,
root: Event,
window: &mut Window,
cx: &mut App,
) {
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
});
// Both the dialog body and submit button capture the root event.
// Share it instead of cloning into each closure.
let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| {
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("Update pull request"))
.child(DialogDescription::new().child(
"Publish a new revision with the output of `git format-patch`.",
)),
)
.child(
v_form().child(
field()
.label("Patch")
.child(Textarea::new(&patch).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Update pull request")
.tooltip("Update pull request")
.on_click({
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
move |_event, window, cx| {
let patch = patch.read(cx).value().to_string();
store.update(cx, |store, cx| {
store.update_pull_request(&root, patch, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
/// The `c` tag of a PR event, the commit the proposal points at.
/// One-line commit metadata for the commits list.
///
/// Author and relative time, whichever is available.
fn commit_meta(commit: &FileCommit) -> String {
let author = commit.author.trim();
let time = commit.time > 0;
match (author.is_empty(), time) {
(false, true) => format!("{author} · {}", relative_time_secs(commit.time)),
(false, false) => author.to_string(),
(true, true) => relative_time_secs(commit.time),
(true, false) => String::new(),
}
}
impl BasePanel for PullRequestDetailView {
fn panel_name(&self) -> &'static str {
"pull-request-detail"
}
}
impl Panel for PullRequestDetailView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
let hex = self.pr_id.to_hex();
let id = SharedString::from(&hex[..8]);
let title = if self.repo_name.is_empty() {
id
} else {
SharedString::from(format!("{}/{}", self.repo_name, id))
};
div().text_sm().child(title)
}
}
impl EventEmitter<PanelEvent> for PullRequestDetailView {}
impl Focusable for PullRequestDetailView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for PullRequestDetailView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.image_cache(gpui::retain_all("pull-request-detail"))
.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_tab(cx)),
_ => this.child(self.render_commits_tab(cx)),
})
}
}
#[cfg(test)]
mod tests {
use super::*;
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
#[test]
fn commit_meta_combines_author_and_time() {
let commit = |author: &str, time: i64| FileCommit {
id: COMMIT_HEX.into(),
summary: "summary".into(),
description: None,
author: author.into(),
time,
};
assert_eq!(commit_meta(&commit("Alice", 0)), "Alice");
assert_eq!(commit_meta(&commit("", 0)), "");
assert!(!commit_meta(&commit("", 1_000_000)).is_empty());
assert!(!commit_meta(&commit("Alice", 1_000_000)).is_empty());
}
}