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
+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 comments::{CommentThread, comment_threads};
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 status::{RepoStatus, references_root, resolve_status};
+6 -3
View File
@@ -163,7 +163,8 @@ impl ProfileStore {
/// Load recently seen profiles from the local database.
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 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.
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 filter = Filter::new().kind(Kind::Metadata).author(public_key);
@@ -238,7 +240,8 @@ impl ProfileStore {
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 filter = Filter::new().kind(Kind::Metadata).authors(authors);
+157 -11
View File
@@ -3,12 +3,14 @@ use std::collections::{HashMap, HashSet};
use std::time::Duration;
use anyhow::Error;
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{AppContext, Context, Subscription, Task};
use nostr::event::IntoEventBuilder;
use nostr_sdk::prelude::*;
use signed_core::{
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};
@@ -235,7 +237,8 @@ impl RepoStore {
fn run_refresh(&mut self, cx: &mut Context<Self>) {
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 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
/// the proposal. The `clone` tag carries the announced mirror URLs; the
/// 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(
&mut self,
subject: Option<String>,
description: String,
branch_name: Option<String>,
patch: String,
draft: bool,
cx: &mut Context<Self>,
) {
self.last_error = None;
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 {
self.last_error = Some(
"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_task =
Backend::global(cx).update(cx, |backend, cx| backend.send(patch_builder, cx));
let backend = Backend::global(cx);
let patch_task = backend.update(cx, |backend, cx| backend.send(patch_builder, cx));
self.tasks.push(cx.spawn(async move |this, cx| {
let patch_event = match patch_task.await {
@@ -688,7 +695,7 @@ impl RepoStore {
content: description,
subject,
labels: Vec::new(),
branch_name: None,
branch_name,
// NIP-34: PRs carry at least one clone URL where the
// tip commit can be downloaded; use the repository's
// announced mirrors until a push backend exists.
@@ -703,10 +710,146 @@ impl RepoStore {
}
.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| {
this.last_error = Some(e.to_string());
cx.notify();
@@ -729,7 +872,8 @@ impl RepoStore {
.map(Announcement::effective_maintainers)
.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());
cx.notify();
return;
@@ -761,7 +905,8 @@ impl RepoStore {
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
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());
cx.notify();
return;
@@ -924,7 +1069,8 @@ fn resolve_statuses(
.chain(pull_requests)
.map(|root| {
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)
})
.collect()
+2 -1
View File
@@ -202,7 +202,8 @@ impl RepoListStore {
fn run_refresh(&mut self, cx: &mut Context<Self>) {
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 work = cx.background_spawn(async move {
@@ -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() {
+7 -5
View File
@@ -124,7 +124,7 @@ Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/re
| Draft on create | no | optional 1633 status (P1) |
| 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`:
- 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).
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
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
- [ ] P1: merge-base + branch-name + `r` EUC on creation.
- [ ] P1: `update_pull_request` (1619) + UI button; author check.
- [ ] P1: `latest_update` author filter.
- [ ] P1: draft toggle on create.
- [x] P1: merge-base + branch-name + `r` EUC on creation (merge-base deferred to P2 — not computable from a pasted patch).
- [x] P1: `update_pull_request` (1619) + UI button; author check.
- [x] P1: `latest_update` author filter.
- [x] P1: draft toggle on create.
- [ ] P2: local checkout picker + generated patch + pre-publish apply check.
- [ ] P3: push tip to grasp, truthful `clone` tags, size-aware series.
- [ ] P4: merge status tags.
+13 -4
View File
@@ -2,10 +2,19 @@
## Fork support
- [ ] Add UI for fork (see `PLAN.md` section 1):
- [ ] 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.
- [ ] Clicking the upstream opens it as a center panel (shared `open_repo_panel` helper).
- [x] Add UI for fork (see `PLAN.md` section 1):
- [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).
## 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