feat: pull request and patch #13

Merged
reya merged 9 commits from feat/improve-ui into master 2026-09-02 10:49:31 +00:00
24 changed files with 3023 additions and 454 deletions
+1 -1
View File
@@ -6,11 +6,11 @@ use crate::RepoAddr;
/// Kinds that make up the activity of a repository. /// Kinds that make up the activity of a repository.
pub const ACTIVITY_KINDS: [Kind; 9] = [ pub const ACTIVITY_KINDS: [Kind; 9] = [
Kind::Comment,
Kind::GitPatch, Kind::GitPatch,
Kind::GitPullRequest, Kind::GitPullRequest,
Kind::GitPullRequestUpdate, Kind::GitPullRequestUpdate,
Kind::GitIssue, Kind::GitIssue,
Kind::Comment,
Kind::GitStatusOpen, Kind::GitStatusOpen,
Kind::GitStatusApplied, Kind::GitStatusApplied,
Kind::GitStatusClosed, Kind::GitStatusClosed,
+1 -1
View File
@@ -13,6 +13,6 @@ pub use annotations::{COVER_NOTE_KIND, cover_note, labels_and_subject, subject_o
pub use clone_url::{CloneTarget, parse_clone_url}; pub use clone_url::{CloneTarget, parse_clone_url};
pub use comments::{CommentThread, comment_threads}; pub use comments::{CommentThread, comment_threads};
pub use deletions::Deletions; pub use deletions::Deletions;
pub use model::{Announcement, activity_subject, pull_request_patch}; pub use model::{Announcement, activity_subject, pull_request_patch, pull_request_patches};
pub use state::{build_state, parse_state}; pub use state::{build_state, parse_state};
pub use status::{RepoStatus, references_root, resolve_status}; pub use status::{RepoStatus, references_root, resolve_status};
+96 -7
View File
@@ -3,6 +3,8 @@ use std::collections::HashSet;
use gpui::SharedString; use gpui::SharedString;
use nostr::prelude::*; use nostr::prelude::*;
use crate::RepoAddr;
/// Parsed NIP-34 repository announcement (plain data, ready for the UI). /// Parsed NIP-34 repository announcement (plain data, ready for the UI).
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement { pub struct Announcement {
@@ -28,11 +30,53 @@ pub struct Announcement {
pub maintainers: Vec<PublicKey>, pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any: this repository is a subordinate fork of /// Value of a `u` tag, if any: this repository is a subordinate fork of
/// the referenced upstream (NIP-34). /// the referenced upstream (NIP-34).
pub upstream: Option<String>, pub upstream: Option<Upstream>,
/// Hashtags labelling the repository (`t` tags). /// Hashtags labelling the repository (`t` tags).
pub hashtags: Vec<String>, pub hashtags: Vec<String>,
} }
/// The `u` tag of a fork announcement (NIP-34)
/// the repository this one is a subordinate fork of. The first value is
/// the upstream coordinate (`30617:<pubkey>:<id>`) or a git URL.
/// The second is an optional relay hint for the upstream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Upstream {
/// Raw first value of the `u` tag (coordinate or git URL).
pub raw: String,
/// The upstream `30617:<pubkey>:<id>` coordinate, when the `u` tag
/// references a NIP-34 repository; `None` for the git-URL form.
pub addr: Option<RepoAddr>,
/// Relay hint for the upstream, if the `u` tag carries one.
pub relay_hint: Option<RelayUrl>,
}
impl Upstream {
/// Parse the `u` tag values. The first is the upstream coordinate or a
/// git URL (the coordinate form may append `|git-url`; the coordinate is
/// the part before the first `|`), the second an optional relay hint.
fn parse(raw: &str, relay_hint: Option<&str>) -> Self {
let coordinate = raw.split('|').next().unwrap_or(raw);
let addr = coordinate
.parse::<Coordinate>()
.ok()
.filter(|c| c.kind == Kind::GitRepoAnnouncement);
Self {
raw: raw.to_owned(),
addr,
relay_hint: relay_hint.and_then(|hint| RelayUrl::parse(hint).ok()),
}
}
/// Text for display: the upstream coordinate when it is a NIP-34
/// repository, otherwise the raw `u` value (git-URL form).
pub fn display(&self) -> SharedString {
match &self.addr {
Some(addr) => SharedString::from(addr.to_string()),
None => SharedString::from(self.raw.clone()),
}
}
}
/// Subject of a NIP-34 issue or pull request event: the `subject` tag, /// Subject of a NIP-34 issue or pull request event: the `subject` tag,
/// falling back to the first non-empty line of the content. /// falling back to the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> SharedString { pub fn activity_subject(event: &Event) -> SharedString {
@@ -193,7 +237,7 @@ impl Announcement {
let mut relays: Vec<RelayUrl> = Vec::new(); let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None; let mut euc: Option<String> = None;
let mut maintainers: Vec<PublicKey> = Vec::new(); let mut maintainers: Vec<PublicKey> = Vec::new();
let mut upstream: Option<String> = None; let mut upstream: Option<Upstream> = None;
for tag in event.tags.iter() { for tag in event.tags.iter() {
match Nip34Tag::parse(tag.as_slice()) { match Nip34Tag::parse(tag.as_slice()) {
@@ -208,9 +252,13 @@ impl Announcement {
} }
// The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it // The `u` tag is not modelled by the SDK's `Nip34Tag`; parse it
// manually (first value wins). // manually (first wins).
if upstream.is_none() && tag.kind() == "u" { if upstream.is_none() && tag.kind() == "u" {
upstream = tag.content().map(str::to_owned); let values = tag.as_slice();
let raw = values.get(1).map(String::as_str).unwrap_or_default();
if !raw.is_empty() {
upstream = Some(Upstream::parse(raw, values.get(2).map(String::as_str)));
}
} }
} }
@@ -391,14 +439,55 @@ mod tests {
fn parses_upstream_tag() { fn parses_upstream_tag() {
let event = announcement_event(&[ let event = announcement_event(&[
&["d", "my-fork"], &["d", "my-fork"],
&["u", "30617:abc:upstream|https://example.com/upstream.git"], &[
"u",
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git",
"wss://relay.example.com",
],
]); ]);
let announcement = Announcement::from_event(&event).expect("parses"); let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
// The coordinate part resolves to a repository address; the raw
// value keeps the `|git-url` suffix.
assert_eq!( assert_eq!(
announcement.upstream.as_deref(), upstream.addr,
Some("30617:abc:upstream|https://example.com/upstream.git") Some(crate::repo_addr(
PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey"),
"upstream"
))
);
assert_eq!(
upstream.raw,
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream|https://example.com/upstream.git"
);
assert_eq!(
upstream.relay_hint,
Some(RelayUrl::parse("wss://relay.example.com").expect("valid relay"))
);
assert_eq!(
upstream.display().to_string(),
"30617:68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272:upstream"
);
}
#[test]
fn parses_git_url_upstream() {
// The `u` tag may reference a non-nostr upstream by git URL only;
// there is no repository address to navigate to.
let event = announcement_event(&[
&["d", "my-fork"],
&["u", "https://example.com/upstream.git"],
]);
let announcement = Announcement::from_event(&event).expect("parses");
let upstream = announcement.upstream.expect("parses the u tag");
assert_eq!(upstream.addr, None);
assert_eq!(
upstream.display().to_string(),
"https://example.com/upstream.git"
); );
} }
+373
View File
@@ -195,6 +195,185 @@ pub fn apply_patch(repo_path: &Path, patch: &str) -> Result<()> {
Ok(()) Ok(())
} }
/// The merge base of two revisions (branch names, remote-tracking refs or
/// commit ids) in the repository at `repo_path`. `Ok(None)` when the
/// revisions share no common ancestor; unresolvable revisions are errors.
pub fn merge_base(repo_path: &Path, a: &str, b: &str) -> Result<Option<String>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["merge-base", a, b])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git merge-base`")?;
match output.status.code() {
// Exit 1: no common ancestor (a valid outcome for a proposal).
Some(1) => Ok(None),
Some(0) => Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
)),
_ => bail!(
"git merge-base failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
),
}
}
/// The `git format-patch` series of `base..tip` (mbox), like
/// `git format-patch --stdout`. Fails when the range has no commits. The
/// mbox is returned untrimmed; trailing newlines are part of the format.
pub fn format_patch_between(repo_path: &Path, base: &str, tip: &str) -> Result<String> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["format-patch", "--stdout", &format!("{base}..{tip}")])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git format-patch`")?;
if !output.status.success() {
bail!(
"git format-patch failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
let patch = String::from_utf8_lossy(&output.stdout).into_owned();
if patch.trim().is_empty() {
bail!("no commits between {base} and {tip}");
}
Ok(patch)
}
/// Whether `patch` (a `git format-patch` series) applies to the working
/// tree of `repo_path`, without modifying anything
/// (`git apply --check --3way`). Best-effort: useful to surface conflicts
/// before a patch is published or applied.
pub fn patch_applies(repo_path: &Path, patch: &str) -> Result<()> {
let mut child = Command::new("git")
.arg("apply")
.args(["--check", "--3way", "--whitespace=nowarn", "-"])
.current_dir(repo_path)
.stdin(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `git apply --check`")?;
child
.stdin
.as_mut()
.expect("stdin piped")
.write_all(patch.as_bytes())?;
let output = child.wait_with_output()?;
if !output.status.success() {
bail!(
"patch does not apply: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Push `commit` to `reference` (e.g. `refs/nostr/<event-id>`) on the git
/// server at `url`, from the repository at `repo_path`. GRASP servers host
/// the `refs/nostr` namespace so anyone can contribute a commit; nak pushes
/// pull request tips there before publishing the PR event, and readers
/// fetch the ref to get the commit behind a PR's `c` tag.
pub fn push_commit_ref(repo_path: &Path, url: &str, commit: &str, reference: &str) -> Result<()> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["push"])
.arg(url)
.arg(format!("{commit}:{reference}"))
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git push`")?;
if !output.status.success() {
bail!(
"git push failed: {}",
String::from_utf8_lossy(&output.stderr).trim()
);
}
Ok(())
}
/// Split a `git format-patch` series into its individual patches (mbox
/// messages). Each message begins with a `From <40-hex> ` boundary line;
/// `>From` quoting inside bodies means no false positives. A single patch
/// yields one element; a malformed input yields one element covering it.
pub fn split_patch_series(patch: &str) -> Vec<&str> {
let mut starts = vec![0usize];
let mut search_from = 1;
while let Some(rel) = patch[search_from..].find("\nFrom ") {
let ix = search_from + rel + 1;
let hex = patch[ix + 5..]
.split(|c: char| !c.is_ascii_hexdigit())
.next()
.unwrap_or("");
if hex.len() == 40 {
starts.push(ix);
}
search_from = ix + 1;
}
starts
.iter()
.enumerate()
.map(|(i, &start)| {
let end = starts.get(i + 1).copied().unwrap_or(patch.len());
&patch[start..end]
})
.collect()
}
/// The commit HEAD points to in the repository at `repo_path`, or `None`
/// when the repository has no commits yet (unborn HEAD).
pub fn head_commit_id(repo_path: &Path) -> Result<Option<String>> {
let output = Command::new("git")
.arg("-C")
.arg(repo_path)
.args(["rev-parse", "HEAD"])
.env("GIT_TERMINAL_PROMPT", "0")
.stderr(Stdio::piped())
.output()
.context("failed to spawn `git rev-parse`")?;
if !output.status.success() {
return Ok(None);
}
Ok(Some(
String::from_utf8_lossy(&output.stdout).trim().to_owned(),
))
}
/// The commits in `base..HEAD` of the repository at `repo_path`, oldest
/// first (the order `git am` creates them); `HEAD` alone when `base` is
/// `None`. An empty range yields an empty list.
pub fn commits_since(repo_path: &Path, base: Option<&str>) -> Result<Vec<String>> {
let output = match base {
Some(base) => git_in(
repo_path,
&["rev-list", "--reverse", &format!("{base}..HEAD")],
)?,
// No `base` (unborn HEAD): there is nothing to walk yet.
None => match git_in(repo_path, &["rev-parse", "HEAD"]) {
Ok(head) => head,
Err(_) => return Ok(Vec::new()),
},
};
Ok(output
.lines()
.map(str::to_owned)
.filter(|line| !line.is_empty())
.collect())
}
fn clone(url: &str, path: &Path) -> Result<gix::Repository> { fn clone(url: &str, path: &Path) -> Result<gix::Repository> {
// GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs; // GRASP servers announce `grasp://<host>/<owner>/<repo>` clone URLs;
// the transport is git smart HTTP, so rewrite the scheme for gix. // the transport is git smart HTTP, so rewrite the scheme for gix.
@@ -1884,6 +2063,200 @@ mod tests {
git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]); git_run(repo.workdir().expect("workdir"), &["commit", "-m", message]);
} }
#[test]
fn merge_base_finds_the_fork_point_and_reports_unrelated_history() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
// A feature branch and a mainline commit diverge from the initial
// commit; it is their merge base.
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "feature commit");
git_run(&path, &["checkout", "main"]);
std::fs::write(path.join("main.txt"), "main\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "mainline commit");
assert_eq!(
merge_base(&path, "feature", "main")
.expect("merge base")
.as_deref(),
Some(initial.as_str())
);
// An orphan branch shares no history with main: `Ok(None)`.
git_run(&path, &["checkout", "--orphan", "orphan"]);
std::fs::write(path.join("orphan.txt"), "orphan\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "orphan commit");
assert_eq!(merge_base(&path, "orphan", "main").expect("ok"), None);
// An unresolvable revision is an error, not a missing ancestor.
assert!(merge_base(&path, "orphan", "no-such-ref").is_err());
}
#[test]
fn format_patch_between_produces_the_series_and_rejects_empty_ranges() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "feature commit");
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
assert!(patch.contains("Subject: [PATCH] feature commit"));
assert!(patch.contains("feature.txt"));
// An empty range has no commits to send.
assert!(format_patch_between(&path, "feature", "feature").is_err());
}
#[test]
fn patch_applies_checks_without_modifying_the_tree() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("feature.txt"), "feature\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "feature commit");
let patch = format_patch_between(&path, &initial, "feature").expect("patch");
// A clone of the initial state accepts the series...
let clone = dir.path().join("clone");
git_run(
dir.path(),
&[
"clone",
"-q",
path.to_str().unwrap(),
clone.to_str().unwrap(),
],
);
git_run(&clone, &["checkout", "-q", &initial]);
assert!(patch_applies(&clone, &patch).is_ok());
// ...and the check must not have modified the working tree.
assert!(!clone.join("feature.txt").exists());
// A conflicting file makes the same series fail the check.
std::fs::write(clone.join("feature.txt"), "conflicting\n").expect("write");
assert!(patch_applies(&clone, &patch).is_err());
}
#[test]
fn push_commit_ref_pushes_to_the_event_namespace() {
// A bare "server" repository reachable via a `file://` URL, like a
// grasp server's `{base}/{owner}/{repo-id}.git` layout.
let server = tempfile::tempdir().unwrap();
let server_repo = server.path().join("npub1test").join("my-repo.git");
std::fs::create_dir_all(server_repo.parent().unwrap()).unwrap();
let init_status = Command::new("git")
.args(["init", "--bare", "-q"])
.arg(&server_repo)
.status()
.expect("spawn git init --bare");
assert!(init_status.success());
let (dir, repo) = fixture(&[("a.txt", b"one")]);
commit_all(&repo, "initial");
let dir = dir.path();
let tip = git_in(dir, &["rev-parse", "HEAD"]).expect("tip");
let url = format!("file://{}/npub1test/my-repo.git", server.path().display());
push_commit_ref(dir, &url, &tip, "refs/nostr/abcd1234").expect("push");
let refs = git_in(&server_repo, &["show-ref"]).expect("server refs");
assert!(refs.contains("refs/nostr/abcd1234"));
}
#[test]
fn split_patch_series_splits_real_multi_commit_mboxes() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
git_run(&path, &["checkout", "-b", "feature"]);
std::fs::write(path.join("one.txt"), "one\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "first commit");
std::fs::write(path.join("two.txt"), "two\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "second commit");
let series = format_patch_between(&path, &initial, "feature").expect("series");
let parts = split_patch_series(&series);
assert_eq!(parts.len(), 2);
assert!(parts[0].contains("Subject: [PATCH 1/2] first commit"));
assert!(parts[1].contains("Subject: [PATCH 2/2] second commit"));
// Each part starts its own mbox message with its own commit id.
let first = parts[0].lines().next().expect("first header");
let second = parts[1].lines().next().expect("second header");
assert!(first.starts_with("From ") && first.len() >= 45);
assert_ne!(first, second);
}
#[test]
fn split_patch_series_keeps_single_patches_whole() {
let patch = "From abcdefabcdefabcdefabcdefabcdefabcdefab Mon Sep 17 00:00:00 2001\nFrom: A <a@b>\nSubject: [PATCH] fix\n\n---\n";
let parts = split_patch_series(patch);
assert_eq!(parts.len(), 1);
assert_eq!(parts[0], patch);
}
#[test]
fn head_commit_and_commits_since_track_applied_commits() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let initial = init_repository(&path, "My Repo", "desc").expect("init");
assert_eq!(
head_commit_id(&path).expect("head").as_deref(),
Some(initial.as_str())
);
// No commits yet: `HEAD` alone.
assert_eq!(
commits_since(&path, None).expect("commits"),
vec![initial.clone()]
);
std::fs::write(path.join("one.txt"), "one\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "first commit");
let first = head_commit_id(&path).expect("head").expect("on a branch");
std::fs::write(path.join("two.txt"), "two\n").expect("write");
commit_all(&gix::open(&path).expect("open"), "second commit");
let second = head_commit_id(&path).expect("head").expect("on a branch");
// Oldest first, like the order `git am` creates them.
assert_eq!(
commits_since(&path, Some(&initial)).expect("commits"),
vec![first.clone(), second.clone()]
);
assert_eq!(
commits_since(&path, Some(&first)).expect("commits"),
vec![second]
);
}
#[test]
fn head_commit_reports_unborn_repositories() {
let dir = tempfile::tempdir().expect("tempdir");
let path = dir.path().join("repo");
let status = Command::new("git")
.args(["init", "-q"])
.arg(&path)
.status()
.expect("spawn git init");
assert!(status.success());
assert_eq!(head_commit_id(&path).expect("head"), None);
assert_eq!(
commits_since(&path, None).expect("commits"),
Vec::<String>::new()
);
}
#[test] #[test]
fn init_repository_creates_main_branch_and_readme() { fn init_repository_creates_main_branch_and_readme() {
let dir = tempfile::tempdir().expect("tempdir"); let dir = tempfile::tempdir().expect("tempdir");
+56 -2
View File
@@ -1302,6 +1302,55 @@ impl Backend {
}) })
} }
/// Broadcast and locally store an already-signed event, like
/// [`Self::send`] without the signing step. Callers that signed early
/// (e.g. to learn the event id before pushing a commit to the grasp
/// servers) publish through this.
pub fn publish_event(
&mut self,
event: Event,
cx: &mut Context<Self>,
) -> Task<Result<Event, Error>> {
let client = self.client.clone();
cx.spawn(async move |this, cx| {
let work = cx.background_spawn(async move {
let output = client.send_event(&event).await?;
if output.success.is_empty() && !output.failed.is_empty() {
let reasons = output
.failed
.values()
.cloned()
.collect::<Vec<String>>()
.join(", ");
return Err(anyhow!("event not accepted by any relay: {reasons}"));
}
Ok(event.clone())
});
let result = work.await;
match &result {
Ok(event) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::Published(Box::new(event.clone())));
})
.ok();
}
Err(e) => {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
})
.ok();
}
}
result
})
}
/// Publish a NIP-34 repository announcement (kind 30617) with the /// Publish a NIP-34 repository announcement (kind 30617) with the
/// current signer. The returned task yields the published event, so /// current signer. The returned task yields the published event, so
/// callers can show inline progress/errors. /// callers can show inline progress/errors.
@@ -1414,7 +1463,12 @@ async fn connect_repo_relays_only(
let relays = &relays; let relays = &relays;
let sync_opts = sync_opts.clone(); let sync_opts = sync_opts.clone();
async move { async move {
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await { if let Err(e) = client
.sync(filter)
.with(relays.iter())
.opts(sync_opts)
.await
{
log::warn!("repo relay negentropy sync failed: {e}"); log::warn!("repo relay negentropy sync failed: {e}");
} }
} }
@@ -1469,7 +1523,7 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like /// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
/// ngit) base URL for a grasp server. The repository then lives at /// ngit) base URL for a grasp server. The repository then lives at
/// `{base}/{npub}/{repo-id}.git`. /// `{base}/{npub}/{repo-id}.git`.
fn grasp_base_url(relay: &RelayUrl) -> Option<String> { pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
// `domain()` drops the port; parse the full URL to keep it (local dev // `domain()` drops the port; parse the full URL to keep it (local dev
// grasp servers commonly run on a custom port). // grasp servers commonly run on a custom port).
let parsed = Url::parse(relay.as_str()).ok()?; let parsed = Url::parse(relay.as_str()).ok()?;
+6 -3
View File
@@ -163,7 +163,8 @@ impl ProfileStore {
/// Load recently seen profiles from the local database. /// Load recently seen profiles from the local database.
fn load(&mut self, cx: &mut Context<Self>) { fn load(&mut self, cx: &mut Context<Self>) {
let client = Backend::global(cx).read(cx).client(); let backend = Backend::global(cx);
let client = backend.read(cx).client();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).limit(200); let filter = Filter::new().kind(Kind::Metadata).limit(200);
@@ -197,7 +198,8 @@ impl ProfileStore {
/// Re-read the latest metadata of an author from the local database. /// Re-read the latest metadata of an author from the local database.
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) { fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = Backend::global(cx).read(cx).client(); let backend = Backend::global(cx);
let client = backend.read(cx).client();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).author(public_key); let filter = Filter::new().kind(Kind::Metadata).author(public_key);
@@ -238,7 +240,8 @@ impl ProfileStore {
return; return;
} }
let client = Backend::global(cx).read(cx).client(); let backend = Backend::global(cx);
let client = backend.read(cx).client();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
let filter = Filter::new().kind(Kind::Metadata).authors(authors); let filter = Filter::new().kind(Kind::Metadata).authors(authors);
+511 -92
View File
@@ -1,23 +1,30 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::{HashMap, HashSet}; use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use gpui::{AppContext, Context, Subscription, Task}; use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder; use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use signed_core::{ use signed_core::{
Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note, Announcement, COVER_NOTE_KIND, Deletions, RepoAddr, RepoStatus, build_state, cover_note,
filters, labels_and_subject, parse_state, pull_request_patch, subject_override, filters, labels_and_subject, parse_state, pull_request_patch, pull_request_patches,
subject_override,
}; };
use crate::backend::{Backend, BackendEvent}; use crate::backend::{Backend, BackendEvent, grasp_base_url};
use crate::git_store::GitStore; use crate::git_store::GitStore;
/// Delay between a refresh request and the actual re-query, so bursts of /// Delay between a refresh request and the actual re-query, so bursts of
/// events (e.g. per-event `NostrUpdate`s) collapse into one query. /// events (e.g. per-event `NostrUpdate`s) collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event, following NIP-34's guidance that
/// patches should be used when each event is under 60kb.
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Per-repository store: announcement, state, issues, patches, PRs, /// Per-repository store: announcement, state, issues, patches, PRs,
/// comments and their resolved statuses. Always derived from the local /// comments and their resolved statuses. Always derived from the local
/// database. /// database.
@@ -50,6 +57,9 @@ pub struct RepoStore {
version: u64, version: u64,
/// Error of the last action initiated from this store, if any. /// Error of the last action initiated from this store, if any.
pub last_error: Option<String>, pub last_error: Option<String>,
/// Non-fatal warning of the last action (e.g. a PR published without
/// its commit reaching a grasp server), if any.
pub last_warning: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we /// Relays announced by this repository (NIP-34 `relays` tag) that we
/// have already been asked to connect to and fetch from, to avoid /// have already been asked to connect to and fetch from, to avoid
/// re-subscribing on every refresh. /// re-subscribing on every refresh.
@@ -128,6 +138,7 @@ impl RepoStore {
labels: Vec::new(), labels: Vec::new(),
version: 0, version: 0,
last_error: None, last_error: None,
last_warning: None,
repo_relays: HashSet::new(), repo_relays: HashSet::new(),
root_fetches: HashSet::new(), root_fetches: HashSet::new(),
refreshing: false, refreshing: false,
@@ -146,12 +157,22 @@ impl RepoStore {
store store
} }
/// Returns the repository's address.
pub fn addr(&self) -> &RepoAddr { pub fn addr(&self) -> &RepoAddr {
&self.addr &self.addr
} }
/// Filters that make up a repository: announcement, state, activity and /// Returns the repository's name, or "Unknown" if not known.
/// deletions targeting it. pub fn name(&self) -> SharedString {
self.announcement
.as_ref()
.map_or(SharedString::default(), |a| {
a.name.clone().unwrap_or(SharedString::from("Unknown"))
})
}
/// Filters that make up a repository: announcement, state,
/// activity and deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> { fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![ let mut filters = vec![
// Announcement and state share author and identifier, so they // Announcement and state share author and identifier, so they
@@ -191,8 +212,7 @@ impl RepoStore {
}); });
} }
/// Fetch this repository's events from the bootstrap relays (one-shot, /// Fetch this repository's events from the bootstrap relays
/// auto-closing subscription).
fn subscribe_remote(&mut self, cx: &mut Context<Self>) { fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx); let backend = Backend::global(cx);
let addr = self.addr.clone(); let addr = self.addr.clone();
@@ -204,10 +224,8 @@ impl RepoStore {
/// Re-query the local database and update all fields. /// Re-query the local database and update all fields.
/// ///
/// Debounced: a short delay collapses bursts of requests (e.g. per-event /// The query and processing run on a background thread,
/// `NostrUpdate`s), and requests that arrive while a query is running are /// only the results are applied on the main thread.
/// folded into one follow-up query. The query and processing run on a
/// background thread; only the results are applied on the main thread.
pub fn refresh(&mut self, cx: &mut Context<Self>) { pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing { if self.refreshing {
self.refresh_dirty = true; self.refresh_dirty = true;
@@ -231,11 +249,11 @@ impl RepoStore {
self.tasks.push(task); self.tasks.push(task);
} }
/// One query + apply cycle (debounced entry point).
fn run_refresh(&mut self, cx: &mut Context<Self>) { fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true; self.refreshing = true;
let client = Backend::global(cx).read(cx).client(); let backend = Backend::global(cx);
let client = backend.read(cx).client();
let addr = self.addr.clone(); let addr = self.addr.clone();
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
@@ -285,13 +303,15 @@ impl RepoStore {
// NIP-22 comments reference their root via an `E`/`e` tag rather // NIP-22 comments reference their root via an `E`/`e` tag rather
// than the repository's `a` tag, so query them by the root events // than the repository's `a` tag, so query them by the root events
// of this repository. // of this repository.
let db = client.database();
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect(); let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
let db = client.database();
let roots = issues let roots = issues
.iter() .iter()
.chain(&patches) .chain(&patches)
.chain(&pull_requests) .chain(&pull_requests)
.map(|e| e.id); .map(|e| e.id);
for filter in filters::comments_for(roots) { for filter in filters::comments_for(roots) {
for event in db.query(filter).await? { for event in db.query(filter).await? {
if seen_comments.insert(event.id) { if seen_comments.insert(event.id) {
@@ -300,16 +320,17 @@ impl RepoStore {
} }
} }
// Status events may omit their `a` tag (NIP-34 makes it // Status events may omit their `a` tag,
// optional), so also query them by the root events they // so also query them by the root events they reference.
// reference.
let db = client.database();
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect(); let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
let db = client.database();
let roots = issues let roots = issues
.iter() .iter()
.chain(&patches) .chain(&patches)
.chain(&pull_requests) .chain(&pull_requests)
.map(|e| e.id); .map(|e| e.id);
for root in roots { for root in roots {
for event in db.query(filters::statuses_for([root])).await? { for event in db.query(filters::statuses_for([root])).await? {
if seen_statuses.insert(event.id) { if seen_statuses.insert(event.id) {
@@ -318,17 +339,18 @@ impl RepoStore {
} }
} }
// Cover notes (1624) and label events (1985) reference their // Cover notes (1624) and label events (1985) reference
// target via an `e` tag, so query them per root like comments // so query them per root like comments and statuses.
// and statuses.
let db = client.database();
let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect(); let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect();
let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect(); let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
let db = client.database();
let roots = issues let roots = issues
.iter() .iter()
.chain(&patches) .chain(&patches)
.chain(&pull_requests) .chain(&pull_requests)
.map(|e| e.id); .map(|e| e.id);
for root in roots { for root in roots {
for event in db.query(filters::annotations_for([root])).await? { for event in db.query(filters::annotations_for([root])).await? {
if deletions.is_deleted(&event) { if deletions.is_deleted(&event) {
@@ -356,12 +378,15 @@ impl RepoStore {
.as_ref() .as_ref()
.map(Announcement::effective_maintainers) .map(Announcement::effective_maintainers)
.unwrap_or_default(); .unwrap_or_default();
let status_by_root = let status_by_root =
resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers); resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers);
let open_issue_count = issues let open_issue_count = issues
.iter() .iter()
.filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open) .filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open)
.count(); .count();
let open_pr_count = pull_requests let open_pr_count = pull_requests
.iter() .iter()
.filter(|pr| { .filter(|pr| {
@@ -414,8 +439,8 @@ impl RepoStore {
let again = this.update(cx, |this, cx| { let again = this.update(cx, |this, cx| {
this.announcement = announcement; this.announcement = announcement;
// The announcement may list relays for this repository's // The announcement may list relays for this repository's activity,
// activity; connect to any we haven't fetched from yet. // connect to any we haven't fetched from yet.
let relays = this let relays = this
.announcement .announcement
.as_ref() .as_ref()
@@ -450,11 +475,13 @@ impl RepoStore {
.chain(&this.pull_requests) .chain(&this.pull_requests)
.map(|e| e.id) .map(|e| e.id)
.collect::<HashSet<EventId>>(); .collect::<HashSet<EventId>>();
let new_roots: Vec<EventId> = roots let new_roots: Vec<EventId> = roots
.iter() .iter()
.filter(|id| !this.root_fetches.contains(id)) .filter(|id| !this.root_fetches.contains(id))
.copied() .copied()
.collect(); .collect();
if !new_roots.is_empty() { if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied()); this.root_fetches.extend(new_roots.iter().copied());
// Batch the per-root filters: one statuses filter and one // Batch the per-root filters: one statuses filter and one
@@ -464,6 +491,7 @@ impl RepoStore {
let mut root_filters = filters::comments_for(new_roots.clone()); let mut root_filters = filters::comments_for(new_roots.clone());
root_filters.push(filters::statuses_for(new_roots.iter().copied())); root_filters.push(filters::statuses_for(new_roots.iter().copied()));
root_filters.push(filters::annotations_for(new_roots)); root_filters.push(filters::annotations_for(new_roots));
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect(); let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx); let backend = Backend::global(cx);
backend.update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
@@ -483,8 +511,7 @@ impl RepoStore {
} }
})?; })?;
// Requests that arrived while the refresh was running are // Requests that arrived while the refresh was running are coalesced into one follow-up refresh.
// coalesced into one follow-up refresh.
if again { if again {
this.update(cx, |this, cx| this.refresh(cx))?; this.update(cx, |this, cx| this.refresh(cx))?;
} }
@@ -493,21 +520,19 @@ impl RepoStore {
})); }));
} }
/// Resolve the status of a root event (issue / patch / PR) per NIP-34: /// Resolve the status of a root event (issue / patch / PR) per NIP-34
/// a lookup into the map built on the last refresh.
pub fn status_of(&self, root: &Event) -> RepoStatus { pub fn status_of(&self, root: &Event) -> RepoStatus {
status_of(&self.status_by_root, root) status_of(&self.status_by_root, root)
} }
/// Refresh generation, incremented on every applied refresh. Views use /// Refresh generation, incremented on every applied refresh.
/// it to key their derived-data caches (filtered lists, counts) so /// Views use it to key their derived-data caches.
/// renders that change nothing stay O(1).
pub fn version(&self) -> u64 { pub fn version(&self) -> u64 {
self.version self.version
} }
/// The effective cover note of `root` (kind 1624), if any: the latest /// The effective cover note of `root` (kind 1624), if any:
/// note authored by the root author or a maintainer. /// the latest note authored by the root author or a maintainer.
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> { pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
let maintainers = self let maintainers = self
.announcement .announcement
@@ -545,14 +570,13 @@ 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).
/// Cached on the last refresh.
pub fn issue_count(&self) -> usize { pub fn issue_count(&self) -> usize {
self.open_issue_count self.open_issue_count
} }
/// 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`]. Cached on the last refresh. /// [`RepoStatus::Open`].
pub fn pull_request_count(&self) -> usize { pub fn pull_request_count(&self) -> usize {
self.open_pr_count self.open_pr_count
} }
@@ -584,15 +608,13 @@ impl RepoStore {
.filter(move |e| signed_core::references_root(e, root)) .filter(move |e| signed_core::references_root(e, root))
} }
/// Comment on a root event (issue / PR) per NIP-34 (kind 1111), using /// Comment on a root event (issue / PR) per NIP-34 (kind 1111)
/// the SDK's NIP-22 `CommentBuilder` so other NIP-34 clients (ngit,
/// GitWorkshop) can thread the comment.
pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) { pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) {
self.reply(root, None, content, cx); self.reply(root, None, content, cx);
} }
/// Reply to `parent` (a comment on `root`) with a NIP-22 threaded /// Reply to `parent` (a comment on `root`) with a NIP-22 threaded comment,
/// comment; `None` publishes a top-level comment on the root itself. /// `None` publishes a top-level comment on the root itself.
pub fn reply( pub fn reply(
&mut self, &mut self,
root: &Event, root: &Event,
@@ -617,23 +639,60 @@ impl RepoStore {
/// (kind 1617) carrying the `git format-patch` output, which the PR /// (kind 1617) carrying the `git format-patch` output, which the PR
/// references via an `e` tag (NIP-34). /// references via an `e` tag (NIP-34).
/// ///
/// The patch is published first so the PR can reference its id. The /// The patch series is published first (one kind-1617 event per commit,
/// proposed commit is parsed from the patch's `From <commit>` header; /// chained with NIP-10 `e` replies, each under [`MAX_PATCH_EVENT_BYTES`])
/// without one publishing is refused, because the PR's `c` tag must /// so the PR can reference the root patch's id. The proposed commit is
/// carry a real commit id for other NIP-34 clients to verify and apply /// parsed from the series' last `From <commit>` header (the tip); without
/// the proposal. The `clone` tag carries the announced mirror URLs; the /// one publishing is refused, because the PR's `c` tag must carry a real
/// linked patch is the source of truth until the commit is pushed there. /// commit id for other NIP-34 clients to verify and apply the proposal.
/// The `clone` tag carries the announced mirror URLs, and when
/// `push_from` is set the tip is pushed to those servers under
/// `refs/nostr/<event-id>` (best-effort) before the PR is published, so
/// the commit is actually downloadable there; the linked patch stays the
/// source of truth either way.
///
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
/// publishes a kind-1633 status right after the PR event. `merge_base`
/// is the hex commit the proposed branch forked from, computed from a
/// local checkout when the patch was generated there.
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request( pub fn open_pull_request(
&mut self, &mut self,
subject: Option<String>, subject: Option<String>,
description: String, description: String,
branch_name: Option<String>,
patch: String, patch: String,
draft: bool,
merge_base: Option<String>,
push_from: Option<PathBuf>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
self.last_error = None; self.last_error = None;
self.last_warning = None;
let Some(current_commit) = let series: Vec<String> = signed_git::split_patch_series(&patch)
patch_current_commit(&patch).and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok()) .into_iter()
.map(str::to_owned)
.collect();
if let Some(oversized) = series
.iter()
.find(|part| part.len() > MAX_PATCH_EVENT_BYTES)
{
self.last_error = Some(format!(
"patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)",
oversized.len(),
MAX_PATCH_EVENT_BYTES
));
cx.notify();
return;
}
// The tip of the series is its last commit; `git format-patch` orders patches oldest first.
let Some(current_commit) = series
.last()
.and_then(|part| patch_current_commit(part))
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
else { else {
self.last_error = Some( self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(), "Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
@@ -642,35 +701,43 @@ impl RepoStore {
return; return;
}; };
let Ok(root_marker) = Tag::parse(["t", "root"]) else { let backend = Backend::global(cx);
return; let signer = backend.read(cx).signer();
};
let commit_hex = current_commit.to_string();
let mut patch_tags = vec![
Tag::coordinate(self.addr.clone(), None),
Tag::public_key(self.addr.public_key),
root_marker,
];
// NIP-34: the `r` EUC tag lets clients subscribe to all patches of
// this repository; `commit`/`r` tags reference the proposed commit.
if let Some(euc) = self.announcement.as_ref().and_then(|a| a.euc.clone())
&& let Ok(tag) = Tag::parse(["r", &euc])
{
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["commit", &commit_hex]) {
patch_tags.push(tag);
}
if let Ok(tag) = Tag::parse(["r", &commit_hex]) {
patch_tags.push(tag);
}
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags);
let patch_task = if backend.read(cx).current_user().is_none() {
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx)); self.last_error = Some("Sign in to open a pull request".into());
cx.notify();
return;
}
let addr = self.addr.clone();
let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let (push_owner, push_repo_id, push_relays) = self
.announcement
.as_ref()
.map(|a| {
let owner = a.owner.to_bech32().unwrap_or_else(|_| a.owner.to_hex());
(owner, a.id.clone(), a.relays.clone())
})
.unwrap_or_default();
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
let patch_event = match patch_task.await { // The PR references the root patch event so viewers can find
// the patch without carrying it inline.
let root_patch = match publish_patch_series(
&this,
cx,
&addr,
owner,
euc.as_deref(),
&series,
"root",
None,
)
.await
{
Ok(event) => event, Ok(event) => event,
Err(e) => { Err(e) => {
return this.update(cx, |this, cx| { return this.update(cx, |this, cx| {
@@ -680,33 +747,225 @@ impl RepoStore {
} }
}; };
// The PR references the patch event so viewers can find the let builder = this.update(cx, |this, _cx| {
// patch without carrying it inline.
let pr_task = this.update(cx, |this, cx| {
let builder = GitPullRequest { let builder = GitPullRequest {
repository: this.addr.clone(), repository: this.addr.clone(),
content: description, content: description,
subject, subject,
labels: Vec::new(), labels: Vec::new(),
branch_name: None, branch_name,
// NIP-34: PRs carry at least one clone URL where the // NIP-34: PRs carry at least one clone URL where the tip commit can be downloaded,
// tip commit can be downloaded; use the repository's // the announced mirrors are also the servers the tip is pushed to below.
// announced mirrors until a push backend exists.
clone: this clone: this
.announcement .announcement
.as_ref() .as_ref()
.map(|a| a.clone.clone()) .map(|a| a.clone.clone())
.unwrap_or_default(), .unwrap_or_default(),
current_commit, current_commit,
root_patch_event: Some(patch_event.id), root_patch_event: Some(root_patch.id),
merge_base: merge_base
.and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok()),
}
.into_event_builder();
// NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository
match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")),
None => builder,
}
})?;
// Sign before publishing so the tip can be pushed to the grasp
// servers under `refs/nostr/<event-id>` (nak's convention):
// readers fetch that ref to get the commit behind the `c` tag.
let event = cx
.background_spawn({
let signer = signer.clone();
async move { builder.finalize_async(&signer).await }
})
.await?;
if let Some(path) = push_from.as_ref() {
let tip = current_commit.to_string();
let reference = format!("refs/nostr/{}", event.id.to_hex());
let (pushed, failures) = cx
.background_spawn({
let path = path.clone();
let tip = tip.clone();
let reference = reference.clone();
let owner = push_owner.clone();
let repo_id = push_repo_id.clone();
let relays = push_relays.clone();
async move {
let mut failures = Vec::new();
let mut pushed = 0;
for relay in &relays {
let Some(base) = grasp_base_url(relay) else {
continue;
};
let url = format!("{base}/{owner}/{repo_id}.git");
match signed_git::push_commit_ref(&path, &url, &tip, &reference) {
Ok(()) => pushed += 1,
Err(e) => failures.push(format!("{relay}: {e}")),
}
}
(pushed, failures)
}
})
.await;
if pushed == 0 {
this.update(cx, |this, cx| {
this.last_warning = Some(format!(
"Pull request published, but the commit could not be pushed to any grasp server ({}); the patch is still the source of truth",
failures.join("; ")
));
cx.notify();
})?;
}
}
let publish_task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.publish_event(event, cx))
})?;
let pr_event = match publish_task.await {
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
};
// NIP-34: a draft PR carries a kind-1633 status event,
// publish it right after the PR event so viewers never show it open.
if draft {
this.update(cx, |this, cx| {
this.set_status(&pr_event, RepoStatus::Draft, cx);
})?;
}
Ok(())
}));
}
/// Update a pull request: publish revision patch events chained to the
/// original root patch (`t root-revision` and a NIP-10 `e` reply on the
/// first, per NIP-34), then a kind-1619 PR update event carrying the new tip.
///
/// Only the PR author may update it; other authors must open a new PR.
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
self.last_error = Some("Sign in to update the pull request".into());
cx.notify();
return;
};
if user != root.pubkey {
self.last_error = Some("Only the pull request author can update it".into());
cx.notify();
return;
}
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
.collect();
if let Some(oversized) = series
.iter()
.find(|part| part.len() > MAX_PATCH_EVENT_BYTES)
{
self.last_error = Some(format!(
"patch too large ({} bytes; NIP-34 suggests keeping each patch under {} bytes)",
oversized.len(),
MAX_PATCH_EVENT_BYTES
));
cx.notify();
return;
}
// The new tip of the PR is the last commit of the series.
let Some(current_commit) = series
.last()
.and_then(|part| patch_current_commit(part))
.and_then(|hex| hex.parse::<Sha1Hash>().ok())
else {
self.last_error = Some(
"Patch must be `git format-patch` output with a `From <commit-id>` header".into(),
);
cx.notify();
return;
};
// NIP-34: the first patch of a revision replies to the original
// root patch (the PR's `e` tag; fall back to the oldest patch of
// the linked set for PRs without one).
let root_patch_id = root.tags.event_ids().next().or_else(|| {
pull_request_patches(root, self.patches.iter())
.first()
.map(|p| p.id)
});
let addr = self.addr.clone();
let owner = self.addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let clone: Vec<Url> = self
.announcement
.as_ref()
.map(|a| a.clone.clone())
.unwrap_or_default();
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = publish_patch_series(
&this,
cx,
&addr,
owner,
euc.as_deref(),
&series,
"root-revision",
root_patch_id,
)
.await
{
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
let update_task = this.update(cx, |this, cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
pull_request_event: root.id,
pull_request_author: root.pubkey,
current_commit,
clone: clone.clone(),
merge_base: None, merge_base: None,
} }
.into_event_builder(); .into_event_builder();
Backend::global(cx).update(cx, |backend, cx| backend.send(builder, cx)) // NIP-34: the `r` EUC tag lets clients subscribe to all PR
// updates of this repository; the SDK builder omits it.
let builder = match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder,
};
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
})?; })?;
if let Err(e) = pr_task.await { if let Err(e) = update_task.await {
return this.update(cx, |this, cx| { return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string()); this.last_error = Some(e.to_string());
cx.notify(); cx.notify();
@@ -729,7 +988,8 @@ impl RepoStore {
.map(Announcement::effective_maintainers) .map(Announcement::effective_maintainers)
.unwrap_or_default(); .unwrap_or_default();
let Some(user) = Backend::global(cx).read(cx).current_user() else { let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
self.last_error = Some("Sign in to change the status".into()); self.last_error = Some("Sign in to change the status".into());
cx.notify(); cx.notify();
return; return;
@@ -761,7 +1021,8 @@ impl RepoStore {
pub fn publish_state(&mut self, cx: &mut Context<Self>) { pub fn publish_state(&mut self, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
let Some(user) = Backend::global(cx).read(cx).current_user() else { let backend = Backend::global(cx);
let Some(user) = backend.read(cx).current_user() else {
self.last_error = Some("Sign in to publish repository state".into()); self.last_error = Some("Sign in to publish repository state".into());
cx.notify(); cx.notify();
return; return;
@@ -808,7 +1069,10 @@ impl RepoStore {
/// Merge a pull request: apply its patch (the content of the linked /// Merge a pull request: apply its patch (the content of the linked
/// root patch event) to the local clone of this repository, then publish /// root patch event) to the local clone of this repository, then publish
/// the merged status. /// a kind-1631 (Applied) status event with merge provenance: the commits
/// `git am` created (`applied-as-commits` + `r` tags) and the applied
/// patch events (`q` tags, plus `e` reply tags for every patch beyond
/// the root, per NIP-34).
/// ///
/// 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 needed. Patch application /// from the announcement's clone URLs when needed. Patch application
@@ -816,6 +1080,7 @@ impl RepoStore {
/// no longer applies) surface in [`Self::last_error`]. /// no longer applies) surface in [`Self::last_error`].
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) { pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
self.last_warning = None;
let is_author = Backend::global(cx) let is_author = Backend::global(cx)
.read(cx) .read(cx)
@@ -834,21 +1099,46 @@ impl RepoStore {
.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 = pull_request_patch(root, self.patches.iter()); let patch = pull_request_patch(root, self.patches.iter());
// The applied patch events, for the status tags below.
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
.into_iter()
.cloned()
.collect();
let relay_hint = self
.announcement
.as_ref()
.and_then(|a| a.relays.first())
.map(ToString::to_string)
.unwrap_or_default();
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone(); let root = root.clone();
let apply = cx.background_spawn(async move { let apply = cx.background_spawn(async move {
let repo = cache.ensure_clone(&addr, &clone_urls)?; let repo = cache.ensure_clone(&addr, &clone_urls)?;
let workdir = repo let workdir = repo
.workdir() .workdir()
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?; .ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
signed_git::apply_patch(workdir, &patch) .to_path_buf();
// The commits created by the apply: everything between the
// previous HEAD and the new one, oldest first.
let previous = signed_git::head_commit_id(&workdir)?;
signed_git::apply_patch(&workdir, &patch)?;
let applied = signed_git::commits_since(&workdir, previous.as_deref())?;
Ok::<_, Error>(applied)
}); });
self.tasks.push(cx.spawn(async move |this, cx| { self.tasks.push(cx.spawn(async move |this, cx| {
match apply.await { match apply.await {
Ok(()) => { Ok(applied) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.set_status(&root, RepoStatus::Applied, cx); this.publish_applied_status(
&root,
&patches,
&applied,
&relay_hint,
euc.as_deref(),
cx,
);
})?; })?;
} }
Err(e) => { Err(e) => {
@@ -862,6 +1152,62 @@ impl RepoStore {
})); }));
} }
/// Publish a kind-1631 (Applied) status event for `root` after a merge:
/// `applied-as-commits` + `r` tags for the commits `git am` created,
/// `q` tags for the applied patch events, and `e` reply tags for every
/// patch of the series beyond the root (NIP-34).
fn publish_applied_status(
&mut self,
root: &Event,
patches: &[Event],
applied: &[String],
relay_hint: &str,
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(self.addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
];
if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc])
{
tags.push(tag);
}
// The applied patch events: a `q` tag per event, plus an `e` reply
// for every event beyond the root (chain parts and revisions), so
// their statuses resolve to Applied too.
for (ix, patch) in patches.iter().enumerate() {
if let Ok(tag) =
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
{
tags.push(tag);
}
if ix > 0
&& let Ok(tag) = Tag::parse(["e", &patch.id.to_hex(), "", "reply"])
{
tags.push(tag);
}
}
// The commits `git am` created on top of the previous HEAD.
if !applied.is_empty() {
let mut applied_tag = vec!["applied-as-commits".to_string()];
applied_tag.extend(applied.iter().cloned());
if let Ok(tag) = Tag::parse(applied_tag) {
tags.push(tag);
}
for commit in applied {
if let Ok(tag) = Tag::parse(["r", commit]) {
tags.push(tag);
}
}
}
self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
}
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) { fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None; self.last_error = None;
@@ -924,7 +1270,8 @@ fn resolve_statuses(
.chain(pull_requests) .chain(pull_requests)
.map(|root| { .map(|root| {
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]); let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers); let status =
signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
(root.id, status) (root.id, status)
}) })
.collect() .collect()
@@ -946,6 +1293,78 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
hex.split_whitespace().next().filter(|hex| hex.len() == 40) hex.split_whitespace().next().filter(|hex| hex.len() == 40)
} }
/// Publish a `git format-patch` series as chained kind-1617 events and
/// return the root event (the one a PR references). The first part carries
/// `first_marker` (`t root`, or `t root-revision` with an `e` reply to
/// `reply_to` for revisions); every later part replies to the previous one
/// (NIP-34). Every part gets the repository coordinate, the owner, its own
/// `commit`/`r` tags, and the repository EUC when known.
#[allow(clippy::too_many_arguments)]
async fn publish_patch_series(
this: &WeakEntity<RepoStore>,
cx: &mut AsyncApp,
addr: &RepoAddr,
owner: PublicKey,
euc: Option<&str>,
series: &[String],
first_marker: &str,
reply_to: Option<EventId>,
) -> Result<Event, Error> {
let mut root: Option<Event> = None;
let mut previous = reply_to;
for (ix, part) in series.iter().enumerate() {
let Some(commit) = patch_current_commit(part).filter(|hex| hex.len() == 40) else {
return Err(anyhow::anyhow!(
"patch {} of the series has no `From <commit-id>` header",
ix + 1
));
};
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
if ix == 0 {
if let Ok(tag) = Tag::parse(["t", first_marker]) {
tags.push(tag);
}
if let Some(root_id) = reply_to
&& let Ok(tag) = Tag::parse(["e", &root_id.to_hex(), "", "reply"])
{
tags.push(tag);
}
} else if let Some(previous) = previous
&& let Ok(tag) = Tag::parse(["e", &previous.to_hex(), "", "reply"])
{
tags.push(tag);
}
if let Some(euc) = euc
&& let Ok(tag) = Tag::parse(["r", euc])
{
tags.push(tag);
}
if let Ok(tag) = Tag::parse(["commit", commit]) {
tags.push(tag);
}
if let Ok(tag) = Tag::parse(["r", commit]) {
tags.push(tag);
}
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
let task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
})?;
let event = task.await?;
if root.is_none() {
root = Some(event.clone());
}
previous = Some(event.id);
}
root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
}
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the /// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a /// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
/// top-level comment). An `a` tag with the repository coordinate (not part /// top-level comment). An `a` tag with the repository coordinate (not part
+2 -1
View File
@@ -202,7 +202,8 @@ impl RepoListStore {
fn run_refresh(&mut self, cx: &mut Context<Self>) { fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true; self.refreshing = true;
let client = Backend::global(cx).read(cx).client(); let backend = Backend::global(cx);
let client = backend.read(cx).client();
let author = self.author; let author = self.author;
let work = cx.background_spawn(async move { let work = cx.background_spawn(async move {
+6 -6
View File
@@ -10,11 +10,11 @@ use signed_core::RepoStatus;
pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement { pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
let (icon, label, tooltip, bg, fg) = match status { let (icon, label, tooltip, bg, fg) = match status {
RepoStatus::Open => ( RepoStatus::Open => (
CustomIconName::GitIssueDone, CustomIconName::GitIssueOpen,
"open", "open",
"Issue is open", "Issue is open",
cx.theme().primary, cx.theme().secondary,
cx.theme().primary_foreground, cx.theme().secondary_foreground,
), ),
RepoStatus::Closed => ( RepoStatus::Closed => (
CustomIconName::GitIssueClosed, CustomIconName::GitIssueClosed,
@@ -31,11 +31,11 @@ pub fn status_badge(status: RepoStatus, cx: &App) -> AnyElement {
cx.theme().accent_foreground, cx.theme().accent_foreground,
), ),
RepoStatus::Applied => ( RepoStatus::Applied => (
CustomIconName::GitIssueOpen, CustomIconName::GitIssueDone,
"applied", "applied",
"Issue is completed", "Issue is completed",
cx.theme().secondary, cx.theme().primary,
cx.theme().secondary_foreground, cx.theme().primary_foreground,
), ),
}; };
+1
View File
@@ -3,5 +3,6 @@ mod repo_list;
pub(crate) mod sidebar; pub(crate) mod sidebar;
pub use repo_detail::RepoDetailView; pub use repo_detail::RepoDetailView;
pub(crate) use repo_detail::open_repo_panel;
pub use repo_list::RepoListView; pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel; pub use sidebar::SidebarPanel;
@@ -60,6 +60,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx, cx,
)); ));
} }
if let Some(euc) = &announcement.euc { if let Some(euc) = &announcement.euc {
rows.push(row( rows.push(row(
"Earliest Commit", "Earliest Commit",
@@ -67,13 +68,11 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx, cx,
)); ));
} }
if let Some(upstream) = &announcement.upstream { if let Some(upstream) = &announcement.upstream {
rows.push(row( rows.push(row("Upstream", text(upstream.display()), cx));
"Upstream",
text(SharedString::from(upstream.clone())),
cx,
));
} }
if !announcement.hashtags.is_empty() { if !announcement.hashtags.is_empty() {
rows.push(row( rows.push(row(
"Hashtags", "Hashtags",
@@ -81,6 +80,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx, cx,
)); ));
} }
if !announcement.clone.is_empty() { if !announcement.clone.is_empty() {
rows.push(row( rows.push(row(
"Clone URLs", "Clone URLs",
@@ -92,6 +92,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx, cx,
)); ));
} }
if !announcement.relays.is_empty() { if !announcement.relays.is_empty() {
rows.push(row( rows.push(row(
"Grasp Relays", "Grasp Relays",
@@ -103,6 +104,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx, cx,
)); ));
} }
if !announcement.maintainers.is_empty() { if !announcement.maintainers.is_empty() {
rows.push(row( rows.push(row(
"Maintainers", "Maintainers",
@@ -1,5 +1,5 @@
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{AnyElement, App, Context, WeakEntity, div, px}; use gpui::{AnyElement, App, Context, Window, div, px};
use gpui_component::scroll::Scrollbar; use gpui_component::scroll::Scrollbar;
use gpui_component::spinner::Spinner; use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list}; use gpui_component::{ActiveTheme, Sizable, h_flex, v_flex, v_virtual_list};
@@ -12,17 +12,12 @@ use super::RepoDetailView;
/// Height of one commit row in the virtual list. /// Height of one commit row in the virtual list.
pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.; pub(super) const COMMIT_ROW_HEIGHT: f32 = 56.;
/// One row of the commit list: id, summary, author and relative time. pub(super) fn commit_row(
/// Clicking a row opens the diff of that commit in a new panel.
fn commit_row(
ix: usize, ix: usize,
commit: &FileCommit, commit: &FileCommit,
view: &WeakEntity<RepoDetailView>, on_click: impl Fn(&mut Window, &mut App) + 'static,
cx: &App, cx: &App,
) -> AnyElement { ) -> AnyElement {
let view = view.clone();
let id = commit.id.clone();
h_flex() h_flex()
.id(ix) .id(ix)
.px_4() .px_4()
@@ -70,11 +65,7 @@ fn commit_row(
.child(relative_time_secs(commit.time)), .child(relative_time_secs(commit.time)),
), ),
) )
.on_click(move |_event, window, cx| { .on_click(move |_event, window, cx| on_click(window, cx))
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| this.open_commit_diff(&id, window, cx));
}
})
.into_any_element() .into_any_element()
} }
@@ -114,22 +105,34 @@ impl RepoDetailView {
.w_full() .w_full()
.min_h_0() .min_h_0()
.child( .child(
v_virtual_list( v_virtual_list(view, "commits", sizes, move |this, range, _window, cx| {
view, let view = cx.entity().downgrade();
"repo-commits",
sizes,
move |this, range, _window, cx| {
let commits = this let commits = this
.all_commits .all_commits
.as_ref() .as_ref()
.map(|list| list.commits.as_slice()) .map(|list| list.commits.as_slice())
.unwrap_or(&[]); .unwrap_or(&[]);
let view = cx.entity().downgrade();
range range
.map(|ix| commit_row(ix, &commits[ix], &view, cx)) .map(|ix| {
.collect() let id = commits[ix].id.clone();
let view = view.clone();
commit_row(
ix,
&commits[ix],
move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx)
});
}
}, },
cx,
) )
})
.collect()
})
.track_scroll(&scroll_handle) .track_scroll(&scroll_handle)
.size_full(), .size_full(),
) )
+162 -117
View File
@@ -28,22 +28,13 @@ use super::helpers::{
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Detail panel showing the diff of one commit. /// The tree + per-file diff body shared by the commit diff panel and the
pub struct CommitDiffView { /// compare view of the new-pull-request panel. Owns the changed-files
focus_handle: FocusHandle, /// explorer and the virtual list of the selected file's hunks; the host
/// Local clone the commit lives in. /// feeds it a [`CommitDiff`] via [`DiffPane::set_diff`].
worktree: PathBuf, pub struct DiffPane {
/// Display name of the repository the commit belongs to. /// Loaded diff; `None` until [`Self::set_diff`] is called.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit,
/// Loaded diff; `None` while loading or after a failure.
diff: Option<CommitDiff>, diff: Option<CommitDiff>,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer state. /// Changed-files explorer state.
tree_state: Entity<TreeState>, tree_state: Entity<TreeState>,
/// Path of the file whose diff is shown in the detail column. /// Path of the file whose diff is shown in the detail column.
@@ -55,81 +46,27 @@ pub struct CommitDiffView {
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows. /// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
} }
impl CommitDiffView { impl DiffPane {
pub fn new( pub fn new(cx: &mut Context<Self>) -> Self {
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let tree_state = cx.new(|cx| TreeState::new(cx));
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self { Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
diff: None, diff: None,
loading: true, tree_state: cx.new(|cx| TreeState::new(cx)),
error: None,
tree_state,
selected_file: None, selected_file: None,
rows: Vec::new(), rows: Vec::new(),
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
tasks: Vec::new(),
} }
} }
/// Load the commit diff (and the full commit metadata) on a background /// The loaded diff, for stats and badges in the host's header.
/// task and populate the tree. pub fn diff(&self) -> Option<&CommitDiff> {
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) { self.diff.as_ref()
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
} }
match diff {
Ok(diff) => { /// Replace the diff and rebuild the tree and the selected file's rows.
pub fn set_diff(&mut self, diff: CommitDiff, cx: &mut Context<Self>) {
let mut paths: Vec<PathBuf> = diff let mut paths: Vec<PathBuf> = diff
.files .files
.iter() .iter()
@@ -141,28 +78,28 @@ impl CommitDiffView {
.files .files
.first() .first()
.map(|file| SharedString::from(file.path.as_str())); .map(|file| SharedString::from(file.path.as_str()));
this.tree_state.update(cx, |state, cx| { self.tree_state.update(cx, |state, cx| {
state.set_items(items.clone(), cx); state.set_items(items.clone(), cx);
let item = find_item(&items, first.as_deref()); let item = find_item(&items, first.as_deref());
state.set_selected_item(item, cx); state.set_selected_item(item, cx);
}); });
this.selected_file = first.clone(); self.selected_file = first.clone();
this.diff = Some(diff); self.diff = Some(diff);
if let Some(path) = first { if let Some(path) = first {
this.set_diff_rows(path.as_ref()); self.set_diff_rows(path.as_ref());
} }
} }
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(()) /// Forget the diff (e.g. when the compared branches changed): clear the
/// tree, the selection and the diff rows.
pub fn clear(&mut self, cx: &mut Context<Self>) {
self.diff = None;
self.selected_file = None;
self.rows = Vec::new();
self.item_sizes = Rc::new(Vec::new());
self.tree_state.update(cx, |state, cx| {
state.set_items(Vec::new(), cx);
}); });
self.tasks.push(task);
} }
/// Show the diff of the file at `path` (selected in the tree). /// Show the diff of the file at `path` (selected in the tree).
@@ -226,8 +163,8 @@ impl CommitDiffView {
.p_2(), .p_2(),
) )
}) })
.when(self.diff.is_none() && !self.loading, |this| { .when(self.diff.is_none(), |this| {
this.child(placeholder("Failed to load diff", cx)) this.child(placeholder("No changes", cx))
}), }),
) )
.into_any_element() .into_any_element()
@@ -235,23 +172,12 @@ impl CommitDiffView {
/// Right column: header of the selected file plus its diff. /// Right column: header of the selected file plus its diff.
fn render_detail_column(&self, cx: &mut Context<Self>) -> AnyElement { 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 { let Some(diff) = self.diff.as_ref() else {
return placeholder("Failed to load diff", cx); return placeholder("No changes", cx);
}; };
let Some(path) = self.selected_file.clone() else { let Some(path) = self.selected_file.clone() else {
return if diff.files.is_empty() { return if diff.files.is_empty() {
placeholder("No files changed in this commit", cx) placeholder("No files changed", cx)
} else { } else {
placeholder("Select a file", cx) placeholder("Select a file", cx)
}; };
@@ -377,11 +303,126 @@ impl CommitDiffView {
.child(div().id("commit-diff-body").flex_1().min_h_0().child(body)) .child(div().id("commit-diff-body").flex_1().min_h_0().child(body))
.into_any_element() .into_any_element()
} }
}
impl Render for DiffPane {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
h_flex()
.size_full()
.min_h_0()
.bg(cx.theme().background)
.child(self.render_tree_column(cx))
.child(self.render_detail_column(cx))
}
}
/// Detail panel showing the diff of one commit: a metadata header plus the
/// shared [`DiffPane`] body.
pub struct CommitDiffView {
focus_handle: FocusHandle,
/// Local clone the commit lives in.
worktree: PathBuf,
/// Display name of the repository the commit belongs to.
repo_name: SharedString,
/// The commit being shown (header and tab title). Starts as an id-only
/// stub; [`Self::load`] replaces it with the full metadata, which the
/// history list intentionally omits.
commit: FileCommit,
/// The diff is being computed on a background task.
loading: bool,
error: Option<SharedString>,
/// Changed-files explorer and per-file diff, shared with the compare
/// view of the new-pull-request panel.
pane: Entity<DiffPane>,
/// In-flight tasks; pruned on every push (see [`helpers::track`]).
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
}
impl CommitDiffView {
pub fn new(
worktree: PathBuf,
repo_name: SharedString,
commit_id: String,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let pane = cx.new(DiffPane::new);
// Defer until the window is ready, like the repository detail view.
cx.defer_in(window, |this, window, cx| {
this.load(window, cx);
});
Self {
focus_handle: cx.focus_handle(),
worktree,
repo_name,
commit: FileCommit {
id: commit_id,
summary: String::new(),
description: None,
author: String::new(),
time: 0,
},
loading: true,
error: None,
pane,
tasks: Vec::new(),
}
}
/// Load the commit diff (and the full commit metadata) on a background
/// task and populate the tree.
fn load(&mut self, window: &mut Window, cx: &mut Context<Self>) {
self.loading = true;
self.error = None;
cx.notify();
let worktree = self.worktree.clone();
let id = self.commit.id.clone();
let task = cx.spawn_in(window, async move |this, cx| {
let commit = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit(&worktree, &id) }
})
.await;
let diff = cx
.background_spawn({
let worktree = worktree.clone();
let id = id.clone();
async move { signed_git::worktree_commit_diff(&worktree, &id) }
})
.await;
this.update_in(cx, |this, _window, cx| {
this.loading = false;
if let Ok(Some(commit)) = commit {
this.commit = commit;
}
match diff {
Ok(diff) => {
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// 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;
let (files, insertions, deletions) = self.diff.as_ref().map_or((0, 0, 0), |diff| { let (files, insertions, deletions) = self.pane.read(cx).diff().map_or((0, 0, 0), |diff| {
( (
diff.files.len(), diff.files.len(),
diff.files.iter().map(|file| file.insertions).sum(), diff.files.iter().map(|file| file.insertions).sum(),
@@ -481,6 +522,19 @@ impl Focusable for CommitDiffView {
impl Render for CommitDiffView { impl Render for CommitDiffView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let body: AnyElement = if self.loading {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element()
} else if let Some(error) = self.error.clone() {
placeholder(&error, cx)
} else {
self.pane.clone().into_any_element()
};
v_resizable("commit-diff") v_resizable("commit-diff")
.child( .child(
resizable_panel() resizable_panel()
@@ -490,15 +544,6 @@ impl Render for CommitDiffView {
.bg(cx.theme().background) .bg(cx.theme().background)
.child(self.render_header(cx)), .child(self.render_header(cx)),
) )
.child( .child(resizable_panel().child(body))
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)),
),
)
} }
} }
@@ -21,16 +21,13 @@ use utils::relative_time;
/// Detail panel of a single issue. /// Detail panel of a single issue.
pub struct IssueDetailView { pub struct IssueDetailView {
focus_handle: FocusHandle,
/// Repo store holding the issues and their statuses. /// Repo store holding the issues and their statuses.
store: Entity<RepoStore>, store: Entity<RepoStore>,
issue_id: EventId, issue_id: EventId,
contents: HashMap<EventId, SharedString>,
/// Input state of the "leave a comment" textarea. /// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
/// Issue/comment bodies as shared strings, keyed by event ID, so focus_handle: FocusHandle,
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
} }
impl IssueDetailView { impl IssueDetailView {
@@ -283,7 +280,13 @@ impl Render for IssueDetailView {
let content = self let content = self
.contents .contents
.entry(issue.id) .entry(issue.id)
.or_insert_with(|| SharedString::from(issue.content.clone())) .or_insert_with(|| {
if issue.content.is_empty() {
SharedString::from("No description provided.")
} else {
SharedString::from(&issue.content)
}
})
.clone(); .clone();
( (
@@ -338,8 +341,7 @@ impl Render for IssueDetailView {
h_flex() h_flex()
.gap_1() .gap_1()
.child( .child(
UserAvatar::new(author.clone()) UserAvatar::new(&author).picture(picture),
.picture(picture),
) )
.child(author), .child(author),
) )
+180 -6
View File
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet, VecDeque}; use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf}; use std::path::{Component, Path, PathBuf};
use std::rc::Rc; use std::rc::Rc;
use std::time::Duration;
use anyhow::Error; use anyhow::Error;
use assets::CustomIconName; use assets::CustomIconName;
@@ -26,9 +27,9 @@ use gpui_component::{
VirtualListScrollHandle, h_flex, v_flex, VirtualListScrollHandle, h_flex, v_flex,
}; };
use nostr::prelude::{EventId, RelayUrl, ToBech32}; use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement; use signed_core::{Announcement, RepoAddr, filters};
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore}; use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoListStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row}; use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
@@ -40,8 +41,10 @@ mod helpers;
mod init_dialog; mod init_dialog;
mod issue_detail; mod issue_detail;
mod issues; mod issues;
mod new_pull_request;
mod pull_request_detail; mod pull_request_detail;
mod pull_requests; mod pull_requests;
mod send_patch;
use about::open_about_dialog; use about::open_about_dialog;
use browser::{ use browser::{
@@ -52,7 +55,10 @@ use commits::COMMIT_ROW_HEIGHT;
use diff::CommitDiffView; use diff::CommitDiffView;
use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items}; use helpers::{ShareTargets, TreeItemSeed, build_tree_items, is_markdown_path, tree_items};
use issues::{IssuesView, open_new_issue_dialog}; use issues::{IssuesView, open_new_issue_dialog};
use pull_requests::{PullRequestsView, open_new_pull_request_dialog}; use pull_requests::PullRequestsView;
use send_patch::open_send_patch_panel;
use crate::views::repo_detail::new_pull_request::open_new_pull_panel;
/// What kind of ref the header selectors switch to. /// What kind of ref the header selectors switch to.
#[derive(Clone, Copy, PartialEq, Eq)] #[derive(Clone, Copy, PartialEq, Eq)]
@@ -64,13 +70,17 @@ enum RefKind {
} }
/// Header actions dispatched by the dropdown menus of the header buttons. /// Header actions dispatched by the dropdown menus of the header buttons.
/// `pub(super)`: the pull-request list panel offers the same New-PR / Send-
/// patch actions in its own dropdown.
#[derive(Clone, Action, PartialEq, Eq)] #[derive(Clone, Action, PartialEq, Eq)]
#[action(namespace = repo_detail, no_json)] #[action(namespace = repo_detail, no_json)]
enum RepoAction { pub(super) enum RepoAction {
/// Open the "new issue" dialog. /// Open the "new issue" dialog.
NewIssue, NewIssue,
/// Open the "new pull request" dialog. /// Open the "new pull request" dialog.
NewPR, NewPR,
/// Open the "send patch" panel.
SendPatch,
/// Open the about dialog. /// Open the about dialog.
About, About,
/// Re-push the repository to its grasp servers. /// Re-push the repository to its grasp servers.
@@ -188,6 +198,9 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive. /// Subscriptions keeping the selectors' confirm events alive.
_subscriptions: Vec<Subscription>, _subscriptions: Vec<Subscription>,
/// Upstream repository (from this fork's `u` tag) the user asked to
/// open, while its announcement is still being fetched.
pending_upstream: Option<RepoAddr>,
} }
impl RepoDetailView { impl RepoDetailView {
@@ -315,6 +328,7 @@ impl RepoDetailView {
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
tasks: Vec::new(), tasks: Vec::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
pending_upstream: None,
} }
} }
@@ -959,6 +973,77 @@ impl RepoDetailView {
}); });
} }
/// Open the upstream repository (the `u` tag of this fork's announcement).
/// When the upstream announcement is not in the local database yet,
/// subscribe for it and open the panel as soon as it lands.
fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pending_upstream.is_some() {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
let Some(addr) = announcement.upstream.and_then(|upstream| upstream.addr) else {
return;
};
if let Some(found) = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned()
{
open_repo_panel(&self.dock_area, &found, window, &mut *cx);
return;
}
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
});
self.pending_upstream = Some(addr);
let task = cx.spawn_in(window, async move |this, cx| {
for _ in 0..60 {
cx.background_executor()
.timer(Duration::from_millis(250))
.await;
let opened = this.update_in(cx, |this, window, cx| {
let Some(addr) = this.pending_upstream.clone() else {
return true;
};
let found = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == addr)
.cloned();
match found {
Some(found) => {
this.pending_upstream = None;
open_repo_panel(&this.dock_area, &found, window, &mut *cx);
true
}
None => false,
}
})?;
if opened {
return Ok(());
}
}
this.update(cx, |this, _cx| this.pending_upstream = None)?;
Ok(())
});
self.tasks.push(task);
}
/// Check out `name` (a branch or tag picked in the header) and refresh /// Check out `name` (a branch or tag picked in the header) and refresh
/// the explorer once the switch completes. /// the explorer once the switch completes.
fn switch_ref( fn switch_ref(
@@ -1285,7 +1370,12 @@ impl RepoDetailView {
} }
RepoAction::NewPR => { RepoAction::NewPR => {
if let Some(store) = this.store.clone() { if let Some(store) = this.store.clone() {
open_new_pull_request_dialog(store, window, cx); open_new_pull_panel(this.dock_area.clone(), store, window, cx);
}
}
RepoAction::SendPatch => {
if let Some(store) = this.store.clone() {
open_send_patch_panel(this.dock_area.clone(), store, window, cx);
} }
} }
RepoAction::About => { RepoAction::About => {
@@ -1332,6 +1422,7 @@ impl RepoDetailView {
.text_ellipsis() .text_ellipsis()
.child(description), .child(description),
) )
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
.child( .child(
h_flex() h_flex()
.mt_2() .mt_2()
@@ -1432,8 +1523,18 @@ impl RepoDetailView {
.gap_2() .gap_2()
.text_sm() .text_sm()
.child(Icon::new(IconName::Plus)) .child(Icon::new(IconName::Plus))
.child("New PR") .child("New Pull Request")
}) })
.menu_element(
Box::new(RepoAction::SendPatch),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::File))
.child("Send Patch")
},
)
}), }),
) )
.child( .child(
@@ -2015,3 +2116,76 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
SharedString::from(url) SharedString::from(url)
} }
/// The "Forked from …" row of the detail header: a clickable link to the
/// upstream repository when the `u` tag references a NIP-34 repo,
/// plain text when it only carries a git URL.
fn fork_row(announcement: &Announcement, cx: &mut Context<RepoDetailView>) -> Option<AnyElement> {
let upstream = announcement.upstream.as_ref()?;
let (label, clickable) = match &upstream.addr {
Some(addr) => {
// Prefer the upstream's display name when its announcement
// is already known locally fall back to its repository id.
let name = RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == *addr)
.map(|a| {
a.name
.clone()
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
(SharedString::from(format!("Forked from {name}")), true)
}
None => (upstream.display(), false),
};
let row = h_flex()
.gap_1()
.items_center()
.min_w_0()
.text_sm()
.text_color(cx.theme().muted_foreground)
.child(Icon::new(CustomIconName::GitBranch).small())
.child(div().whitespace_nowrap().text_ellipsis().child(label));
Some(if clickable {
row.id("fork-upstream")
.cursor_pointer()
.hover(|this| this.text_color(cx.theme().foreground))
.on_click(cx.listener(|this, _ev, window, cx| this.open_upstream(window, cx)))
.into_any_element()
} else {
row.into_any_element()
})
}
/// Open `announcement` as a repository panel in the dock's center, returning
/// the new detail view. Shared by the explore list, the sidebar and fork
/// links so every entry point opens repositories identically.
pub(crate) fn open_repo_panel(
dock_area: &WeakEntity<DockArea>,
announcement: &Announcement,
window: &mut Window,
cx: &mut App,
) -> Entity<RepoDetailView> {
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail.clone()),
DockPlacement::Center,
None,
window,
cx,
);
});
}
detail
}
@@ -0,0 +1,867 @@
//! The "new pull request" panel: pick a local checkout, a base and a
//! compare branch (GitHub-style), review the diff and the commit list, then
//! publish the PR with only a title and an optional description. The patch
//! series is generated from the checkout at submit time; there is no patch
//! input.
use std::path::{Path, PathBuf};
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, PathPromptOptions,
Pixels, Render, SharedString, Size, Subscription, Task, WeakEntity, Window, div, px, relative,
size,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
};
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_component::scroll::Scrollbar;
use gpui_component::searchable_list::SearchableVec;
use gpui_component::spinner::Spinner;
use gpui_component::{
ActiveTheme, Disableable, Icon, IconName, Sizable, VirtualListScrollHandle, h_flex, v_flex,
v_virtual_list,
};
use signed_git::{
format_patch_between, merge_base, worktree_commit_range_commits, worktree_commit_range_diff,
};
use signed_state::RepoStore;
use signed_ui::placeholder;
use super::commits::{COMMIT_ROW_HEIGHT, commit_row};
use super::diff::{CommitDiffView, DiffPane};
/// The "new pull request" panel of a repository.
///
/// Both branch selectors list the branches of a user-chosen local checkout;
/// the compare view (Files/Commits tabs) is built from `merge-base..compare`
/// in that checkout, and the patch series published with the PR is generated
/// from the same range at submit time.
pub struct NewPullRequestView {
focus_handle: FocusHandle,
/// Dock area the panel lives in; commit diffs are opened there.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository (for the announced HEAD default).
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// The user's checkout: where both branches live and where the tip is
/// pushed from.
repo_path: Option<PathBuf>,
/// Branches of the checkout, backing both selectors.
branches: Vec<SharedString>,
/// Selected base branch (the target of the PR).
base: SharedString,
/// Selected compare branch (the source of the PR).
compare: SharedString,
base_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
compare_select: Entity<ComboboxState<SearchableVec<SharedString>>>,
/// Title input (required).
subject: Entity<InputState>,
/// Description input (optional).
description: Entity<TextareaState>,
/// Merge base of the selected branches; `None` until the compare loads.
merge_base: Option<String>,
/// Commits in `merge_base..compare`, newest first.
commits: Option<Vec<signed_git::FileCommit>>,
/// The compare is being computed.
loading: bool,
/// Error of the last compare or submit attempt.
error: Option<SharedString>,
/// A submit (patch generation + publish) is in flight.
submitting: bool,
/// Bumped on every branch switch; stale compare results are discarded.
compare_generation: u64,
/// Active tab: 0 = Files, 1 = Commits.
active_tab: usize,
/// The compare diff (Files tab).
pane: Entity<DiffPane>,
/// Virtual list state of the Commits tab.
scroll_handle: VirtualListScrollHandle,
item_sizes: Rc<Vec<Size<Pixels>>>,
_subscriptions: Vec<Subscription>,
tasks: Vec<Task<Result<(), anyhow::Error>>>,
}
impl NewPullRequestView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let pane = cx.new(DiffPane::new);
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe..."));
let base_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let compare_select: Entity<ComboboxState<SearchableVec<SharedString>>> = cx.new(|cx| {
ComboboxState::new(
SearchableVec::new(Vec::<SharedString>::new()),
Vec::new(),
window,
cx,
)
.searchable(true)
});
let subscriptions = vec![
// Re-evaluate the Create button's enabled state as the title
// changes.
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe_in(&base_select, window, |this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.base = name.clone();
this.reload_compare(window, cx);
}
}),
cx.subscribe_in(
&compare_select,
window,
|this, _state, event, window, cx| {
if let ComboboxEvent::Change(values) = event
&& let Some(name) = values.first()
{
this.compare = name.clone();
this.reload_compare(window, cx);
}
},
),
];
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
repo_path: None,
branches: Vec::new(),
base: SharedString::default(),
compare: SharedString::default(),
base_select,
compare_select,
subject,
description,
merge_base: None,
commits: None,
loading: false,
error: None,
submitting: false,
compare_generation: 0,
active_tab: 0,
pane,
scroll_handle: VirtualListScrollHandle::new(),
item_sizes: Rc::new(Vec::new()),
_subscriptions: subscriptions,
tasks: Vec::new(),
}
}
/// Prompt for a local checkout; on success populate the branch selectors
/// (defaults: the announced HEAD branch for the base, the checkout's
/// current branch for the compare) and load the compare.
fn choose_checkout(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let prompt = cx.prompt_for_paths(PathPromptOptions {
files: false,
directories: true,
multiple: false,
prompt: Some("Choose local checkout".into()),
});
let task = cx.spawn_in(window, async move |this, cx| {
// `Ok(Ok(Some(paths)))` means the user picked a folder; a
// cancel (or a picker failure) resolves to anything else.
let picked = match prompt.await {
Ok(Ok(Some(mut paths))) => paths.pop(),
_ => None,
};
let Some(path) = picked else {
return Ok(());
};
let path = path.to_string_lossy().to_string();
// Branches and the current branch are read off the UI thread.
let info = cx
.background_spawn({
let path = path.clone();
async move {
let repo = gix::open(Path::new(&path)).ok()?;
let branches =
signed_git::worktree_branches(Path::new(&path)).unwrap_or_default();
let current = signed_git::current_branch(&repo).ok().flatten();
Some((branches, current))
}
})
.await;
this.update_in(cx, |this, window, cx| {
this.apply_checkout(path, info, window, cx);
})?;
Ok(())
});
self.tasks.push(task);
}
/// Apply a picked checkout: fill the selectors and load the compare.
fn apply_checkout(
&mut self,
path: String,
info: Option<(Vec<String>, Option<String>)>,
window: &mut Window,
cx: &mut Context<Self>,
) {
let Some((branches, current)) = info else {
self.error = Some("The chosen folder is not a git repository".into());
self.repo_path = None;
self.branches.clear();
self.merge_base = None;
self.commits = None;
self.pane.update(cx, |pane, cx| pane.clear(cx));
cx.notify();
return;
};
if branches.is_empty() {
self.error = Some("The repository has no branches yet".into());
self.repo_path = None;
self.branches.clear();
cx.notify();
return;
}
// Defaults: the announced HEAD branch when the checkout has it
// (falling back to `main`, then the first branch); the checkout's
// current branch for the compare side.
let announced = self.store.read(cx).head.clone();
let base = announced
.as_ref()
.filter(|branch| branches.contains(branch))
.cloned()
.or_else(|| branches.iter().find(|branch| *branch == "main").cloned())
.unwrap_or_else(|| branches[0].clone());
let compare = current
.filter(|branch| branches.contains(branch))
.unwrap_or_else(|| base.clone());
self.repo_path = Some(PathBuf::from(&path));
self.error = None;
self.branches = branches.into_iter().map(SharedString::from).collect();
let branches = self.branches.clone();
let base = SharedString::from(base.clone());
let compare = SharedString::from(compare.clone());
self.base = base.clone();
self.compare = compare.clone();
self.base_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches.clone()), window, cx);
state.set_selected_values(&[base], window, cx);
});
self.compare_select.update(cx, |state, cx| {
state.set_items(SearchableVec::from(branches), window, cx);
state.set_selected_values(&[compare], window, cx);
});
self.reload_compare(window, cx);
}
/// (Re)compute `merge_base..compare` of the selected branches on a
/// background task: the merge base, the commit list and the diff.
fn reload_compare(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let base = self.base.to_string();
let compare = self.compare.to_string();
self.loading = true;
self.error = None;
self.compare_generation += 1;
let generation = self.compare_generation;
cx.notify();
if base == compare {
self.loading = false;
self.merge_base = None;
self.commits = None;
self.pane.update(cx, |pane, cx| pane.clear(cx));
self.error = Some("Choose different base and compare branches".into());
cx.notify();
return;
}
let task = cx.spawn_in(window, async move |this, cx| {
let result = cx
.background_spawn({
let repo_path = repo_path.clone();
let base = base.clone();
let compare = compare.clone();
async move {
let merge_base = merge_base(Path::new(&repo_path), &base, &compare)?
.ok_or_else(|| {
anyhow::anyhow!("{base} and {compare} share no common ancestor")
})?;
let commits = worktree_commit_range_commits(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
let diff = worktree_commit_range_diff(
Path::new(&repo_path),
&merge_base,
&compare,
)?;
Ok::<_, anyhow::Error>((merge_base, commits, diff))
}
})
.await;
this.update_in(cx, |this, _window, cx| {
// A stale result (the branches changed mid-flight) must not
// clobber a newer compare; the newer task clears the flag.
if generation != this.compare_generation {
return;
}
this.loading = false;
match result {
Ok((merge_base, commits, diff)) => {
this.merge_base = Some(merge_base);
let count = commits.len();
this.item_sizes = Rc::new(vec![size(px(0.), px(COMMIT_ROW_HEIGHT)); count]);
this.commits = Some(commits);
this.pane.update(cx, |pane, cx| pane.set_diff(diff, cx));
}
Err(error) => {
this.merge_base = None;
this.commits = None;
this.pane.update(cx, |pane, cx| pane.clear(cx));
this.error = Some(error.to_string().into());
}
}
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Publish the pull request: generate the patch series from the checkout
/// on a background task, hand it to the store, and close the panel once
/// the publish is underway (errors surface in the pull request list).
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting || self.loading {
return;
}
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let Some(merge_base) = self.merge_base.clone() else {
return;
};
let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string();
let branch_name = self.compare.to_string();
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
self.submitting = true;
self.error = None;
cx.notify();
let task = cx.spawn_in(window, async move |this, cx| {
// Regenerate the series at submit time so the published patch
// covers the current tip of the compare branch.
let patch = cx
.background_spawn({
let repo_path = repo_path.clone();
let merge_base = merge_base.clone();
let branch_name = branch_name.clone();
async move {
format_patch_between(Path::new(&repo_path), &merge_base, &branch_name)
}
})
.await;
let patch = match patch {
Ok(patch) if !patch.is_empty() => patch,
Ok(_) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some("No commits between the branches to propose".into());
cx.notify();
})?;
return Ok(());
}
Err(error) => {
this.update_in(cx, |this, _window, cx| {
this.submitting = false;
this.error = Some(format!("Failed to generate the patch: {error}").into());
cx.notify();
})?;
return Ok(());
}
};
this.update_in(cx, |this, window, cx| {
this.submitting = false;
store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
description,
Some(branch_name),
patch,
false,
Some(merge_base),
Some(repo_path),
cx,
);
});
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
let entity = entity.clone();
move |_, window, cx| {
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock, cx| {
dock.remove_panel(entity, window, cx);
});
}
}
});
cx.notify();
})?;
Ok(())
});
self.tasks.push(task);
}
/// Open the diff of `commit_id` (from the Commits tab) in a new panel.
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
let Some(repo_path) = self.repo_path.clone() else {
return;
};
let Some(dock_area) = self.dock_area.upgrade() else {
return;
};
let panel = cx.new(|cx| {
CommitDiffView::new(
repo_path,
self.repo_name.clone(),
commit_id.into(),
window,
cx,
)
});
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
/// The compare bar: base/compare selectors, the checkout chooser and the
/// Create button.
fn render_compare_bar(&self, cx: &mut Context<Self>) -> AnyElement {
let has_checkout = self.repo_path.is_some();
let checkout = self.repo_path.clone();
let can_submit = has_checkout
&& !self.loading
&& !self.submitting
&& self.merge_base.is_some()
&& self
.commits
.as_ref()
.is_some_and(|commits| !commits.is_empty())
&& !self.subject.read(cx).value().is_empty();
h_flex()
.px_4()
.h_16()
.w_full()
.gap_2()
.items_end()
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Merge Into"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.base_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_checkout)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
v_flex()
.gap_1()
.child(
div()
.text_xs()
.font_semibold()
.text_color(cx.theme().muted_foreground)
.child("Pull From"),
)
.child(
div().w(px(140.)).child(
Combobox::new(&self.compare_select)
.placeholder("branch")
.appearance(false)
.menu_width(px(220.))
.disabled(!has_checkout)
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.render_trigger(|ctx, _window, cx| {
render_ref_trigger(ctx, CustomIconName::GitBranch, cx)
}),
),
),
)
.child(
Button::new("choose-checkout")
.icon(IconName::Folder)
.ghost()
.tooltip(checkout.as_ref().map_or_else(
|| "Choose a local checkout".into(),
|path| path.display().to_string(),
))
.on_click(cx.listener(|this, _event, window, cx| {
this.choose_checkout(window, cx);
})),
)
.child(div().flex_1())
.child(
Button::new("create-pr")
.primary()
.icon(IconName::Plus)
.loading(self.submitting)
.disabled(!can_submit)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
)
.into_any_element()
}
/// The title and description inputs.
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
.w_full()
.gap_2()
.child(Input::new(&self.subject))
.child(Textarea::new(&self.description).h_24())
.into_any_element()
}
/// The Files/Commits tab bar, mirroring the repository panel's.
fn render_tabs(&self, cx: &mut Context<Self>) -> AnyElement {
let files = self.pane.read(cx).diff().map_or(0, |diff| diff.files.len());
let commits = self.commits.as_ref().map_or(0, |commits| commits.len());
h_flex()
.px_4()
.pb_4()
.w_full()
.gap_2()
.items_center()
.border_b_1()
.border_color(cx.theme().border)
.child(
BaseButton::new("files-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitFile).small())
.child("Files"),
)
.child(count_badge(files, cx))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 0)
.when(self.active_tab == 0, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 0;
cx.notify();
})),
)
.child(
BaseButton::new("commits-tab")
.flex()
.items_center()
.h_8()
.px_2()
.gap_2()
.child(
h_flex()
.gap_1()
.text_sm()
.child(Icon::new(CustomIconName::GitCommit).small())
.child("Commits"),
)
.child(count_badge(commits, cx))
.text_color(cx.theme().button_foreground)
.rounded(cx.theme().radius)
.hover(|this| this.bg(cx.theme().button_hover))
.active(|this| this.bg(cx.theme().button_active))
.selected(self.active_tab == 1)
.when(self.active_tab == 1, |this| {
this.bg(cx.theme().button_active)
})
.on_click(cx.listener(|this, _event, _window, cx| {
this.active_tab = 1;
cx.notify();
})),
)
.into_any_element()
}
/// The active tab's body.
fn render_content(&self, cx: &mut Context<Self>) -> AnyElement {
if self.loading {
return v_flex()
.size_full()
.items_center()
.justify_center()
.child(Spinner::new().small())
.into_any_element();
}
if self.repo_path.is_none() {
return placeholder("Choose a local checkout to compare branches", cx);
}
if self.commits.is_none() && self.error.is_some() {
return placeholder("Nothing to compare", cx);
}
match self.active_tab {
0 => self.pane.clone().into_any_element(),
_ => self.render_commits_tab(cx),
}
}
/// The Commits tab: `merge_base..compare` in a virtual list; clicking a
/// row opens the commit's diff in a new panel.
fn render_commits_tab(&self, cx: &mut Context<Self>) -> AnyElement {
let Some(commits) = self.commits.as_ref() else {
return placeholder("No commits", cx);
};
if commits.is_empty() {
return placeholder("No commits between the branches", cx);
}
let view = cx.entity().clone();
let sizes = self.item_sizes.clone();
let scroll_handle = self.scroll_handle.clone();
v_flex()
.relative()
.flex_1()
.w_full()
.min_h_0()
.child(
v_virtual_list(
view,
"pr-commits",
sizes,
move |this, range, _window, cx| {
let commits = this.commits.as_deref().unwrap_or(&[]);
let view = cx.entity().downgrade();
range
.map(|ix| {
let id = commits[ix].id.clone();
let view = view.clone();
commit_row(
ix,
&commits[ix],
move |window, cx| {
if let Some(view) = view.upgrade() {
view.update(cx, |this, cx| {
this.open_commit_diff(&id, window, cx)
});
}
},
cx,
)
})
.collect()
},
)
.track_scroll(&scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&scroll_handle)),
)
.into_any_element()
}
}
/// The count badge of a tab, styled like the repository panel's.
fn count_badge(count: usize, cx: &App) -> impl IntoElement {
h_flex()
.justify_center()
.px_1()
.py_0p5()
.min_w_4()
.text_size(px(8.))
.bg(cx.theme().muted)
.text_color(cx.theme().muted_foreground)
.rounded(cx.theme().radius)
.line_height(relative(1.))
.child(SharedString::from(count.to_string()))
}
/// The trigger of a branch selector: icon + current selection (or
/// placeholder) + caret. `Combobox` replaces its default trigger entirely.
fn render_ref_trigger(
ctx: &ComboboxTriggerContext<SearchableVec<SharedString>>,
icon: CustomIconName,
cx: &App,
) -> AnyElement {
let muted = cx.theme().muted_foreground;
h_flex()
.w_full()
.min_w_0()
.gap_1()
.items_center()
.child(Icon::new(icon).small().flex_shrink_0())
.child(
div()
.flex_1()
.min_w_0()
.overflow_hidden()
.text_ellipsis()
.whitespace_nowrap()
.when(ctx.selection().is_empty(), |this| this.text_color(muted))
.child(
ctx.selection()
.first()
.map(|(_, item)| item.clone())
.or_else(|| ctx.placeholder().cloned())
.unwrap_or_default(),
),
)
.child(Caret::new(ctx.size()).text_color(muted))
.into_any_element()
}
/// Open the "new pull request" panel in the center dock.
pub(super) fn open_new_pull_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let panel = cx.new(|cx| NewPullRequestView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
impl BasePanel for NewPullRequestView {
fn panel_name(&self) -> &'static str {
"new-pull-request"
}
}
impl Panel for NewPullRequestView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!(
"{}/new-pull-request",
self.repo_name
)))
}
}
impl EventEmitter<PanelEvent> for NewPullRequestView {}
impl Focusable for NewPullRequestView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for NewPullRequestView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("new-pr")
.size_full()
.child(
v_flex()
.gap_4()
.child(self.render_compare_bar(cx))
.child(self.render_inputs(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
h_flex()
.px_4()
.py_1()
.w_full()
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(self.render_tabs(cx)),
)
.child(
v_flex()
.flex_1()
.min_h_0()
.w_full()
.child(self.render_content(cx)),
)
}
}
@@ -12,6 +12,8 @@ use gpui::{
}; };
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard; use gpui_component::clipboard::Clipboard;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Textarea, TextareaState}; use gpui_component::input::{Textarea, TextareaState};
use gpui_component::list::ListItem; use gpui_component::list::ListItem;
use gpui_component::scroll::{ScrollableElement, Scrollbar}; use gpui_component::scroll::{ScrollableElement, Scrollbar};
@@ -20,12 +22,13 @@ use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag; use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree}; use gpui_component::tree::{TreeEntry, TreeState, tree};
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, ActiveTheme, Icon, Sizable, StyledExt, VirtualListScrollHandle, WindowExt, h_flex, v_flex,
v_virtual_list,
}; };
use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey}; use nostr::prelude::{Event, EventId, Kind, Nip34Tag, PublicKey};
use signed_core::{activity_subject, pull_request_patch}; use signed_core::{activity_subject, pull_request_patch};
use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs}; use signed_git::{CommitDiff, FileCommit, FileDiff, patch_commits, patch_diffs};
use signed_state::{GitStore, ProfileStore, RepoStore}; use signed_state::{Backend, GitStore, ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{UserAvatar, placeholder, status_badge, tree_row}; use signed_ui::{UserAvatar, placeholder, status_badge, tree_row};
use utils::{relative_time, relative_time_secs}; use utils::{relative_time, relative_time_secs};
@@ -183,7 +186,7 @@ impl PullRequestDetailView {
cx.notify(); cx.notify();
return; return;
}; };
let update = latest_update(store.pull_requests.iter(), &root.id); let update = latest_update(store.pull_requests.iter(), root);
let tip = update let tip = update
.and_then(current_commit_of) .and_then(current_commit_of)
.or_else(|| current_commit_of(root)); .or_else(|| current_commit_of(root));
@@ -1002,7 +1005,7 @@ impl PullRequestDetailView {
/// Always-visible header: status badge and title, like the issue panel. /// Always-visible header: status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone(); let current_commit = self.current_commit.clone();
let (title, status, branch) = { let (title, status, branch, author) = {
let store = self.store.read(cx); let store = self.store.read(cx);
let Some(root) = store let Some(root) = store
.pull_requests .pull_requests
@@ -1015,9 +1018,14 @@ impl PullRequestDetailView {
activity_subject(root), activity_subject(root),
store.status_of(root), store.status_of(root),
branch_name_of(root), branch_name_of(root),
root.pubkey,
) )
}; };
// Only the PR author may publish revisions (kind 1619, NIP-34).
let backend = Backend::global(cx);
let can_update = backend.read(cx).current_user() == Some(author);
v_flex() v_flex()
.px_4() .px_4()
.mb_4() .mb_4()
@@ -1048,6 +1056,38 @@ impl PullRequestDetailView {
.label(branch), .label(branch),
) )
}) })
.when(can_update, |this| {
this.child(
Button::new("update-pr")
.ghost()
.small()
.icon(CustomIconName::GitPullRequest)
.label("Update")
.tooltip("Publish a new revision of this pull request")
.on_click(cx.listener({
let store = self.store.clone();
let pr_id = self.pr_id;
move |_this, _event, window, cx| {
let root = store
.read(cx)
.pull_requests
.iter()
.find(|pr| {
pr.id == pr_id && pr.kind == Kind::GitPullRequest
})
.cloned();
if let Some(root) = root {
open_update_pull_request_dialog(
store.clone(),
root,
window,
cx,
);
}
}
})),
)
})
.when_some(current_commit, |this, id| { .when_some(current_commit, |this, id| {
this.child( this.child(
h_flex() h_flex()
@@ -1064,6 +1104,68 @@ impl PullRequestDetailView {
} }
} }
/// Open the "update pull request" dialog: a patch input that submits a new
/// revision through [`RepoStore::update_pull_request`] when confirmed.
fn open_update_pull_request_dialog(
store: Entity<RepoStore>,
root: Event,
window: &mut Window,
cx: &mut App,
) {
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("Paste the updated `git format-patch` output...")
});
// Both the dialog body and the submit button capture the root event;
// share it instead of cloning into each closure.
let root = Rc::new(root);
window.open_dialog(cx, move |dialog, _window, _cx| {
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("Update pull request"))
.child(DialogDescription::new().child(
"Publish a new revision with the output of `git format-patch`.",
)),
)
.child(
v_form().child(
field()
.label("Patch")
.child(Textarea::new(&patch).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Update pull request")
.tooltip("Update pull request")
.on_click({
let store = store.clone();
let patch = patch.clone();
let root = root.clone();
move |_event, window, cx| {
let patch = patch.read(cx).value().to_string();
store.update(cx, |store, cx| {
store.update_pull_request(&root, patch, cx);
});
window.close_dialog(cx);
}
}),
),
)
})
});
}
/// One sidebar section title. /// One sidebar section title.
fn sidebar_title(text: &str, cx: &App) -> AnyElement { fn sidebar_title(text: &str, cx: &App) -> AnyElement {
div() div()
@@ -1121,11 +1223,13 @@ fn branch_name_of(event: &Event) -> Option<String> {
} }
/// The latest PR update (kind 1619) revising `root`, found via its NIP-22 /// The latest PR update (kind 1619) revising `root`, found via its NIP-22
/// `E` tag pointing at the root PR event. /// `E` tag pointing at the root PR event. Only updates by the PR author
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &EventId) -> Option<&'a Event> { /// count: the tip of a PR is only mutable by its author (NIP-34).
let root_hex = root.to_hex(); fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events events
.filter(|e| e.kind == Kind::GitPullRequestUpdate) .filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| e.pubkey == root.pubkey)
.filter(|e| { .filter(|e| {
e.tags e.tags
.iter() .iter()
@@ -1262,16 +1366,35 @@ mod tests {
); );
let events = [unrelated, revision(200), root.clone(), revision(300)]; let events = [unrelated, revision(200), root.clone(), revision(300)];
let latest = latest_update(events.iter(), &root.id).expect("an update"); let latest = latest_update(events.iter(), &root).expect("an update");
assert_eq!(latest.created_at.as_secs(), 300); assert_eq!(latest.created_at.as_secs(), 300);
assert_eq!(latest.kind, Kind::GitPullRequestUpdate); assert_eq!(latest.kind, Kind::GitPullRequestUpdate);
} }
#[test]
fn latest_update_ignores_other_authors() {
let root = pr_root();
let root_hex = root.id.to_hex();
let other = Keys::new(
SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000002")
.expect("valid secret key"),
);
let stranger = EventBuilder::new(Kind::GitPullRequestUpdate, "")
.tags([Tag::parse(["E", &root_hex]).expect("valid tag")])
.custom_created_at(Timestamp::from(999))
.finalize(&other)
.expect("signed event");
// The tip of a PR is only mutable by its author: a newer update
// from anyone else must not win.
assert!(latest_update([&stranger, &root].into_iter(), &root).is_none());
}
#[test] #[test]
fn latest_update_ignores_roots_without_revisions() { fn latest_update_ignores_roots_without_revisions() {
let root = pr_root(); let root = pr_root();
assert!(latest_update([&root].into_iter(), &root.id).is_none()); assert!(latest_update([&root].into_iter(), &root).is_none());
} }
#[test] #[test]
@@ -7,22 +7,23 @@ use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
SharedString, Size, WeakEntity, Window, div, px, size, SharedString, Size, WeakEntity, Window, div, px, size,
}; };
use gpui_component::button::{Button, ButtonVariants}; use gpui_base::Button as BaseButton;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle}; use gpui_component::alert::Alert;
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
use gpui_component::scroll::Scrollbar; use gpui_component::scroll::Scrollbar;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, ActiveTheme, Icon, IconName, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list,
}; };
use nostr::prelude::{EventId, Kind}; use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject}; use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore}; use signed_state::{ProfileStore, RepoStore};
use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar, placeholder, status_badge}; use signed_ui::{DropdownButton, SegmentButton, UserAvatar, placeholder, status_badge};
use utils::relative_time; use utils::relative_time;
use super::RepoAction;
use super::new_pull_request::open_new_pull_panel;
use super::pull_request_detail::PullRequestDetailView; use super::pull_request_detail::PullRequestDetailView;
use super::send_patch::open_send_patch_panel;
/// 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. /// issue row.
@@ -40,8 +41,7 @@ enum PullRequestFilter {
Closed, Closed,
/// Pull requests whose resolved status is [`RepoStatus::Draft`]. /// Pull requests whose resolved status is [`RepoStatus::Draft`].
Draft, Draft,
/// Pull requests whose resolved status is [`RepoStatus::Applied`] /// Pull requests whose resolved status is [`RepoStatus::Applied`].
/// (i.e. merged).
Merged, Merged,
} }
@@ -217,7 +217,7 @@ impl PullRequestsView {
.child( .child(
h_flex() h_flex()
.h_12() .h_12()
.gap_2() .gap_1()
.child( .child(
SegmentButton::new("all", "All") SegmentButton::new("all", "All")
.icon(Icon::new(CustomIconName::GitPullRequest)) .icon(Icon::new(CustomIconName::GitPullRequest))
@@ -271,97 +271,45 @@ impl PullRequestsView {
) )
.child(div().flex_1()) .child(div().flex_1())
.child( .child(
SegmentButton::new("new-pr", "New pull request") h_flex().items_center().child(
.icon(Icon::new(CustomIconName::CirclePlus)) DropdownButton::new("new-pr-actions")
.primary() .action(
BaseButton::new("new-pr")
.child(
h_flex()
.h_8()
.px_2()
.gap_1()
.rounded(cx.theme().radius)
.bg(cx.theme().primary)
.hover(|this| this.bg(cx.theme().primary_hover))
.text_sm()
.text_color(cx.theme().primary_foreground)
.child(Icon::new(IconName::Plus))
.child("New"),
)
.on_click(cx.listener(|this, _event, window, cx| { .on_click(cx.listener(|this, _event, window, cx| {
open_new_pull_request_dialog(this.store.clone(), window, cx); open_new_pull_panel(
this.dock_area.clone(),
this.store.clone(),
window,
cx,
);
})), })),
) )
.into_any_element() .dropdown_menu(|menu, _, _| {
} menu.menu_element(Box::new(RepoAction::SendPatch), |_, _| {
} h_flex()
.gap_2()
/// Open the "new pull request" dialog: a title, an optional description and .text_sm()
/// a patch input that submit through [`RepoStore::open_pull_request`] when .child(Icon::new(IconName::File))
/// confirmed. .child("Send Patch")
pub(super) 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 description =
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
let patch = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output..."));
window.open_dialog(cx, move |dialog, _window, _cx| {
let subject = subject.clone();
let description = description.clone();
let patch = patch.clone();
let store = store.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("New pull request"))
.child(
DialogDescription::new()
.child("Propose a change with the output of `git format-patch`."),
),
)
.child(
v_form()
.child(
field()
.label("Title")
.required(true)
.child(Input::new(&subject)),
)
.child(
field()
.label("Description")
.child(Textarea::new(&description).h(px(96.))),
)
.child(
field()
.label("Patch")
.child(Textarea::new(&patch).h(px(160.))),
),
)
.child(
DialogFooter::new().justify_end().child(
Button::new("submit")
.primary()
.label("Create pull request")
.tooltip("Create pull request")
.on_click({
let subject = subject.clone();
let description = description.clone();
let patch = patch.clone();
let store = store.clone();
move |_event, window, cx| {
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 subject = (!subject.is_empty()).then_some(subject);
store.update(cx, |store, cx| {
store.open_pull_request(subject, description, patch, cx);
});
window.close_dialog(cx);
}
}), }),
), ),
) )
}) .into_any_element()
}); }
} }
impl BasePanel for PullRequestsView { impl BasePanel for PullRequestsView {
@@ -437,10 +385,38 @@ impl Render for PullRequestsView {
let scroll_handle = self.scroll_handle.clone(); let scroll_handle = self.scroll_handle.clone();
let view = cx.entity().clone(); let view = cx.entity().clone();
// Non-fatal warnings and errors of the last action (e.g. creating
// or updating a PR), shown as dismissible banners above the list.
let (last_error, last_warning) = {
let store = self.store.read(cx);
(store.last_error.clone(), store.last_warning.clone())
};
v_flex() v_flex()
.size_full() .size_full()
.image_cache(image_cache("pull-requests", MAX_IMAGES)) .image_cache(image_cache("pull-requests", MAX_IMAGES))
.on_action(cx.listener(|this, action: &RepoAction, window, cx| {
if action == &RepoAction::SendPatch {
open_send_patch_panel(this.dock_area.clone(), this.store.clone(), window, cx);
}
}))
.child(self.render_header(cx)) .child(self.render_header(cx))
.when_some(last_warning, |this, warning| {
this.child(Alert::warning("pr-warning", warning).banner().on_close({
let store = self.store.clone();
move |_event, _window, cx| {
store.update(cx, |store, _| store.last_warning = None);
}
}))
})
.when_some(last_error, |this, error| {
this.child(Alert::error("pr-error", error).banner().on_close({
let store = self.store.clone();
move |_event, _window, cx| {
store.update(cx, |store, _| store.last_error = None);
}
}))
})
.child( .child(
v_flex() v_flex()
.relative() .relative()
@@ -0,0 +1,265 @@
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use gpui::prelude::*;
use gpui::{
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString,
Subscription, WeakEntity, Window, div, px,
};
use gpui_base::{Button as BaseButton, StyledExt};
use gpui_component::input::{Input, InputEvent, InputState, Textarea, TextareaState};
use gpui_component::scroll::ScrollableElement;
use gpui_component::spinner::Spinner;
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex};
use signed_state::RepoStore;
pub struct SendPatchView {
focus_handle: FocusHandle,
/// Dock area the panel lives in.
dock_area: WeakEntity<DockArea>,
/// Store of the target repository.
store: Entity<RepoStore>,
/// Display name of the repository, for the panel title.
repo_name: SharedString,
/// Title input (required).
subject: Entity<InputState>,
/// Description input (optional).
description: Entity<TextareaState>,
/// The pasted `git format-patch` output (required).
patch: Entity<TextareaState>,
/// A submit is in flight.
submitting: bool,
/// Error of the last submit attempt (keeps the panel open).
error: Option<SharedString>,
_subscriptions: Vec<Subscription>,
}
impl SendPatchView {
pub fn new(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let repo_name = store.read(cx).name();
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Title"));
let description = cx
.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change (optional)"));
let patch = cx.new(|cx| {
TextareaState::new(window, cx).placeholder("diff --git a/file.txt b/file.txt\nindex 1234567..abcdefg 100644\n--- a/file.txt\n+++ b/file.txt")
});
// Re-evaluate the Send button's enabled state as the inputs change.
let subscriptions = vec![
cx.subscribe(&subject, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
cx.subscribe(&patch, |_this, _state, _event: &InputEvent, cx| {
cx.notify();
}),
];
Self {
focus_handle: cx.focus_handle(),
dock_area,
store,
repo_name,
subject,
description,
patch,
submitting: false,
error: None,
_subscriptions: subscriptions,
}
}
/// Publish the pull request from the pasted patch. The store validates
/// synchronously (patch shape, per-part size, sign-in); on failure the
/// panel stays open with the error inline, on success it closes — async
/// publish failures surface in the pull request list's banner.
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.submitting {
return;
}
let subject = self.subject.read(cx).value().to_string();
let description = self.description.read(cx).value().to_string();
let patch = self.patch.read(cx).value().to_string();
if patch.is_empty() {
return;
}
let store = self.store.clone();
let dock_area = self.dock_area.clone();
let entity = cx.entity().clone();
self.submitting = true;
self.error = None;
cx.notify();
// Errors the store detects before publishing are returned
// synchronously through `last_error`.
let sync_error = store.update(cx, |store, cx| {
store.open_pull_request(
(!subject.is_empty()).then_some(subject),
description,
None,
patch,
false,
None,
None,
cx,
);
store.last_error.clone()
});
if let Some(error) = sync_error {
self.submitting = false;
self.error = Some(error.into());
cx.notify();
return;
}
// Close the panel once the publish is underway.
cx.defer_in(window, {
let dock_area = dock_area.clone();
let entity = entity.clone();
move |_, window, cx| {
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock, cx| {
dock.remove_panel(entity, window, cx);
});
}
}
});
cx.notify();
}
fn render_footer(&self, cx: &mut Context<Self>) -> AnyElement {
let can_submit = !self.submitting
&& !self.subject.read(cx).value().is_empty()
&& !self.patch.read(cx).value().is_empty();
h_flex()
.px_4()
.h_16()
.w_full()
.gap_2()
.items_center()
.border_t_1()
.border_color(cx.theme().border)
.child(div().flex_1())
.child(
BaseButton::new("send-patch")
.h_flex()
.h_8()
.px_2()
.gap_1()
.text_sm()
.items_center()
.justify_center()
.bg(cx.theme().primary)
.text_color(cx.theme().primary_foreground)
.hover(|this| this.bg(cx.theme().primary_hover))
.active(|this| this.bg(cx.theme().primary_active))
.map(|this| {
if self.submitting {
this.child(Spinner::new().small())
} else {
this.child(Icon::new(IconName::ArrowUp)).child("Send patch")
}
})
.disabled(!can_submit)
.on_click(cx.listener(|this, _event, window, cx| {
this.submit(window, cx);
})),
)
.into_any_element()
}
fn render_inputs(&self, _cx: &mut Context<Self>) -> AnyElement {
v_flex()
.px_4()
.py_2()
.w_full()
.gap_2()
.child(Input::new(&self.subject))
.child(Textarea::new(&self.description).h(px(64.)))
.into_any_element()
}
fn render_patch(&self, cx: &mut Context<Self>) -> AnyElement {
const MSG: &str = "You can paste a git diff or a git format-patch patch series here.";
v_flex()
.px_4()
.py_2()
.w_full()
.gap_2()
.child(
div()
.text_xs()
.text_color(cx.theme().muted_foreground)
.child(MSG),
)
.child(Textarea::new(&self.patch).h_56())
.into_any_element()
}
}
pub(super) fn open_send_patch_panel(
dock_area: WeakEntity<DockArea>,
store: Entity<RepoStore>,
window: &mut Window,
cx: &mut App,
) {
let panel = cx.new(|cx| SendPatchView::new(dock_area.clone(), store, window, cx));
let _ = dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
}
impl BasePanel for SendPatchView {
fn panel_name(&self) -> &'static str {
"send-patch"
}
}
impl Panel for SendPatchView {
fn title(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div().child(SharedString::from(format!("{}/send-patch", self.repo_name)))
}
}
impl EventEmitter<PanelEvent> for SendPatchView {}
impl Focusable for SendPatchView {
fn focus_handle(&self, _cx: &App) -> FocusHandle {
self.focus_handle.clone()
}
}
impl Render for SendPatchView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
v_flex()
.id("send-patch")
.size_full()
.child(
v_flex()
.overflow_y_scrollbar()
.flex_1()
.w_full()
.child(self.render_inputs(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
h_flex()
.px_4()
.py_1()
.w_full()
.text_xs()
.text_color(cx.theme().danger)
.child(error),
)
})
.child(self.render_patch(cx)),
)
.child(self.render_footer(cx))
}
}
+41 -17
View File
@@ -1,7 +1,7 @@
use std::rc::Rc; use std::rc::Rc;
use assets::CustomIconName; use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle}; use dock::{BasePanel, DockArea, 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,
@@ -19,7 +19,7 @@ use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{SegmentButton, UserAvatar}; use signed_ui::{SegmentButton, UserAvatar};
use utils::relative_time; use utils::relative_time;
use super::RepoDetailView; use super::open_repo_panel;
const COLUMNS: usize = 2; const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
@@ -173,21 +173,7 @@ impl RepoListView {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let dock_area = self.dock_area.clone(); open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
let detail =
cx.new(|cx| RepoDetailView::new(dock_area.clone(), announcement.clone(), window, cx));
if let Some(dock_area) = dock_area.upgrade() {
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
}
} }
fn render_card( fn render_card(
@@ -215,6 +201,26 @@ impl RepoListView {
.map(|label| SharedString::from(format!("Updated {label}"))) .map(|label| SharedString::from(format!("Updated {label}")))
.unwrap_or_default(); .unwrap_or_default();
// Fork badge: the upstream's display name when its announcement is
// known locally, otherwise its repository id from the `u` tag.
let fork_label: Option<SharedString> =
announcement.upstream.as_ref().and_then(|upstream| {
let addr = upstream.addr.as_ref()?;
let name = self
.store
.read(cx)
.announcements
.iter()
.find(|a| a.addr() == *addr)
.map(|a| {
a.name
.clone()
.unwrap_or_else(|| SharedString::from(a.id.clone()))
})
.unwrap_or_else(|| SharedString::from(addr.identifier.clone()));
Some(SharedString::from(format!("forked from {name}")))
});
v_flex() v_flex()
.id(ix) .id(ix)
.flex_1() .flex_1()
@@ -228,12 +234,30 @@ impl RepoListView {
.child( .child(
h_flex() h_flex()
.h_10() .h_10()
.gap_1p5()
.items_center()
.child(
div()
.min_w_0()
.text_sm() .text_sm()
.font_semibold() .font_semibold()
.whitespace_nowrap() .whitespace_nowrap()
.text_ellipsis() .text_ellipsis()
.child(name), .child(name),
) )
.when_some(fork_label, |this, label| {
this.child(
h_flex()
.gap_1()
.items_center()
.text_xs()
.text_color(cx.theme().muted_foreground)
.whitespace_nowrap()
.child(Icon::new(CustomIconName::GitBranch).small())
.child(label),
)
}),
)
.child( .child(
div() div()
.h_16() .h_16()
@@ -1,4 +1,4 @@
use dock::{DockArea, DockPlacement, panel_handle}; use dock::DockArea;
use gpui::prelude::*; use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px}; use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState; use gpui_base::input::TextareaState;
@@ -11,7 +11,7 @@ use settings::SettingsStore;
use signed_core::Announcement; use signed_core::Announcement;
use signed_state::Backend; use signed_state::Backend;
use super::super::RepoDetailView; use super::super::open_repo_panel;
use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers};
/// Shared state for the Create Repository dialog, so async results can be rendered. /// Shared state for the Create Repository dialog, so async results can be rendered.
@@ -262,13 +262,5 @@ fn open_repo(
window: &mut Window, window: &mut Window,
cx: &mut App, cx: &mut App,
) { ) {
let Some(dock_area) = dock_area.upgrade() else { open_repo_panel(&dock_area, &announcement, window, cx);
return;
};
let panel = cx.new(|cx| RepoDetailView::new(dock_area.downgrade(), announcement, window, cx));
dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx);
});
} }
+4 -15
View File
@@ -19,7 +19,7 @@ use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore
use signed_ui::image_cache::{MAX_IMAGES, image_cache}; use signed_ui::image_cache::{MAX_IMAGES, image_cache};
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView}; use super::{RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog; mod create_repo_dialog;
pub(crate) mod grasp_servers; pub(crate) mod grasp_servers;
@@ -100,7 +100,8 @@ impl SidebarPanel {
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) { fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
self.my_repos_subscription = None; self.my_repos_subscription = None;
let author = Backend::global(cx).read(cx).current_user(); let backend = Backend::global(cx);
let author = backend.read(cx).current_user();
self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
if let Some(store) = self.my_repos.as_ref() { if let Some(store) = self.my_repos.as_ref() {
@@ -158,19 +159,7 @@ impl SidebarPanel {
window: &mut Window, window: &mut Window,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let detail = cx.new(|cx| { open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx)
});
let _ = self.dock_area.update(cx, |dock_area, cx| {
dock_area.add_panel_view(
panel_handle(detail),
DockPlacement::Center,
None,
window,
cx,
);
});
} }
/// Open a local repository's detail view in the dock's center; the /// Open a local repository's detail view in the dock's center; the
+139
View File
@@ -0,0 +1,139 @@
# Pull request flow
How a pull request moves through Signed from creation to merge. A PR is a
kind-1618 root event whose content is the markdown description; its changes
live in a NIP-10-chained series of kind-1617 patch events (one per commit),
whose root the PR references via an `e` tag. Revisions publish new patch
events plus kind-1619 updates; statuses (kind 1630-1633) resolve the PR's
state.
## Whole lifecycle
```mermaid
graph TD
A["New pull request dialog"] --> B{"Patch source"}
B -->|"Paste"| C["Paste git format-patch output"]
B -->|"Local checkout"| D["Browse for checkout"]
D --> E["Defaults: source = current branch, target = announced HEAD"]
E --> F["Generate: merge-base plus format-patch base..tip"]
F --> G["Apply check vs mirror clone - non-blocking warning"]
C --> H["Submit"]
F --> H
G --> H
H --> I["split_patch_series: one part per commit"]
I --> J{"Any part over 60 KB?"}
J -->|"Yes"| K["Refuse with message"]
J -->|"No"| L["tip = last part's From commit"]
L --> M["Publish kind-1617 patch series: first has t root, later parts e-reply chained"]
M --> N["Build kind-1618 PR event: c = tip, e = root patch, branch-name, merge-base"]
N --> O["Sign early - learn the event id"]
O --> P["Push tip to refs/nostr/event-id on every announced grasp server"]
P -->|"All rejected"| Q["last_warning banner in PR list"]
P --> R["Publish kind-1618 PR event"]
Q --> R
R --> S{"Draft?"}
S -->|"Yes"| T["Publish kind-1633 draft status"]
S -->|"No"| U["PR open"]
T --> U
U --> V{"Author updates?"}
V -->|"Yes"| W["Publish revision patch series: first has t root-revision and e-replies to the original root"]
W --> X["Publish kind-1619 update: E/P NIP-22 tags, c = new tip"]
X --> U
V -->|"No"| Y{"Repository author merges?"}
Y -->|"Yes"| Z["Apply the series with git am on the mirror clone"]
Z --> AA["applied = rev-list previous-head..HEAD"]
AA --> AB["Publish kind-1631 applied status: applied-as-commits plus r per commit, q plus e-reply per patch event"]
AB --> AC["PR merged"]
Y -->|"Close instead"| AD["Publish kind-1632 closed status"]
AD --> AE["PR closed"]
```
Key points of the write side:
- **Merge base**: only computable in the local-checkout path
(`signed_git::merge_base`); the paste path publishes none. The dialog
reuses it at submit only while the patch textarea is unchanged.
- **Patch series**: each commit becomes its own kind-1617 event so no event
grows past NIP-34's 60 KB guidance; the PR's `c` tag carries the *last*
commit of the series (the tip), and each part carries its own
`commit`/`r` tags.
- **Push before publish**: the tip is pushed to every announced grasp
server under `refs/nostr/<event-id>` (nak's convention) so the announced
`clone` URLs really can serve the commit. Failure is non-fatal — the
patch events remain the source of truth — and surfaces as a
`last_warning` banner.
## Creating a pull request - event ordering
```mermaid
sequenceDiagram
participant User
participant App
participant Checkout as Local checkout
participant Grasp as Grasp servers
participant Relays as Nostr relays
User->>App: pick checkout and branches, Generate
App->>Checkout: merge-base(source, target)
Checkout-->>App: base commit
App->>Checkout: format-patch base..tip
Checkout-->>App: patch series
App->>App: split series, check per-part size
loop each patch of the series
App->>Relays: publish kind-1617 (first: t root, later: e reply)
end
App->>App: build and sign kind-1618 PR event
App->>Grasp: push tip to refs/nostr/event-id
Grasp-->>App: accepted or rejected (best-effort)
App->>Relays: publish kind-1618 PR event
opt draft
App->>Relays: publish kind-1633 draft status
end
```
## Updating and merging
```mermaid
sequenceDiagram
participant Author
participant Relays as Nostr relays
participant Maintainer
participant Clone as Mirror clone
Note over Author,Relays: Update - PR author only
Author->>Relays: publish revision patch series (t root-revision, e reply to original root)
Author->>Relays: publish kind-1619 update (E/P tags, c = new tip)
Note over Maintainer,Clone: Merge - repository author only (store-only today)
Maintainer->>Clone: git am the patch series
Clone-->>Maintainer: applied commits (rev-list previous-head..HEAD)
Maintainer->>Relays: publish kind-1631 applied status
Note over Relays: applied-as-commits and r per commit, q and e-reply per applied patch event
```
## Reading side
```mermaid
graph TD
A["PR root kind-1618"] --> B{"Newest status event by author or maintainer?"}
B -->|"1633"| C["Draft"]
B -->|"1631"| D["Applied / merged"]
B -->|"1632"| E["Closed"]
B -->|"1630 or none"| F["Open"]
A --> G{"Newest kind-1619 update by PR author?"}
G -->|"Yes"| H["tip = update's c tag"]
G -->|"No"| I["tip = root's c tag"]
A --> J{"Patch set present?"}
J -->|"Yes"| K["Root patch via e tag, follow reply chain (newest wins per revision)"]
J -->|"No"| L["Diff merge-base..tip from the git clone"]
```
Reader rules that keep the flow consistent:
- **Status**: only status events by the root author or a repository
maintainer count; the newest wins, `Open` is the default.
- **Tip**: only kind-1619 updates by the PR author move the tip — a
stranger's update is ignored.
- **Diff**: the patch set is preferred (NIP-34 `e`-linked chain); PRs from
other clients without patch events fall back to diffing
`merge-base..tip` in the local clone.
+32 -4
View File
@@ -1,12 +1,40 @@
# TODO # TODO
## Local repository scan ## Fork support
- [ ] Make the scanned directories configurable (currently fixed to Desktop and Documents). - [x] Fork badge on repo list cards (`repo_list.rs::render_card`).
- [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog.
- [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
## Create repository dialog ## Pull request improvement
- [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop). ### New pull request panel (replaces the dialog)
- [x] "New pull request" (PR list header + repo header `New PR`) opens a center panel instead of the paste dialog:
- [x] Base/compare branch selectors fed from a user-chosen local checkout (GitHub-style; defaults: announced HEAD for base, checkout's current branch for compare).
- [x] Files/Commits tabs like the repo panel: diff of `merge-base..compare` (shared `DiffPane` widget, also extracted for the commit diff panel) + virtual commit list with count badge; clicking a commit opens its diff panel.
- [x] Only two inputs: title (required, gates the Create button) and description (optional).
- [x] Patch is generated from the checkout at submit time (`format_patch_between` on the stored merge base); panel closes after publishing, errors surface in the PR list banner.
- [x] Removed with the dialog: paste textarea, draft checkbox, branch-name input and the mirror-clone apply-check hint (store behavior unchanged: `open_pull_request` still publishes the series + `branch-name`/`merge-base`/`r` tags and pushes the tip).
### Send patch panel (classic paste flow)
- [x] "Send patch" entry in the repo header PRs dropdown (`RepoAction::SendPatch`) and a "New pull request ▾ Send patch" dropdown replacing the PR list's plain new-PR button.
- [x] `send_patch.rs` center panel: title + optional description + `git format-patch` paste area; submits through `RepoStore::open_pull_request` (no checkout, no `branch-name`/`merge-base`). Synchronous store errors (malformed/oversized patch, sign-in) keep the panel open with an inline error; the panel closes once the publish is underway.
- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog (dialog since replaced by the panel above).
- [x] P1: `RepoStore::update_pull_request` (kind 1619 + root-revision patch) with an author-only "Update" button on the PR detail header.
- [x] P1: `latest_update` filters by PR author.
- [x] P2: local checkout picker in the new-PR dialog (folder picker + source/target branches + Generate): `signed_git::{merge_base, format_patch_between, patch_applies}`; `merge-base` tag now published; best-effort apply check shown under the patch field (superseded by the panel's live compare view).
- [x] P3: push tip to grasp servers under `refs/nostr/<event-id>` before publishing (from the local checkout); multi-commit series published as NIP-10-chained 1617 events with a 60 KB per-patch cap; PR list shows dismissible error/warning banners (incl. push failures).
- [x] P4: merge status tags — `merge_pull_request` publishes 1631 with `applied-as-commits` + `r` per applied commit and `q`/`e`-reply tags per applied patch event.
### Pull request follow-ups
- [ ] GRASP-06 `/prs/<npub>/<id>.git` contributor endpoints + kind-10317 user grasp-list fallback.
- [ ] Merge button in the PR detail view (`merge_pull_request` is store-only today), then fetch-and-merge (`merge-commit`) when the push backend is guaranteed.
- [ ] Local-checkout generation for the update-PR dialog (currently paste-only).
- [ ] Fork-aware compare in the New PR panel: today both branch selectors come from the user-picked local checkout, so a cross-fork PR (GitHub's "compare across forks") requires the fork's branch to exist locally. Add picking the fork repository from announced repos (its 30617 may point at this repo via the `u` tag, or share the EUC) + a branch, fetch it into the `GitCache` mirror, and run the `merge-base`/diff/`format-patch` flow against the base repo's mirror — like `choose_checkout` today but repo-driven.
## Performance: render path ## Performance: render path