add pull request viewer

This commit is contained in:
2026-08-22 20:38:22 +07:00
parent 385718d74d
commit 7fb7275233
9 changed files with 1737 additions and 187 deletions
+1 -1
View File
@@ -9,6 +9,6 @@ pub mod status;
pub use addr::{RepoAddr, repo_addr}; pub use addr::{RepoAddr, repo_addr};
pub use clone_url::{CloneTarget, parse_clone_url}; pub use clone_url::{CloneTarget, parse_clone_url};
pub use deletions::Deletions; pub use deletions::Deletions;
pub use model::{activity_subject, Announcement}; pub use model::{Announcement, activity_subject, pull_request_patch};
pub use state::parse_state; pub use state::parse_state;
pub use status::{RepoStatus, references_root, resolve_status}; pub use status::{RepoStatus, references_root, resolve_status};
+244
View File
@@ -50,6 +50,123 @@ pub fn activity_subject(event: &Event) -> SharedString {
.unwrap_or(SharedString::from("Untitled")) .unwrap_or(SharedString::from("Untitled"))
} }
/// The patch set of a pull request: the root patch event (kind `1617`) the
/// PR references via its `e` tag, plus every patch of the set chained to it
/// with NIP-10 `e` reply tags, in series order (oldest first). When the PR
/// has no `e` tag, falls back to the patch producing the PR's tip commit
/// (its `commit`/`r` tag, per NIP-34) and walks the reply chain backward to
/// the root.
///
/// Returns an empty list when no patch event can be linked to the PR.
pub fn pull_request_patches<'a>(
pr: &Event,
patches: impl IntoIterator<Item = &'a Event>,
) -> Vec<&'a Event> {
let patches: Vec<&'a Event> = patches.into_iter().collect();
// The PR references its root patch via an `e` tag; follow the NIP-10
// reply chain forward from there (each patch of the set replies to the
// previous one). Among several replies (a revision), the newest wins.
if let Some(root_id) = pr.tags.event_ids().next()
&& let Some(root) = patches.iter().find(|patch| patch.id == root_id)
{
return forward_series(root, &patches);
}
// No `e` tag: the last patch of the set carries the PR's tip commit in
// its `commit`/`r` tag; walk the reply chain backward to the root.
let Some(tip) = current_commit_of(pr) else {
return Vec::new();
};
let Some(last) = patches
.iter()
.filter(|patch| patch_produces_commit(patch, &tip))
.max_by_key(|patch| patch.created_at)
.copied()
else {
return Vec::new();
};
let mut series = vec![last];
loop {
let Some(prev_id) = series.last().unwrap().tags.event_ids().next() else {
break;
};
let Some(prev) = patches
.iter()
.find(|patch| patch.id == prev_id && !series.contains(patch))
.copied()
else {
break;
};
series.push(prev);
}
series.reverse();
series
}
/// The patch content of a pull request: the contents of every patch event of
/// its patch set (see [`pull_request_patches`]) joined in series order,
/// falling back to the PR's own content for older PRs that carried the
/// patch inline.
pub fn pull_request_patch<'a>(pr: &Event, patches: impl IntoIterator<Item = &'a Event>) -> String {
let patches: Vec<&'a Event> = patches.into_iter().collect();
let series = pull_request_patches(pr, patches.iter().copied());
if series.is_empty() {
return pr.content.clone();
}
series
.iter()
.map(|patch| patch.content.as_str())
.collect::<Vec<_>>()
.join("\n")
}
/// The chain of patches replying to `root` (NIP-10 `e` tags), oldest first.
fn forward_series<'a>(root: &'a Event, patches: &[&'a Event]) -> Vec<&'a Event> {
let mut series = vec![root];
loop {
let next = patches
.iter()
.filter(|patch| !series.contains(patch))
.filter(|patch| {
patch
.tags
.event_ids()
.any(|id| id == series.last().unwrap().id)
})
.max_by_key(|patch| patch.created_at);
let Some(next) = next else {
break;
};
series.push(next);
}
series
}
/// The `c` tag of an event (tip of the proposed branch), as hex.
fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// Whether `patch` produces `commit` (its `commit` or `r` tag), so clients
/// can find existing patches for a specific commit.
fn patch_produces_commit(patch: &Event, commit: &str) -> bool {
patch
.tags
.iter()
.any(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Commit(c) | Nip34Tag::Reference(c)) => c.to_string() == commit,
_ => false,
})
}
impl Announcement { impl Announcement {
/// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing. /// Parse a kind `30617` event. Returns `None` if the kind is wrong or the `d` tag is missing.
pub fn from_event(event: &Event) -> Option<Self> { pub fn from_event(event: &Event) -> Option<Self> {
@@ -230,4 +347,131 @@ mod tests {
assert!(announcement.name.is_none()); assert!(announcement.name.is_none());
assert!(announcement.web.is_empty()); assert!(announcement.web.is_empty());
} }
/// Build a signed PR event with the given tags and content.
fn pr_event(content: &str, tags: Vec<Tag>) -> Event {
EventBuilder::new(Kind::GitPullRequest, content)
.tags(tags)
.finalize(&keys())
.expect("signed event")
}
#[test]
fn pull_request_patch_prefers_linked_patch_event() {
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
.finalize(&keys())
.expect("signed event");
let pr = pr_event("description", vec![Tag::event(patch.id)]);
assert_eq!(pull_request_patch(&pr, [&patch]), "patch-content");
}
#[test]
fn pull_request_patch_falls_back_to_inline_content() {
// Older PRs carried the patch in the content; no linked patch event.
let pr = pr_event("patch-inline", vec![]);
assert_eq!(pull_request_patch(&pr, [] as [&Event; 0]), "patch-inline");
}
#[test]
fn pull_request_patch_ignores_unrelated_patch_events() {
let patch = EventBuilder::new(Kind::GitPatch, "patch-content")
.finalize(&keys())
.expect("signed event");
let pr = pr_event("description", vec![]);
assert_eq!(pull_request_patch(&pr, [&patch]), "description");
}
/// Build a signed patch event with a controlled `created_at`.
fn patch_event(content: &str, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(Kind::GitPatch, content)
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
#[test]
fn pull_request_patch_joins_the_whole_patch_set() {
// NIP-34: a PR references the root patch; later patches of the set
// reply to the previous one (NIP-10 `e` tags).
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let pr = pr_event("description", vec![Tag::event(root.id)]);
assert_eq!(
pull_request_patch(&pr, [&root, &second]),
"patch-one\npatch-two"
);
assert_eq!(
pull_request_patches(&pr, [&root, &second]),
vec![&root, &second]
);
}
#[test]
fn pull_request_patches_walks_the_reply_chain_in_order() {
let root = patch_event("patch-one", vec![], 100);
let second = patch_event("patch-two", vec![Tag::event(root.id)], 200);
let third = patch_event("patch-three", vec![Tag::event(second.id)], 300);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = pull_request_patches(&pr, [&third, &root, &second]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two", "patch-three"]
);
}
#[test]
fn pull_request_patches_ignores_unrelated_replies() {
let root = patch_event("patch-one", vec![], 100);
let other = patch_event("other-patch", vec![Tag::event(root.id)], 250);
// A patch replying to a different root is not part of the set.
let stranger = patch_event("stranger", vec![], 150);
let pr = pr_event("description", vec![Tag::event(root.id)]);
let series = pull_request_patches(&pr, [&root, &other, &stranger]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "other-patch"]
);
}
#[test]
fn pull_request_patches_finds_the_set_via_the_tip_commit() {
// PRs without an `e` tag: the last patch of the set carries the tip
// commit in its `r` tag; walk the reply chain backward to the root.
let root = patch_event("patch-one", vec![], 100);
let tip = "1111111111111111111111111111111111111111";
let last = patch_event(
"patch-two",
vec![
Tag::event(root.id),
Tag::parse(["r", tip]).expect("valid tag"),
],
200,
);
let pr = pr_event(
"description",
vec![Tag::parse(["c", tip]).expect("valid tag")],
);
let series = pull_request_patches(&pr, [&root, &last]);
assert_eq!(
series
.iter()
.map(|p| p.content.as_str())
.collect::<Vec<_>>(),
vec!["patch-one", "patch-two"]
);
}
} }
+293 -8
View File
@@ -61,7 +61,12 @@ impl GitCache {
for url in clone_urls { for url in clone_urls {
match clone(url, &path) { match clone(url, &path) {
Ok(repo) => return Ok(repo), Ok(repo) => {
// The initial clone uses the default refspecs; also
// fetch the `refs/nostr/*` PR refs.
fetch_all(&repo).ok();
return Ok(repo);
}
Err(e) => last_err = Some(e), Err(e) => last_err = Some(e),
} }
} }
@@ -73,11 +78,23 @@ impl GitCache {
} }
} }
/// Fetch all configured refspecs from `origin`. /// Fetch all configured refspecs from `origin`, plus the `refs/nostr/*`
/// namespace where GRASP mirrors serve pull request branches (one ref per
/// PR event id, as used by ngit).
pub fn fetch_all(repo: &gix::Repository) -> Result<()> { pub fn fetch_all(repo: &gix::Repository) -> Result<()> {
let options = gix::remote::ref_map::Options {
extra_refspecs: vec![
gix::refspec::parse(
gix::bstr::BStr::new("+refs/nostr/*:refs/nostr/*"),
gix::refspec::parse::Operation::Fetch,
)?
.to_owned(),
],
..Default::default()
};
repo.find_remote("origin")? repo.find_remote("origin")?
.connect(gix::remote::Direction::Fetch)? .connect(gix::remote::Direction::Fetch)?
.prepare_fetch(Discard, Default::default())? .prepare_fetch(Discard, options)?
.receive(Discard, &IS_INTERRUPTED)?; .receive(Discard, &IS_INTERRUPTED)?;
Ok(()) Ok(())
} }
@@ -483,10 +500,6 @@ pub fn worktree_commit_diff(workdir: &Path, id: &str) -> Result<CommitDiff> {
} }
fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> { fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::object::tree::diff::Change;
use gix::objs::tree::EntryKind;
let commit_id = repo.rev_parse_single(id.as_bytes())?; let commit_id = repo.rev_parse_single(id.as_bytes())?;
let commit = commit_id.object()?.into_commit(); let commit = commit_id.object()?.into_commit();
let new_tree = commit.tree()?; let new_tree = commit.tree()?;
@@ -494,8 +507,65 @@ fn commit_diff(repo: &gix::Repository, id: &str) -> Result<CommitDiff> {
Some(parent) => Some(parent.object()?.into_commit().tree()?), Some(parent) => Some(parent.object()?.into_commit().tree()?),
None => None, None => None,
}; };
tree_diff(repo, old_tree.as_ref(), &new_tree)
}
let changes = repo.diff_tree_to_tree(old_tree.as_ref(), Some(&new_tree), None)?; /// The changes between two commits (`base`..`tip`), like `git diff base tip`.
/// Same file handling as [`worktree_commit_diff`] (directories and
/// submodules are skipped, files are sorted by path).
pub fn worktree_commit_range_diff(workdir: &Path, base: &str, tip: &str) -> Result<CommitDiff> {
let repo = open_with_cache(workdir)?;
let base_tree = repo
.rev_parse_single(base.as_bytes())?
.object()?
.into_commit()
.tree()?;
let tip_tree = repo
.rev_parse_single(tip.as_bytes())?
.object()?
.into_commit()
.tree()?;
tree_diff(&repo, Some(&base_tree), &tip_tree)
}
/// Commits in the range `base`..`tip`, newest first, like `git log base..tip`.
pub fn worktree_commit_range_commits(
workdir: &Path,
base: &str,
tip: &str,
) -> Result<Vec<FileCommit>> {
use gix::traverse::commit::simple::CommitTimeOrder;
let repo = open_with_cache(workdir)?;
let base_id = repo.rev_parse_single(base.as_bytes())?;
let tip_id = repo.rev_parse_single(tip.as_bytes())?;
let walk = repo
.rev_walk([tip_id])
.sorting(gix::revision::walk::Sorting::ByCommitTime(
CommitTimeOrder::NewestFirst,
))
.with_hidden([base_id]);
let mut commits = Vec::new();
for info in walk.all()? {
let info = info?;
commits.push(file_commit(&info.object()?, false)?);
}
Ok(commits)
}
/// The changes between two trees, used by both [`commit_diff`] and
/// [`worktree_commit_range_diff`].
fn tree_diff(
repo: &gix::Repository,
old_tree: Option<&gix::Tree<'_>>,
new_tree: &gix::Tree<'_>,
) -> Result<CommitDiff> {
use gix::diff::blob::platform::prepare_diff::Operation;
use gix::object::tree::diff::Change;
use gix::objs::tree::EntryKind;
let changes = repo.diff_tree_to_tree(old_tree, Some(new_tree), None)?;
let mut cache = repo.diff_resource_cache_for_tree_diff()?; let mut cache = repo.diff_resource_cache_for_tree_diff()?;
let mut files = Vec::new(); let mut files = Vec::new();
@@ -630,6 +700,88 @@ pub fn patch_diffs(patch: &str) -> Result<CommitDiff> {
Ok(CommitDiff { files }) Ok(CommitDiff { files })
} }
/// Commits of a `git format-patch` output (a single patch or a patch
/// series), parsed from the mbox envelope headers of each patch: commit id,
/// author, summary and author time. Entries appear in patch order (oldest
/// first, as produced by `git format-patch`).
pub fn patch_commits(patch: &str) -> Vec<FileCommit> {
let lines: Vec<&str> = patch.lines().collect();
let mut commits = Vec::new();
let mut i = 0;
while i < lines.len() {
// A patch starts with its `From <id> <date>` envelope line.
let Some(rest) = lines[i].strip_prefix("From ") else {
i += 1;
continue;
};
let Some(id) = rest.split_whitespace().next() else {
i += 1;
continue;
};
if id.len() != 40 {
i += 1;
continue;
}
let mut author = String::new();
let mut summary = String::new();
let mut time = 0i64;
// Envelope headers of this patch, up to the blank line separating
// them from the commit message.
i += 1;
while i < lines.len() && !lines[i].is_empty() {
let header = lines[i];
if let Some(value) = header.strip_prefix("From: ") {
author = name_from_address(value);
} else if let Some(value) = header.strip_prefix("Subject: ") {
summary = strip_patch_prefix(value);
} else if let Some(value) = header.strip_prefix("Date: ") {
time = gix::date::parse(value.trim(), None)
.map(|t| t.seconds)
.unwrap_or(0);
}
i += 1;
}
commits.push(FileCommit {
id: id.to_string(),
summary,
description: None,
author,
time,
});
}
commits
}
/// The name part of a `From: Name <email>` header value.
fn name_from_address(from: &str) -> String {
match from.trim().find('<') {
Some(ix) => from[..ix].trim().to_string(),
None => from.trim().to_string(),
}
}
/// Strip the `[PATCH]`, `[PATCH 1/2]`, `[RFC PATCH]` … prefix from a patch
/// `Subject:` header.
fn strip_patch_prefix(subject: &str) -> String {
let trimmed = subject.trim();
let Some(rest) = trimmed.strip_prefix('[') else {
return trimmed.to_string();
};
let Some(end) = rest.find(']') else {
return trimmed.to_string();
};
if rest[..end].to_ascii_lowercase().contains("patch") {
rest[end + 1..].trim().to_string()
} else {
trimmed.to_string()
}
}
/// Parse one file's diff section: everything after its `diff --git` header /// Parse one file's diff section: everything after its `diff --git` header
/// up to the next section (or the end of the patch). Returns the section /// up to the next section (or the end of the patch). Returns the section
/// and the index of the first unconsumed line. /// and the index of the first unconsumed line.
@@ -1650,6 +1802,52 @@ mod tests {
assert_eq!(deleted.hunks[0].lines[0].new, None); assert_eq!(deleted.hunks[0].lines[0].new, None);
} }
#[test]
fn commit_range_diff_lists_changes_between_two_commits() {
let (dir, repo) = fixture(&[("a.txt", b"a\n"), ("b.txt", b"b\n")]);
commit_all(&repo, "first");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"changed\n").expect("write");
std::fs::write(dir.path().join("c.txt"), b"new\n").expect("write");
commit_all(&repo, "second");
let tip = repo.head_id().expect("head").to_string();
let diff = worktree_commit_range_diff(dir.path(), &base, &tip).expect("diff");
let by_path: HashMap<&str, &FileDiff> = diff
.files
.iter()
.map(|file| (file.path.as_str(), file))
.collect();
assert_eq!(by_path.len(), 2);
assert_eq!(by_path["a.txt"].status, DiffStatus::Modified);
assert_eq!(by_path["a.txt"].insertions, 1);
assert_eq!(by_path["a.txt"].deletions, 1);
assert_eq!(by_path["c.txt"].status, DiffStatus::Added);
// b.txt is unchanged between the two commits.
assert!(diff.files.iter().all(|file| file.path != "b.txt"));
}
#[test]
fn commit_range_commits_lists_only_new_commits_newest_first() {
let (dir, repo) = fixture(&[("a.txt", b"one\n")]);
commit_all(&repo, "one");
let base = repo.head_id().expect("head").to_string();
std::fs::write(dir.path().join("a.txt"), b"two\n").expect("write");
commit_all(&repo, "two");
std::fs::write(dir.path().join("a.txt"), b"three\n").expect("write");
commit_all(&repo, "three");
let tip = repo.head_id().expect("head").to_string();
let commits = worktree_commit_range_commits(dir.path(), &base, &tip).expect("commits");
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].summary, "three");
assert_eq!(commits[1].summary, "two");
}
#[test] #[test]
fn commit_diff_reports_binary_files_without_hunks() { fn commit_diff_reports_binary_files_without_hunks() {
let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]); let (_dir, repo) = fixture(&[("blob.bin", b"\x00\x01\x02")]);
@@ -1881,6 +2079,93 @@ index 3..4 100644
assert_eq!(diff.files[1].deletions, 1); assert_eq!(diff.files[1].deletions, 1);
} }
#[test]
fn patch_commits_lists_every_patch_in_order() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: Alice <alice@example.com>
Date: Tue, 1 Aug 2023 10:00:00 +0200
Subject: [PATCH 1/2] first
body one
---
a.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/a.txt b/a.txt
@@ -1 +1,2 @@
a
+b
From 2222222222222222222222222222222222222222 Mon Sep 17 00:00:00 2001
From: Bob <bob@example.com>
Date: Wed, 2 Aug 2023 11:30:00 +0000
Subject: [PATCH 2/2] second
body two
---
b.txt | 1 +
1 file changed, 1 insertion(+)
diff --git a/b.txt b/b.txt
@@ -1 +1,2 @@
x
+y
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 2);
assert_eq!(commits[0].id, "1111111111111111111111111111111111111111");
assert_eq!(commits[0].summary, "first");
assert_eq!(commits[0].author, "Alice");
assert_eq!(commits[0].time, 1690876800);
assert_eq!(commits[1].id, "2222222222222222222222222222222222222222");
assert_eq!(commits[1].summary, "second");
assert_eq!(commits[1].author, "Bob");
assert_eq!(commits[1].time, 1690975800);
}
#[test]
fn patch_commits_strips_patch_subject_prefixes() {
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
From: A <a@b.c>
Subject: [RFC PATCH v3 4/7] the real title
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "the real title");
}
#[test]
fn patch_commits_handles_missing_headers() {
// A hand-written patch without author/date headers still lists a
// commit; time stays 0 and the author falls back to the raw value.
let patch = r#"From 1111111111111111111111111111111111111111 Mon Sep 17 00:00:00 2001
Subject: [PATCH] plain
---
"#;
let commits = patch_commits(patch);
assert_eq!(commits.len(), 1);
assert_eq!(commits[0].summary, "plain");
assert_eq!(commits[0].author, "");
assert_eq!(commits[0].time, 0);
}
#[test]
fn patch_commits_ignores_non_patch_lines() {
assert!(patch_commits("").is_empty());
assert!(patch_commits("just some text\nFrom 123\n").is_empty());
// A diff-only body (no mbox envelope) has no commits.
let patch = "diff --git a/x b/x\n--- a/x\n+++ b/x\n";
assert!(patch_commits(patch).is_empty());
}
#[test] #[test]
fn marks_binary_sections() { fn marks_binary_sections() {
let patch = r#"diff --git a/img.png b/img.png let patch = r#"diff --git a/img.png b/img.png
+68 -35
View File
@@ -3,7 +3,9 @@ use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use gpui::{AppContext, Context, Subscription, Task}; use gpui::{AppContext, Context, Subscription, Task};
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use signed_core::{Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state}; use signed_core::{
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
};
use crate::backend::{Backend, BackendEvent}; use crate::backend::{Backend, BackendEvent};
use crate::git_store::GitStore; use crate::git_store::GitStore;
@@ -211,6 +213,7 @@ impl RepoStore {
}); });
self.tasks.retain(|task| !task.is_ready()); self.tasks.retain(|task| !task.is_ready());
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let (announcement, state, issues, patches, pull_requests, statuses, comments) = let (announcement, state, issues, patches, pull_requests, statuses, comments) =
match work.await { match work.await {
@@ -277,7 +280,7 @@ impl RepoStore {
/// Number of open issues: issues whose resolved status is /// Number of open issues: issues whose resolved status is
/// [`RepoStatus::Open`] (issues without status events default to open). /// [`RepoStatus::Open`] (issues without status events default to open).
pub fn open_issue_count(&self) -> usize { pub fn issue_count(&self) -> usize {
self.issues self.issues
.iter() .iter()
.filter(|issue| self.status_of(issue) == RepoStatus::Open) .filter(|issue| self.status_of(issue) == RepoStatus::Open)
@@ -287,7 +290,7 @@ impl RepoStore {
/// Number of open pull requests: root PR events (not PR updates, whose /// Number of open pull requests: root PR events (not PR updates, whose
/// status is carried by the root) with a resolved status of /// status is carried by the root) with a resolved status of
/// [`RepoStatus::Open`]. /// [`RepoStatus::Open`].
pub fn open_pull_request_count(&self) -> usize { pub fn pull_request_count(&self) -> usize {
self.pull_requests self.pull_requests
.iter() .iter()
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open) .filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
@@ -336,52 +339,81 @@ impl RepoStore {
self.send(builder, cx); self.send(builder, cx);
} }
/// Open a pull request on this repository: a root PR event whose content /// Open a pull request on this repository: a root PR event (kind 1618)
/// is the `git format-patch` output of the proposed changes. /// whose content is the markdown description, plus a root patch event
/// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34).
/// ///
/// The branch metadata (branch name, clone URL, merge base, root patch) /// The patch is published first and the PR is sent once the patch
/// isn't known to the UI yet and is left empty; the proposed commit is /// event's id is known, so the two always arrive together. The branch
/// parsed from the patch's `From <commit>` header, falling back to an /// metadata (branch name, clone URL, merge base) isn't known to the UI
/// empty hash for hand-written content. /// yet and is left empty; the proposed commit is parsed from the patch's
/// `From <commit>` header, falling back to an empty hash for hand-written
/// content.
pub fn open_pull_request( pub fn open_pull_request(
&mut self, &mut self,
subject: Option<String>, subject: Option<String>,
content: String, description: String,
patch: String,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let current_commit = patch_current_commit(&content) self.last_error = None;
let current_commit = patch_current_commit(&patch)
.and_then(|hex| hex.parse().ok()) .and_then(|hex| hex.parse().ok())
.unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20])); .unwrap_or_else(|| bitcoin_hashes::Sha1::from_byte_array([0u8; 20]));
let builder = GitPullRequest {
repository: self.addr.clone(),
content,
subject,
labels: Vec::new(),
branch_name: None,
clone: Vec::new(),
current_commit,
root_patch_event: None,
merge_base: None,
}
.into_event_builder();
self.send(builder, cx);
}
/// Send a root patch (`git format-patch` output) to this repository.
pub fn send_root_patch(&mut self, patch: String, cx: &mut Context<Self>) {
let Ok(root_marker) = Tag::parse(["t", "root"]) else { let Ok(root_marker) = Tag::parse(["t", "root"]) else {
return; return;
}; };
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags([
let builder = EventBuilder::new(Kind::GitPatch, patch).tags([
Tag::coordinate(self.addr.clone(), None), Tag::coordinate(self.addr.clone(), None),
Tag::public_key(self.addr.public_key), Tag::public_key(self.addr.public_key),
root_marker, root_marker,
]); ]);
self.send(builder, cx); let patch_task =
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx));
self.tasks.push(cx.spawn(async move |this, cx| {
let patch_event = match patch_task.await {
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
};
// The PR references the patch event so viewers can find the
// patch without carrying it inline.
let pr_task = this.update(cx, |this, cx| {
let builder = GitPullRequest {
repository: this.addr.clone(),
content: description,
subject,
labels: Vec::new(),
branch_name: None,
clone: Vec::new(),
current_commit,
root_patch_event: Some(patch_event.id),
merge_base: None,
}
.into_event_builder();
Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx))
})?;
if let Err(e) = pr_task.await {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
Ok(())
}));
} }
/// Set the status of a root event (requires being the root author or a maintainer). /// Set the status of a root event (requires being the root author or a maintainer).
@@ -400,8 +432,9 @@ impl RepoStore {
self.send(builder, cx); self.send(builder, cx);
} }
/// Merge a pull request: apply its patch (`git format-patch` output) to /// Merge a pull request: apply its patch (the content of the linked
/// the local clone of this repository, then publish the merged status. /// root patch event) to the local clone of this repository, then publish
/// the merged status.
/// ///
/// Only the repository author may merge. The clone is created on demand /// Only the repository author may merge. The clone is created on demand
/// from the announcement's clone URLs when the repository hasn't been /// from the announcement's clone URLs when the repository hasn't been
@@ -427,7 +460,7 @@ impl RepoStore {
.as_ref() .as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect()) .map(|a| a.clone.iter().map(ToString::to_string).collect())
.unwrap_or_default(); .unwrap_or_default();
let patch = root.content.clone(); let patch = pull_request_patch(root, self.patches.iter());
let root = root.clone(); let root = root.clone();
let apply = cx.background_spawn(async move { let apply = cx.background_spawn(async move {
+7 -132
View File
@@ -1,8 +1,3 @@
//! Commit diff viewer: a panel showing every file a commit changed, with a
//! tree of the changed files on the left and the line diff of the selected
//! file on the right. Opened from the repository detail view by clicking a
//! commit in the Commits tab or the latest-commit button in the header.
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
@@ -18,34 +13,20 @@ use gpui_component::resizable::{resizable_panel, v_resizable};
use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::tag::Tag; use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeItem, TreeState, tree}; use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
}; };
use signed_git::{CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff}; use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff};
use utils::relative_time_secs; use utils::relative_time_secs;
use super::helpers::{build_tree_items, placeholder, tree_items, tree_row}; use super::helpers::{
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
tree_items, tree_row,
};
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Width of one line-number gutter in a diff row.
const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in the virtual diff list.
const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of the virtual diff list: a hunk header, or a line of a hunk.
#[derive(Clone, Copy)]
enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
/// Line `line` of hunk `hunk` of the selected file's diff.
Line { hunk: usize, line: usize },
}
/// Detail panel showing the diff of one commit. /// Detail panel showing the diff of one commit.
pub struct CommitDiffView { pub struct CommitDiffView {
@@ -329,7 +310,7 @@ impl CommitDiffView {
return Vec::new(); return Vec::new();
}; };
range range
.map(|ix| Self::render_diff_row(&file.hunks, this.rows[ix], cx)) .map(|ix| render_diff_row(&file.hunks, this.rows[ix], cx))
.collect() .collect()
}, },
) )
@@ -397,82 +378,6 @@ impl CommitDiffView {
.into_any_element() .into_any_element()
} }
/// One row of the virtual diff list: a hunk header or a single line.
fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => Self::render_diff_line(&hunks[hunk].lines[line], cx),
}
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has
// the same height, so long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Header: commit id, summary, author/time and overall change stats. /// Header: commit id, summary, author/time and overall change stats.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let commit = &self.commit; let commit = &self.commit;
@@ -550,36 +455,6 @@ impl CommitDiffView {
} }
} }
/// Find a tree item by id, searching into nested children.
fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
/// The rows of `file`'s diff: one header row per hunk, then its lines.
fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
impl Panel for CommitDiffView { impl Panel for CommitDiffView {
fn panel_name(&self) -> &'static str { fn panel_name(&self) -> &'static str {
"commit_diff" "commit_diff"
@@ -1,17 +1,15 @@
//! Pure helpers for the repository detail view: file-tree building, code
//! preview helpers and small element builders.
use std::collections::HashMap; use std::collections::HashMap;
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use assets::CustomIconName; use assets::CustomIconName;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{AnyElement, App, Window, div, px}; use gpui::{AnyElement, App, SharedString, Window, div, px};
use gpui_component::list::ListItem; use gpui_component::list::ListItem;
use gpui_component::tooltip::Tooltip; use gpui_component::tooltip::Tooltip;
use gpui_component::tree::{TreeEntry, TreeItem}; use gpui_component::tree::{TreeEntry, TreeItem};
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use signed_core::RepoStatus; use signed_core::RepoStatus;
use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileDiff};
/// A `Send` file-tree node: the tree is built on a background thread and /// A `Send` file-tree node: the tree is built on a background thread and
/// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot /// converted into [`TreeItem`]s (which hold `Rc` state, so they cannot
@@ -269,6 +267,131 @@ pub(super) fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
.into_any_element() .into_any_element()
} }
/// Width of one line-number gutter in a diff row.
pub(super) const GUTTER_WIDTH: f32 = 44.;
/// Height of one row in a virtual diff list.
pub(super) const DIFF_ROW_HEIGHT: f32 = 20.;
/// One row of a virtual diff list: a hunk header, or a line of a hunk.
/// Shared by the commit diff and pull request diff viewers.
#[derive(Clone, Copy)]
pub(super) enum DiffRow {
Hunk {
old_start: u32,
old_lines: u32,
new_start: u32,
new_lines: u32,
},
/// Line `line` of hunk `hunk` of the selected file's diff.
Line { hunk: usize, line: usize },
}
/// The rows of `file`'s diff: one header row per hunk, then its lines.
pub(super) fn diff_rows(file: &FileDiff) -> Vec<DiffRow> {
let mut rows = Vec::new();
for (hunk_ix, hunk) in file.hunks.iter().enumerate() {
rows.push(DiffRow::Hunk {
old_start: hunk.old_start,
old_lines: hunk.old_lines,
new_start: hunk.new_start,
new_lines: hunk.new_lines,
});
rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line {
hunk: hunk_ix,
line,
}));
}
rows
}
/// One row of the virtual diff list: a hunk header or a single line.
pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement {
match row {
DiffRow::Hunk {
old_start,
old_lines,
new_start,
new_lines,
} => div()
.px_2()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.bg(cx.theme().muted)
.border_y(px(1.))
.border_color(cx.theme().border)
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!(
"@@ -{},{} +{},{} @@",
old_start, old_lines, new_start, new_lines
)))
.into_any_element(),
DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx),
}
}
/// One diff line: old and new line numbers in gutters, then the content,
/// tinted by kind (addition / deletion / context).
pub(super) fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement {
let bg = match line.kind {
DiffLineKind::Addition => Some(cx.theme().success.opacity(0.2)),
DiffLineKind::Deletion => Some(cx.theme().danger.opacity(0.2)),
DiffLineKind::Context => None,
};
let gutter = cx.theme().muted_foreground;
// Fixed height and nowrap: the virtual list assumes every row has
// the same height, so long lines are clipped instead of wrapped.
h_flex()
.w_full()
.h(px(DIFF_ROW_HEIGHT))
.items_center()
.font_family(cx.theme().mono_font_family.clone())
.text_xs()
.when_some(bg, |this, bg| this.bg(bg))
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.old.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.w(px(GUTTER_WIDTH))
.flex_none()
.pr_2()
.text_right()
.text_color(gutter)
.child(line.new.map(|n| n.to_string()).unwrap_or_default()),
)
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.whitespace_nowrap()
.text_color(cx.theme().foreground)
.child(line.text.clone()),
)
.into_any_element()
}
/// Find a tree item by id, searching into nested children.
pub(super) fn find_item<'a>(items: &'a [TreeItem], id: Option<&str>) -> Option<&'a TreeItem> {
let id = id?;
items.iter().find_map(|item| {
if item.id.as_ref() == id {
Some(item)
} else {
find_item(&item.children, Some(id))
}
})
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -34,6 +34,7 @@ mod diff;
mod helpers; mod helpers;
mod issue_detail; mod issue_detail;
mod issues; mod issues;
mod pull_request_detail;
mod pull_requests; mod pull_requests;
use browser::{ use browser::{
@@ -945,8 +946,8 @@ impl RepoDetailView {
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); let store = self.store.read(cx);
let announcement = store.announcement.as_ref().unwrap_or(&self.initial); let announcement = store.announcement.as_ref().unwrap_or(&self.initial);
let issue_count = store.open_issue_count(); let issue_count = store.issue_count();
let pull_request_count = store.open_pull_request_count(); let pull_request_count = store.pull_request_count();
let name = self.display_name(cx); let name = self.display_name(cx);
let description = announcement.description(); let description = announcement.description();
@@ -0,0 +1,971 @@
use std::path::PathBuf;
use std::rc::Rc;
use dock::{Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
ScrollStrategy, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, size,
};
use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::list::ListItem;
use gpui_component::resizable::{resizable_panel, v_resizable};
use gpui_component::scroll::{ScrollableElement, Scrollbar};
use gpui_component::spinner::Spinner;
use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{
ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
};
use nostr::prelude::{Event, EventId, Kind, Nip34Tag};
use signed_core::{activity_subject, pull_request_patch};
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
use signed_state::{GitStore, ProfileStore, RepoStore};
use utils::{relative_time, relative_time_secs};
use super::helpers::{
DIFF_ROW_HEIGHT, DiffRow, build_tree_items, diff_rows, find_item, placeholder, render_diff_row,
status_badge, tree_items, tree_row,
};
/// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.;
/// Detail panel of a single pull request.
pub struct PullRequestDetailView {
focus_handle: FocusHandle,
/// 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 "leave a comment" textarea.
comment_input: Entity<TextareaState>,
/// Root PR's content, shown as plain text.
description: SharedString,
/// Tip commit of the PR: the latest update's `c` tag, else the root's.
current_commit: Option<SharedString>,
/// Commits of the patch series, in patch order (oldest first).
commits: Vec<FileCommit>,
/// Parsed file changes of the patch; `None` while loading or on failure.
diff: Option<CommitDiff>,
/// The patch is being parsed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer state.
tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column.
selected_file: Option<SharedString>,
/// Rows of the selected file's diff (hunk headers + lines).
rows: Vec<DiffRow>,
/// Per-row heights of [`Self::rows`].
item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle,
/// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>,
/// Subscriptions keeping the view live as the store refreshes.
_subscriptions: Vec<Subscription>,
}
impl PullRequestDetailView {
pub fn new(
store: Entity<RepoStore>,
pr_id: EventId,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
// PR author avatars stay in the shared cache until the panel
// closes; free them then.
crate::image_cache::clear_on_release(&cx.entity(), window, cx);
let tree_state = cx.new(|cx| TreeState::new(cx));
let comment_input =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Leave a comment…"));
// Re-render when the store refreshes (new comments, status changes).
let subscriptions = vec![cx.observe(&store, |_this, _store, cx| cx.notify())];
// Defer loading until the window is ready, like the commit diff view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
focus_handle: cx.focus_handle(),
store,
pr_id,
comment_input,
description: SharedString::default(),
current_commit: None,
commits: Vec::new(),
diff: None,
loading: true,
error: None,
tree_state,
selected_file: None,
rows: Vec::new(),
item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(),
_subscriptions: subscriptions,
}
}
/// Snapshot the PR events from the store, then compute the file changes
/// and commit list on a background task and populate the tree.
///
/// The changes come from the PR's patch set (NIP-34 `e`-linked patch
/// events) when present; otherwise they live in the git repository
/// (`c`, `clone` and `merge-base` tags, per NIP-34), so the clone is
/// fetched and the `merge-base..tip` range is diffed.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let cache = GitStore::global(cx).cache().clone();
let (description, patch, current_commit, merge_base, clone_urls, addr, has_patch_link) = {
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 {
self.loading = false;
self.error = Some("Pull request not found".into());
cx.notify();
return;
};
let update = latest_update(store.pull_requests.iter(), &root.id);
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.iter().map(ToString::to_string).collect())
});
(
root.content.clone(),
pull_request_patch(root, store.patches.iter()),
tip,
base,
clone_urls.unwrap_or_default(),
store.addr().clone(),
root.tags.event_ids().next().is_some(),
)
};
self.description = description.into();
let task = cx.spawn_in(window, async move |this, cx| {
// Parse the nostr patch set first.
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 the git repository: 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 = merge_base.clone();
let tip = current_commit.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"))?;
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 with 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))
})
.await,
)
};
let (diff, commits) = match git {
Some(Ok((diff, commits))) => (Ok(diff), commits),
Some(Err(error)) => (Err(error), Vec::new()),
None => (nostr_diff, nostr_commits),
};
this.update_in(cx, |this, _window, cx| {
this.loading = false;
this.current_commit = current_commit.map(SharedString::from);
this.commits = commits;
match diff {
Ok(diff) => {
let mut paths: Vec<PathBuf> = diff
.files
.iter()
.map(|file| PathBuf::from(&file.path))
.collect();
paths.sort();
let items = tree_items(build_tree_items(&paths), true);
let first = diff
.files
.first()
.map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx);
});
this.selected_file = first.clone();
this.diff = Some(diff);
if let Some(path) = first {
this.set_diff_rows(path.as_ref());
}
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
}
/// Show the diff of the file at `path` (selected in the tree).
fn select_file(&mut self, path: &str, cx: &mut Context<Self>) {
self.selected_file = Some(path.into());
self.set_diff_rows(path);
cx.notify();
}
/// Rebuild the virtual list state for the file at `path` and scroll back
/// to the top.
fn set_diff_rows(&mut self, path: &str) {
let Some(diff) = self.diff.as_ref() else {
return;
};
let Some(file) = diff.files.iter().find(|file| file.path == path) else {
return;
};
self.rows = diff_rows(file);
self.item_sizes = Rc::new(vec![size(px(0.), px(DIFF_ROW_HEIGHT)); self.rows.len()]);
self.scroll_handle.scroll_to_item(0, ScrollStrategy::Top);
}
/// One row of the changed-files tree: icon + name, indented by depth.
fn render_tree_item(
ix: usize,
entry: &TreeEntry,
selected: bool,
view: &WeakEntity<Self>,
) -> ListItem {
let view = view.clone();
let id = entry.item().id.clone();
tree_row(ix, entry, selected, move |_window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.select_file(&id, cx));
}
})
}
/// Left column: the changed-files tree.
fn render_tree_column(&self, cx: &mut Context<Self>) -> AnyElement {
let tree_state = self.tree_state.clone();
let view = cx.entity().downgrade();
v_flex()
.h_full()
.w(px(TREE_WIDTH))
.flex_none()
.border_r_1()
.border_color(cx.theme().border)
.child(
div()
.flex_1()
.min_h_0()
.when(self.diff.is_some(), |this| {
this.child(
tree(&tree_state, move |ix, entry, selected, _window, _cx| {
Self::render_tree_item(ix, entry, selected, &view)
})
.p_2(),
)
})
.when(self.diff.is_none() && !self.loading, |this| {
this.child(placeholder("Failed to load diff", cx))
}),
)
.into_any_element()
}
/// Right column: header of the selected file plus its diff.
fn render_detail_column(&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 Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx);
};
let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() {
placeholder("No files changed in this pull request", cx)
} else {
placeholder("Select a file", cx)
};
};
let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else {
return placeholder("File not found", cx);
};
self.render_file_diff(file, cx.entity(), cx)
}
/// The diff of one file: a header with status and stats, then the hunks
/// in a virtual list (a large diff is never materialized per frame).
fn render_file_diff(&self, file: &FileDiff, view: Entity<Self>, cx: &App) -> AnyElement {
let status_label = match file.status {
signed_git::DiffStatus::Added => "A",
signed_git::DiffStatus::Modified => "M",
signed_git::DiffStatus::Deleted => "D",
signed_git::DiffStatus::Renamed => "R",
signed_git::DiffStatus::Copied => "C",
};
let status_color = match file.status {
signed_git::DiffStatus::Added => cx.theme().success,
signed_git::DiffStatus::Modified => cx.theme().info,
signed_git::DiffStatus::Deleted => cx.theme().danger,
signed_git::DiffStatus::Renamed | signed_git::DiffStatus::Copied => {
cx.theme().muted_foreground
}
};
let title = match &file.old_path {
Some(old) => format!("{old}{}", file.path),
None => file.path.clone(),
};
let body: AnyElement = if file.binary {
placeholder("Diff not available", cx)
} else if file.hunks.is_empty() {
placeholder("No content changes", cx)
} else {
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.size_full()
.relative()
.child(
v_virtual_list(
view,
"pr-diff-rows",
sizes,
move |this, range, _window, cx| {
let Some(diff) = this.diff.as_ref() else {
return Vec::new();
};
let Some(path) = this.selected_file.as_deref() else {
return Vec::new();
};
let Some(file) = diff.files.iter().find(|file| file.path == path)
else {
return Vec::new();
};
range
.map(|ix| render_diff_row(&file.hunks, this.rows[ix], 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()
};
v_flex()
.flex_1()
.min_w_0()
.h_full()
.child(
h_flex()
.px_3()
.h_9()
.gap_2()
.items_center()
.child(
div()
.text_xs()
.font_semibold()
.text_color(status_color)
.child(status_label),
)
.child(
div()
.flex_1()
.min_w_0()
.text_xs()
.font_semibold()
.text_ellipsis()
.whitespace_nowrap()
.child(title),
)
.when(!file.binary, |this| {
this.child(
h_flex()
.gap_2()
.text_xs()
.child(
div()
.text_color(cx.theme().success)
.child(format!("+{}", file.insertions)),
)
.child(
div()
.text_color(cx.theme().danger)
.child(format!("-{}", file.deletions)),
),
)
}),
)
.child(div().id("pr-diff-body").flex_1().min_h_0().child(body))
.into_any_element()
}
/// Top panel: title, status, author, description, commits and comments,
/// one scrollable column with the comment form pinned at the bottom.
fn render_conversation(&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 (title, author, picture, status, age, root_id, branch) = {
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);
(
activity_subject(root),
profile.name(),
profile.picture(),
store.status_of(root),
relative_time(root.created_at),
root.id,
branch_name_of(root),
)
};
let current_commit = self.current_commit.clone();
v_flex()
.size_full()
.child(
v_flex()
.flex_1()
.min_h_0()
.px_4()
.py_3()
.gap_4()
.overflow_y_scrollbar()
.child(
// Title row: status badge + subject.
h_flex()
.gap_2()
.items_center()
.child(status_badge(status, cx))
.child(div().font_semibold().child(title)),
)
.child(
// Author, age, tip commit and branch.
h_flex()
.gap_2()
.items_center()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.small(),
)
.child(author),
)
.child(SharedString::from("opened"))
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
)
.when_some(current_commit, |this, id| {
this.child(
div()
.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))
})
.when_some(branch, |this, branch| {
this.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(branch),
)
}),
)
.when(!self.description.is_empty(), |this| {
// Description, plain text for now.
this.child(div().text_sm().child(self.description.clone()))
})
.child(self.render_commits(cx))
.child(self.render_comments(&root_id, cx)),
)
.child(
h_flex()
.px_4()
.py_3()
.border_t_1()
.border_color(cx.theme().border)
.child(self.render_form(&root_id, cx)),
)
.into_any_element()
}
/// The commits of the patch series: id, summary, author and time.
fn render_commits(&self, cx: &App) -> AnyElement {
if self.commits.is_empty() {
return div().into_any_element();
}
v_flex()
.gap_2()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Commits"),
)
.children(self.commits.iter().map(|commit| {
let meta = commit_meta(commit);
h_flex()
.gap_2()
.items_center()
.text_sm()
.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)),
)
})
}))
.into_any_element()
}
fn render_comments(&mut self, id: &EventId, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx);
let comments: Vec<&Event> = store.comments_of(id).collect();
v_flex()
.gap_3()
.when(!comments.is_empty(), |this| {
this.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(format!("Comments ({})", comments.len()))),
)
})
.children(comments.iter().map(|comment| {
let profile = ProfileStore::global(cx).read(cx).get(&comment.pubkey);
let author = profile.name();
let picture = profile.picture();
let age = relative_time(comment.created_at);
v_flex()
.gap_1()
.child(
h_flex()
.gap_2()
.text_sm()
.child(
h_flex()
.gap_1()
.child(
Avatar::new()
.name(author.clone())
.when_some(picture, |this, url| this.src(url))
.xsmall(),
)
.child(author),
)
.child(
div()
.text_color(cx.theme().muted_foreground)
.child(SharedString::from(age)),
),
)
.child(
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
}))
.into_any_element()
}
fn render_form(&mut self, id: &EventId, _cx: &mut Context<Self>) -> AnyElement {
let comment_input = self.comment_input.clone();
let store = self.store.clone();
let id = id.to_owned();
v_flex()
.flex_1()
.min_w_0()
.gap_2()
.child(Textarea::new(&self.comment_input).h(px(72.)))
.child(
h_flex().justify_end().child(
Button::new("pr-comment")
.primary()
.label("Comment")
.tooltip("Post comment")
.on_click(move |_event, window, cx| {
let content = comment_input.read(cx).value().trim().to_string();
if content.is_empty() {
return;
}
let Some(root) = store
.read(cx)
.pull_requests
.iter()
.find(|pr| pr.id == id)
.cloned()
else {
return;
};
store.update(cx, |store, cx| {
store.comment(&root, content, cx);
});
comment_input.update(cx, |input, cx| {
input.set_value("", window, cx);
});
}),
),
)
.into_any_element()
}
}
/// The `c` tag of a PR event (tip of the proposed branch), as hex.
fn current_commit_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::CurrentCommit(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// The `merge-base` tag of a PR event (most recent common ancestor with the
/// target branch), as hex.
fn merge_base_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::MergeBase(commit)) => Some(commit.to_string()),
_ => None,
})
}
/// The `clone` tag of a PR event (URLs where the proposed branch can be
/// fetched), or `None` if the PR has none.
fn clone_urls_of(event: &Event) -> Option<Vec<String>> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::Clone(urls)) => Some(urls.iter().map(ToString::to_string).collect()),
_ => None,
})
}
/// The `branch-name` tag of a PR event, if any.
fn branch_name_of(event: &Event) -> Option<String> {
event
.tags
.iter()
.find_map(|tag| match Nip34Tag::parse(tag.as_slice()) {
Ok(Nip34Tag::BranchName(name)) => Some(name),
_ => None,
})
}
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22
/// `E` tag pointing at the root PR event.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &EventId) -> Option<&'a Event> {
let root_hex = root.to_hex();
events
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| {
e.tags
.iter()
.any(|t| t.kind() == "E" && t.content() == Some(root_hex.as_str()))
})
.max_by_key(|e| e.created_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 Panel for PullRequestDetailView {
fn panel_name(&self) -> &'static str {
"pull-request-detail"
}
fn title(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let subject = self
.store
.read(cx)
.pull_requests
.iter()
.find(|pr| pr.id == self.pr_id && pr.kind == Kind::GitPullRequest)
.map(activity_subject)
.unwrap_or_else(|| SharedString::from("Pull request"));
div().text_sm().child(subject)
}
}
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_resizable("pull-request-detail")
.child(
resizable_panel()
.size(px(320.))
.size_range(px(160.)..px(600.))
.flex_none()
.bg(cx.theme().background)
.child(self.render_conversation(cx)),
)
.child(
resizable_panel().child(
h_flex()
.size_full()
.min_h_0()
.bg(cx.theme().background)
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx)),
),
)
}
}
#[cfg(test)]
mod tests {
use nostr::prelude::*;
use super::*;
const COMMIT_HEX: &str = "1111111111111111111111111111111111111111";
const OTHER_ROOT_HEX: &str = "2222222222222222222222222222222222222222";
fn keys() -> Keys {
Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001")
.expect("valid secret key"),
)
}
/// Build a signed event with a controlled `created_at`.
fn signed(kind: Kind, tags: Vec<Tag>, created_at: u64) -> Event {
EventBuilder::new(kind, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys())
.expect("signed event")
}
fn pr_root() -> Event {
signed(
Kind::GitPullRequest,
vec![
Tag::parse(["c", COMMIT_HEX]).expect("valid tag"),
Tag::parse(["branch-name", "feature/x"]).expect("valid tag"),
],
100,
)
}
#[test]
fn reads_current_commit_and_branch_name() {
let pr = pr_root();
assert_eq!(current_commit_of(&pr).as_deref(), Some(COMMIT_HEX));
assert_eq!(branch_name_of(&pr).as_deref(), Some("feature/x"));
}
#[test]
fn returns_none_without_pr_tags() {
let pr = signed(Kind::GitPullRequest, vec![], 100);
assert_eq!(current_commit_of(&pr), None);
assert_eq!(branch_name_of(&pr), None);
}
#[test]
fn latest_update_picks_newest_revision_of_the_root() {
let root = pr_root();
let root_hex = root.id.to_hex();
let revision = |created_at: u64| {
signed(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", &root_hex]).expect("valid tag")],
created_at,
)
};
// An update revising a different PR must be ignored even though it
// is newer.
let unrelated = signed(
Kind::GitPullRequestUpdate,
vec![Tag::parse(["E", OTHER_ROOT_HEX]).expect("valid tag")],
999,
);
let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root.id).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
}
#[test]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root.id).is_none());
}
#[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());
}
}
@@ -1,7 +1,8 @@
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc;
use assets::CustomIconName; use assets::CustomIconName;
use dock::{DockArea, Panel, PanelEvent}; use dock::{DockArea, DockPlacement, Panel, PanelEvent};
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
@@ -23,6 +24,7 @@ use signed_state::{ProfileStore, RepoStore};
use utils::relative_time; use utils::relative_time;
use super::helpers::{placeholder, status_badge}; use super::helpers::{placeholder, status_badge};
use super::pull_request_detail::PullRequestDetailView;
/// Height of one pull request row in the virtual list: same layout as an /// Height of one pull request row in the virtual list: same layout as an
/// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title /// issue row (8px vertical padding (`py_2`) on top and bottom, a 32px title
@@ -120,7 +122,12 @@ impl PullRequestsView {
let Some(dock_area) = self.dock_area.upgrade() else { let Some(dock_area) = self.dock_area.upgrade() else {
return; return;
}; };
// TODO
let panel = cx.new(|cx| PullRequestDetailView::new(self.store.clone(), pr_id, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel(Arc::new(panel), DockPlacement::Bottom, window, cx);
});
} }
/// Render one row of the pull request list; `ix` is the row index and /// Render one row of the pull request list; `ix` is the row index and
@@ -419,15 +426,19 @@ impl PullRequestsView {
} }
} }
/// Open the "new pull request" dialog: a title and a patch input that /// Open the "new pull request" dialog: a title, an optional description and
/// submit through [`RepoStore::open_pull_request`] when confirmed. /// a patch input that submit through [`RepoStore::open_pull_request`] when
/// confirmed.
fn open_new_pull_request_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) { fn open_new_pull_request_dialog(store: Entity<RepoStore>, window: &mut Window, cx: &mut App) {
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title")); let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
let description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change…"));
let patch = let patch =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…")); cx.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output…"));
window.open_dialog(cx, move |dialog, _window, _cx| { window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone(); let subject = subject.clone();
let description = description.clone();
let patch = patch.clone(); let patch = patch.clone();
let store = store.clone(); let store = store.clone();
@@ -451,6 +462,11 @@ fn open_new_pull_request_dialog(store: Entity<RepoStore>, window: &mut Window, c
.required(true) .required(true)
.child(Input::new(&subject)), .child(Input::new(&subject)),
) )
.child(
field()
.label("Description")
.child(Textarea::new(&description).h(px(96.))),
)
.child( .child(
field() field()
.label("Patch") .label("Patch")
@@ -465,16 +481,18 @@ fn open_new_pull_request_dialog(store: Entity<RepoStore>, window: &mut Window, c
.tooltip("Create pull request") .tooltip("Create pull request")
.on_click({ .on_click({
let subject = subject.clone(); let subject = subject.clone();
let description = description.clone();
let patch = patch.clone(); let patch = patch.clone();
let store = store.clone(); let store = store.clone();
move |_event, window, cx| { move |_event, window, cx| {
let subject = subject.read(cx).value().to_string(); let subject = subject.read(cx).value().to_string();
let description = description.read(cx).value().to_string();
let patch = patch.read(cx).value().to_string(); let patch = patch.read(cx).value().to_string();
let subject = (!subject.is_empty()).then_some(subject); let subject = (!subject.is_empty()).then_some(subject);
store.update(cx, |store, cx| { store.update(cx, |store, cx| {
store.open_pull_request(subject, patch, cx); store.open_pull_request(subject, description, patch, cx);
}); });
window.close_dialog(cx); window.close_dialog(cx);