feat: pull request and patch #13
@@ -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};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
+157
-11
@@ -3,12 +3,14 @@ use std::collections::{HashMap, HashSet};
|
|||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{AppContext, Context, Subscription, Task};
|
use gpui::{AppContext, Context, Subscription, Task};
|
||||||
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};
|
||||||
@@ -235,7 +237,8 @@ impl RepoStore {
|
|||||||
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 {
|
||||||
@@ -623,17 +626,21 @@ impl RepoStore {
|
|||||||
/// carry a real commit id for other NIP-34 clients to verify and apply
|
/// carry a real commit id for other NIP-34 clients to verify and apply
|
||||||
/// the proposal. The `clone` tag carries the announced mirror URLs; the
|
/// the proposal. The `clone` tag carries the announced mirror URLs; the
|
||||||
/// linked patch is the source of truth until the commit is pushed there.
|
/// linked patch is the source of truth until the commit is pushed there.
|
||||||
|
/// `branch_name` lands in the PR's `branch-name` tag (NIP-34); `draft`
|
||||||
|
/// publishes a kind-1633 status right after the PR event.
|
||||||
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,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
self.last_error = None;
|
self.last_error = None;
|
||||||
|
|
||||||
let Some(current_commit) =
|
let Some(current_commit) =
|
||||||
patch_current_commit(&patch).and_then(|hex| hex.parse::<bitcoin_hashes::Sha1>().ok())
|
patch_current_commit(&patch).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(),
|
||||||
@@ -666,8 +673,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags);
|
let patch_builder = EventBuilder::new(Kind::GitPatch, patch).tags(patch_tags);
|
||||||
|
|
||||||
let patch_task =
|
let backend = Backend::global(cx);
|
||||||
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx));
|
let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx));
|
||||||
|
|
||||||
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 {
|
let patch_event = match patch_task.await {
|
||||||
@@ -688,7 +695,7 @@ impl RepoStore {
|
|||||||
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; use the repository's
|
// tip commit can be downloaded; use the repository's
|
||||||
// announced mirrors until a push backend exists.
|
// announced mirrors until a push backend exists.
|
||||||
@@ -703,10 +710,146 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.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 PRs of this repository.
|
||||||
|
let builder = 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
backend.update(cx, |backend, cx| backend.send(builder, cx))
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if let Err(e) = pr_task.await {
|
let pr_event = match pr_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 a revision patch event chained to the
|
||||||
|
/// original root patch (`t root-revision` and a NIP-10 `e` reply, 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;
|
||||||
|
|
||||||
|
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 Some(current_commit) =
|
||||||
|
patch_current_commit(&patch).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 commit_hex = current_commit.to_string();
|
||||||
|
let mut patch_tags = vec![
|
||||||
|
Tag::coordinate(self.addr.clone(), None),
|
||||||
|
Tag::public_key(self.addr.public_key),
|
||||||
|
Tag::parse(["t", "root-revision"]).expect("valid root-revision tag"),
|
||||||
|
];
|
||||||
|
if let Some(root_patch_id) = root_patch_id
|
||||||
|
&& let Ok(tag) = Tag::parse(["e", &root_patch_id.to_hex(), "", "reply"])
|
||||||
|
{
|
||||||
|
patch_tags.push(tag);
|
||||||
|
}
|
||||||
|
// NIP-34: the `r` EUC tag lets clients subscribe to all patches of
|
||||||
|
// this repository; `commit`/`r` tags reference the new tip.
|
||||||
|
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 backend = Backend::global(cx);
|
||||||
|
let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx));
|
||||||
|
|
||||||
|
let root = root.clone();
|
||||||
|
let clone: Vec<Url> = self
|
||||||
|
.announcement
|
||||||
|
.as_ref()
|
||||||
|
.map(|a| a.clone.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
if let Err(e) = patch_task.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,
|
||||||
|
}
|
||||||
|
.into_event_builder();
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
};
|
||||||
|
|
||||||
|
backend.update(cx, |backend, cx| backend.send(builder, cx))
|
||||||
|
})?;
|
||||||
|
|
||||||
|
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 +872,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 +905,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;
|
||||||
@@ -924,7 +1069,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()
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -991,7 +991,8 @@ impl RepoDetailView {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Backend::global(cx).update(cx, |backend, cx| {
|
let backend = Backend::global(cx);
|
||||||
|
backend.update(cx, |backend, cx| {
|
||||||
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
|
backend.subscribe_bootstrap(vec![filters::announcement(&addr)], cx);
|
||||||
});
|
});
|
||||||
self.pending_upstream = Some(addr);
|
self.pending_upstream = Some(addr);
|
||||||
@@ -1001,6 +1002,7 @@ impl RepoDetailView {
|
|||||||
cx.background_executor()
|
cx.background_executor()
|
||||||
.timer(Duration::from_millis(250))
|
.timer(Duration::from_millis(250))
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let opened = this.update_in(cx, |this, window, cx| {
|
let opened = this.update_in(cx, |this, window, cx| {
|
||||||
let Some(addr) = this.pending_upstream.clone() else {
|
let Some(addr) = this.pending_upstream.clone() else {
|
||||||
return true;
|
return true;
|
||||||
@@ -1020,10 +1022,12 @@ impl RepoDetailView {
|
|||||||
None => false,
|
None => false,
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if opened {
|
if opened {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
this.update(cx, |this, _cx| this.pending_upstream = None)?;
|
this.update(cx, |this, _cx| this.pending_upstream = None)?;
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ use gpui::{
|
|||||||
SharedString, Size, WeakEntity, Window, div, px, size,
|
SharedString, Size, WeakEntity, Window, div, px, size,
|
||||||
};
|
};
|
||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
|
use gpui_component::checkbox::Checkbox;
|
||||||
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
|
||||||
use gpui_component::form::{field, v_form};
|
use gpui_component::form::{field, v_form};
|
||||||
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
use gpui_component::input::{Input, InputState, Textarea, TextareaState};
|
||||||
@@ -282,9 +283,15 @@ impl PullRequestsView {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the "new pull request" dialog: a title, an optional description and
|
/// State of the new pull request dialog, so the draft checkbox re-renders.
|
||||||
/// a patch input that submit through [`RepoStore::open_pull_request`] when
|
#[derive(Default)]
|
||||||
/// confirmed.
|
struct NewPullRequestDialogState {
|
||||||
|
draft: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Open the "new pull request" dialog: a title, an optional description,
|
||||||
|
/// an optional branch name and a patch input that submit through
|
||||||
|
/// [`RepoStore::open_pull_request`] when confirmed.
|
||||||
pub(super) fn open_new_pull_request_dialog(
|
pub(super) fn open_new_pull_request_dialog(
|
||||||
store: Entity<RepoStore>,
|
store: Entity<RepoStore>,
|
||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
@@ -293,19 +300,24 @@ pub(super) fn open_new_pull_request_dialog(
|
|||||||
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
|
let subject = cx.new(|cx| InputState::new(window, cx).placeholder("Pull request title"));
|
||||||
let description =
|
let description =
|
||||||
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
|
cx.new(|cx| TextareaState::new(window, cx).placeholder("Describe the change..."));
|
||||||
|
let branch = cx.new(|cx| InputState::new(window, cx).placeholder("Branch name (optional)"));
|
||||||
let patch = cx
|
let patch = cx
|
||||||
.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output..."));
|
.new(|cx| TextareaState::new(window, cx).placeholder("Paste `git format-patch` output..."));
|
||||||
|
let state = cx.new(|_| NewPullRequestDialogState::default());
|
||||||
|
|
||||||
window.open_dialog(cx, move |dialog, _window, _cx| {
|
window.open_dialog(cx, move |dialog, _window, _cx| {
|
||||||
let subject = subject.clone();
|
let subject = subject.clone();
|
||||||
let description = description.clone();
|
let description = description.clone();
|
||||||
|
let branch = branch.clone();
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
dialog
|
dialog
|
||||||
.width(px(520.))
|
.width(px(520.))
|
||||||
.margin_top(px(50.))
|
.margin_top(px(50.))
|
||||||
.content(move |body, _window, _cx| {
|
.content(move |body, _window, cx| {
|
||||||
|
let draft = state.read(cx).draft;
|
||||||
body.child(
|
body.child(
|
||||||
DialogHeader::new()
|
DialogHeader::new()
|
||||||
.child(DialogTitle::new().child("New pull request"))
|
.child(DialogTitle::new().child("New pull request"))
|
||||||
@@ -327,10 +339,29 @@ pub(super) fn open_new_pull_request_dialog(
|
|||||||
.label("Description")
|
.label("Description")
|
||||||
.child(Textarea::new(&description).h(px(96.))),
|
.child(Textarea::new(&description).h(px(96.))),
|
||||||
)
|
)
|
||||||
|
.child(
|
||||||
|
field()
|
||||||
|
.label("Branch")
|
||||||
|
.description("Optional: the branch the change is proposed from")
|
||||||
|
.child(Input::new(&branch)),
|
||||||
|
)
|
||||||
.child(
|
.child(
|
||||||
field()
|
field()
|
||||||
.label("Patch")
|
.label("Patch")
|
||||||
.child(Textarea::new(&patch).h(px(160.))),
|
.child(Textarea::new(&patch).h(px(160.))),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
field().child(
|
||||||
|
Checkbox::new("pr-draft")
|
||||||
|
.label("Create as draft")
|
||||||
|
.checked(draft)
|
||||||
|
.on_click({
|
||||||
|
let state = state.clone();
|
||||||
|
move |checked, _window, cx| {
|
||||||
|
state.update(cx, |state, _| state.draft = *checked);
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
@@ -342,17 +373,29 @@ pub(super) fn open_new_pull_request_dialog(
|
|||||||
.on_click({
|
.on_click({
|
||||||
let subject = subject.clone();
|
let subject = subject.clone();
|
||||||
let description = description.clone();
|
let description = description.clone();
|
||||||
|
let branch = branch.clone();
|
||||||
let patch = patch.clone();
|
let patch = patch.clone();
|
||||||
let store = store.clone();
|
let store = store.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
move |_event, window, cx| {
|
move |_event, window, cx| {
|
||||||
let subject = subject.read(cx).value().to_string();
|
let subject = subject.read(cx).value().to_string();
|
||||||
let description = description.read(cx).value().to_string();
|
let description = description.read(cx).value().to_string();
|
||||||
|
let branch = branch.read(cx).value().to_string();
|
||||||
let patch = patch.read(cx).value().to_string();
|
let patch = patch.read(cx).value().to_string();
|
||||||
let subject = (!subject.is_empty()).then_some(subject);
|
let subject = (!subject.is_empty()).then_some(subject);
|
||||||
|
let branch = (!branch.is_empty()).then_some(branch);
|
||||||
|
let draft = state.read(cx).draft;
|
||||||
|
|
||||||
store.update(cx, |store, cx| {
|
store.update(cx, |store, cx| {
|
||||||
store.open_pull_request(subject, description, patch, cx);
|
store.open_pull_request(
|
||||||
|
subject,
|
||||||
|
description,
|
||||||
|
branch,
|
||||||
|
patch,
|
||||||
|
draft,
|
||||||
|
cx,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
window.close_dialog(cx);
|
window.close_dialog(cx);
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
+7
-5
@@ -124,7 +124,7 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re
|
|||||||
| Draft on create | no | optional 1633 status (P1) |
|
| Draft on create | no | optional 1633 status (P1) |
|
||||||
| Merge provenance | plain 1631 | `merge-commit`/`applied-as-commits` (P4) |
|
| Merge provenance | plain 1631 | `merge-commit`/`applied-as-commits` (P4) |
|
||||||
|
|
||||||
### Phase 1 — Correctness & interop (small, surgical)
|
### Phase 1 — Correctness & interop (small, surgical) ✅ implemented
|
||||||
|
|
||||||
1. **Compute and publish `merge-base`, `branch-name`, `r` EUC** in `open_pull_request`:
|
1. **Compute and publish `merge-base`, `branch-name`, `r` EUC** in `open_pull_request`:
|
||||||
- Target tip = `RepoStore.head` ref from the state announcement (`refs`/`head`, `crates/signed_state/src/repo.rs:28-30`); add `signed_git::merge_base(repo, a, b)` (shell out like `apply_patch`).
|
- Target tip = `RepoStore.head` ref from the state announcement (`refs`/`head`, `crates/signed_state/src/repo.rs:28-30`); add `signed_git::merge_base(repo, a, b)` (shell out like `apply_patch`).
|
||||||
@@ -134,6 +134,8 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re
|
|||||||
3. **Fix `latest_update`** (`crates/workspace/src/views/repo_detail/pull_request_detail.rs:1125`): filter by the root PR's author (nak and ngit both restrict tip updates to the PR author).
|
3. **Fix `latest_update`** (`crates/workspace/src/views/repo_detail/pull_request_detail.rs:1125`): filter by the root PR's author (nak and ngit both restrict tip updates to the PR author).
|
||||||
4. **Draft toggle** in the new-PR dialog: publish a 1633 status right after the PR event (reuse `set_status`).
|
4. **Draft toggle** in the new-PR dialog: publish a 1633 status right after the PR event (reuse `set_status`).
|
||||||
|
|
||||||
|
**Status:** items 1 (partial — `branch-name` + `r` EUC done; `merge-base` remains `None` because the paste-based flow has no access to the author's git objects to compute a merge base; it becomes computable in Phase 2 when the patch is generated from a local checkout), 2, 3, 4 are implemented.
|
||||||
|
|
||||||
### Phase 2 — UX: replace the paste
|
### Phase 2 — UX: replace the paste
|
||||||
|
|
||||||
5. **Local-repo picker** replaces the paste textarea (keep it as an advanced fallback): user picks a git checkout (or the app's `GitCache` mirror), source branch and target branch. The app then:
|
5. **Local-repo picker** replaces the paste textarea (keep it as an advanced fallback): user picks a git checkout (or the app's `GitCache` mirror), source branch and target branch. The app then:
|
||||||
@@ -154,10 +156,10 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re
|
|||||||
|
|
||||||
### Checklist
|
### Checklist
|
||||||
|
|
||||||
- [ ] P1: merge-base + branch-name + `r` EUC on creation.
|
- [x] P1: merge-base + branch-name + `r` EUC on creation (merge-base deferred to P2 — not computable from a pasted patch).
|
||||||
- [ ] P1: `update_pull_request` (1619) + UI button; author check.
|
- [x] P1: `update_pull_request` (1619) + UI button; author check.
|
||||||
- [ ] P1: `latest_update` author filter.
|
- [x] P1: `latest_update` author filter.
|
||||||
- [ ] P1: draft toggle on create.
|
- [x] P1: draft toggle on create.
|
||||||
- [ ] P2: local checkout picker + generated patch + pre-publish apply check.
|
- [ ] P2: local checkout picker + generated patch + pre-publish apply check.
|
||||||
- [ ] P3: push tip to grasp, truthful `clone` tags, size-aware series.
|
- [ ] P3: push tip to grasp, truthful `clone` tags, size-aware series.
|
||||||
- [ ] P4: merge status tags.
|
- [ ] P4: merge status tags.
|
||||||
|
|||||||
+13
-4
@@ -2,10 +2,19 @@
|
|||||||
|
|
||||||
## Fork support
|
## Fork support
|
||||||
|
|
||||||
- [ ] Add UI for fork (see `PLAN.md` section 1):
|
- [x] Add UI for fork (see `PLAN.md` section 1):
|
||||||
- [ ] Fork badge on repo list cards (`repo_list.rs::render_card`).
|
- [x] Fork badge on repo list cards (`repo_list.rs::render_card`).
|
||||||
- [ ] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog.
|
- [x] "Forked from …" text button in the repo detail header (`repo_detail/mod.rs::render_header`) and About dialog.
|
||||||
- [ ] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
|
- [x] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
|
||||||
|
|
||||||
|
## Pull request improvement (see `PLAN.md` section 2)
|
||||||
|
|
||||||
|
- [x] P1: `branch-name` tag + `r` EUC tag on PR creation; draft checkbox in the new-PR dialog.
|
||||||
|
- [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.
|
||||||
|
- [ ] P2: local checkout picker + generated patch + pre-publish apply check (also enables `merge-base`).
|
||||||
|
- [ ] P3: push tip to grasp, truthful `clone` tags, size-aware patch series.
|
||||||
|
- [ ] P4: `merge-commit`/`applied-as-commits` tags on merge status.
|
||||||
|
|
||||||
## Performance: render path
|
## Performance: render path
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user