add pull request viewer
This commit is contained in:
@@ -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};
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user