This commit is contained in:
2026-09-02 09:14:04 +07:00
parent 6f1256757f
commit c054f61593
10 changed files with 373 additions and 41 deletions
@@ -991,7 +991,8 @@ impl RepoDetailView {
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);
});
self.pending_upstream = Some(addr);
@@ -1001,6 +1002,7 @@ impl RepoDetailView {
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;
@@ -1020,10 +1022,12 @@ impl RepoDetailView {
None => false,
}
})?;
if opened {
return Ok(());
}
}
this.update(cx, |this, _cx| this.pending_upstream = None)?;
Ok(())
});
@@ -12,6 +12,8 @@ use gpui::{
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Textarea, TextareaState};
use gpui_component::list::ListItem;
use gpui_component::scroll::{ScrollableElement, Scrollbar};
@@ -20,12 +22,13 @@ use gpui_component::tab::{Tab, TabBar};
use gpui_component::tag::Tag;
use gpui_component::tree::{TreeEntry, TreeState, tree};
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 signed_core::{activity_subject, pull_request_patch};
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::{UserAvatar, placeholder, status_badge, tree_row};
use utils::{relative_time, relative_time_secs};
@@ -183,7 +186,7 @@ impl PullRequestDetailView {
cx.notify();
return;
};
let update = latest_update(store.pull_requests.iter(), &root.id);
let update = latest_update(store.pull_requests.iter(), root);
let tip = update
.and_then(current_commit_of)
.or_else(|| current_commit_of(root));
@@ -1002,7 +1005,7 @@ impl PullRequestDetailView {
/// Always-visible header: status badge and title, like the issue panel.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let current_commit = self.current_commit.clone();
let (title, status, branch) = {
let (title, status, branch, author) = {
let store = self.store.read(cx);
let Some(root) = store
.pull_requests
@@ -1015,9 +1018,14 @@ impl PullRequestDetailView {
activity_subject(root),
store.status_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()
.px_4()
.mb_4()
@@ -1048,6 +1056,38 @@ impl PullRequestDetailView {
.label(branch),
)
})
.when(can_update, |this| {
this.child(
Button::new("update-pr")
.ghost()
.small()
.icon(CustomIconName::GitPullRequest)
.label("Update")
.tooltip("Publish a new revision of this pull request")
.on_click(cx.listener({
let store = self.store.clone();
let pr_id = self.pr_id;
move |_this, _event, window, cx| {
let root = store
.read(cx)
.pull_requests
.iter()
.find(|pr| {
pr.id == pr_id && pr.kind == Kind::GitPullRequest
})
.cloned();
if let Some(root) = root {
open_update_pull_request_dialog(
store.clone(),
root,
window,
cx,
);
}
}
})),
)
})
.when_some(current_commit, |this, id| {
this.child(
h_flex()
@@ -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.
fn sidebar_title(text: &str, cx: &App) -> AnyElement {
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
/// `E` tag pointing at the root PR event.
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &EventId) -> Option<&'a Event> {
let root_hex = root.to_hex();
/// `E` tag pointing at the root PR event. Only updates by the PR author
/// count: the tip of a PR is only mutable by its author (NIP-34).
fn latest_update<'a>(events: impl Iterator<Item = &'a Event>, root: &Event) -> Option<&'a Event> {
let root_hex = root.id.to_hex();
events
.filter(|e| e.kind == Kind::GitPullRequestUpdate)
.filter(|e| e.pubkey == root.pubkey)
.filter(|e| {
e.tags
.iter()
@@ -1262,16 +1366,35 @@ mod tests {
);
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.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]
fn latest_update_ignores_roots_without_revisions() {
let root = pr_root();
assert!(latest_update([&root].into_iter(), &root.id).is_none());
assert!(latest_update([&root].into_iter(), &root).is_none());
}
#[test]
@@ -8,6 +8,7 @@ use gpui::{
SharedString, Size, WeakEntity, Window, div, px, size,
};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::checkbox::Checkbox;
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
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
/// a patch input that submit through [`RepoStore::open_pull_request`] when
/// confirmed.
/// State of the new pull request dialog, so the draft checkbox re-renders.
#[derive(Default)]
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(
store: Entity<RepoStore>,
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 description =
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
.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| {
let subject = subject.clone();
let description = description.clone();
let branch = branch.clone();
let patch = patch.clone();
let store = store.clone();
let state = state.clone();
dialog
.width(px(520.))
.margin_top(px(50.))
.content(move |body, _window, _cx| {
.content(move |body, _window, cx| {
let draft = state.read(cx).draft;
body.child(
DialogHeader::new()
.child(DialogTitle::new().child("New pull request"))
@@ -327,10 +339,29 @@ pub(super) fn open_new_pull_request_dialog(
.label("Description")
.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(
field()
.label("Patch")
.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(
@@ -342,17 +373,29 @@ pub(super) fn open_new_pull_request_dialog(
.on_click({
let subject = subject.clone();
let description = description.clone();
let branch = branch.clone();
let patch = patch.clone();
let store = store.clone();
let state = state.clone();
move |_event, window, cx| {
let subject = subject.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 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.open_pull_request(subject, description, patch, cx);
store.open_pull_request(
subject,
description,
branch,
patch,
draft,
cx,
);
});
window.close_dialog(cx);
+2 -1
View File
@@ -100,7 +100,8 @@ impl SidebarPanel {
fn refresh_my_repos(&mut self, cx: &mut Context<Self>) {
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)));
if let Some(store) = self.my_repos.as_ref() {