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
9 changed files with 471 additions and 67 deletions
Showing only changes of commit 6f1256757f - Show all commits
+96 -7
View File
@@ -3,6 +3,8 @@ use std::collections::HashSet;
use gpui::SharedString;
use nostr::prelude::*;
use crate::RepoAddr;
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Announcement {
@@ -28,11 +30,53 @@ pub struct Announcement {
pub maintainers: Vec<PublicKey>,
/// Value of a `u` tag, if any: this repository is a subordinate fork of
/// the referenced upstream (NIP-34).
pub upstream: Option<String>,
pub upstream: Option<Upstream>,
/// Hashtags labelling the repository (`t` tags).
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,
/// falling back to the first non-empty line of the content.
pub fn activity_subject(event: &Event) -> SharedString {
@@ -193,7 +237,7 @@ impl Announcement {
let mut relays: Vec<RelayUrl> = Vec::new();
let mut euc: Option<String> = None;
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() {
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
// manually (first value wins).
// manually (first wins).
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() {
let event = announcement_event(&[
&["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 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!(
announcement.upstream.as_deref(),
Some("30617:abc:upstream|https://example.com/upstream.git")
upstream.addr,
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"
);
}
+1
View File
@@ -3,5 +3,6 @@ mod repo_list;
pub(crate) mod sidebar;
pub use repo_detail::RepoDetailView;
pub(crate) use repo_detail::open_repo_panel;
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
@@ -60,6 +60,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if let Some(euc) = &announcement.euc {
rows.push(row(
"Earliest Commit",
@@ -67,13 +68,11 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if let Some(upstream) = &announcement.upstream {
rows.push(row(
"Upstream",
text(SharedString::from(upstream.clone())),
cx,
));
rows.push(row("Upstream", text(upstream.display()), cx));
}
if !announcement.hashtags.is_empty() {
rows.push(row(
"Hashtags",
@@ -81,6 +80,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.clone.is_empty() {
rows.push(row(
"Clone URLs",
@@ -92,6 +92,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.relays.is_empty() {
rows.push(row(
"Grasp Relays",
@@ -103,6 +104,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
cx,
));
}
if !announcement.maintainers.is_empty() {
rows.push(row(
"Maintainers",
+148 -2
View File
@@ -1,6 +1,7 @@
use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Component, Path, PathBuf};
use std::rc::Rc;
use std::time::Duration;
use anyhow::Error;
use assets::CustomIconName;
@@ -26,9 +27,9 @@ use gpui_component::{
VirtualListScrollHandle, h_flex, v_flex,
};
use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement;
use signed_core::{Announcement, RepoAddr, filters};
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::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
@@ -188,6 +189,9 @@ pub struct RepoDetailView {
tasks: Vec<Task<Result<(), Error>>>,
/// Subscriptions keeping the selectors' confirm events alive.
_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 {
@@ -315,6 +319,7 @@ impl RepoDetailView {
focus_handle: cx.focus_handle(),
tasks: Vec::new(),
_subscriptions: subscriptions,
pending_upstream: None,
}
}
@@ -959,6 +964,73 @@ 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;
}
Backend::global(cx).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
/// the explorer once the switch completes.
fn switch_ref(
@@ -1332,6 +1404,7 @@ impl RepoDetailView {
.text_ellipsis()
.child(description),
)
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
.child(
h_flex()
.mt_2()
@@ -2015,3 +2088,76 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
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
}
+46 -22
View File
@@ -1,7 +1,7 @@
use std::rc::Rc;
use assets::CustomIconName;
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
use dock::{BasePanel, DockArea, Panel, PanelEvent};
use gpui::prelude::*;
use gpui::{
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 utils::relative_time;
use super::RepoDetailView;
use super::open_repo_panel;
const COLUMNS: usize = 2;
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
@@ -173,21 +173,7 @@ impl RepoListView {
window: &mut Window,
cx: &mut Context<Self>,
) {
let dock_area = self.dock_area.clone();
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,
);
});
}
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
}
fn render_card(
@@ -215,6 +201,26 @@ impl RepoListView {
.map(|label| SharedString::from(format!("Updated {label}")))
.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()
.id(ix)
.flex_1()
@@ -228,11 +234,29 @@ impl RepoListView {
.child(
h_flex()
.h_10()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.child(name),
.gap_1p5()
.items_center()
.child(
div()
.min_w_0()
.text_sm()
.font_semibold()
.whitespace_nowrap()
.text_ellipsis()
.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(
div()
@@ -1,4 +1,4 @@
use dock::{DockArea, DockPlacement, panel_handle};
use dock::DockArea;
use gpui::prelude::*;
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
use gpui_base::input::TextareaState;
@@ -11,7 +11,7 @@ use settings::SettingsStore;
use signed_core::Announcement;
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};
/// Shared state for the Create Repository dialog, so async results can be rendered.
@@ -262,13 +262,5 @@ fn open_repo(
window: &mut Window,
cx: &mut App,
) {
let Some(dock_area) = dock_area.upgrade() else {
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);
});
open_repo_panel(&dock_area, &announcement, window, cx);
}
+2 -14
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::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
use super::{RepoDetailView, RepoListView};
use super::{RepoDetailView, RepoListView, open_repo_panel};
mod create_repo_dialog;
pub(crate) mod grasp_servers;
@@ -158,19 +158,7 @@ impl SidebarPanel {
window: &mut Window,
cx: &mut Context<Self>,
) {
let detail = cx.new(|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_repo_panel(&self.dock_area, announcement, window, &mut *cx);
}
/// Open a local repository's detail view in the dock's center; the
+163
View File
@@ -0,0 +1,163 @@
# Plan
Two work streams:
1. **Fork support (display + navigation UI)** — show when a repository is a fork and let the user jump to the upstream repository.
2. **Pull request improvement** — bring PR creation/updating in line with the other NIP-34 clients (nak, ngit).
---
## 1. Fork support
### Background: what NIP-34 says about forks
NIP-34 has no fork event kind — a fork is an ordinary kind-30617 announcement by another author (or the same author under a different `d`). Fork-ness is expressed by two tags:
- **`u` tag** on the fork's announcement:
`["u", "30617:<upstream-pubkey>:<upstream-id>|<git-url>", "<relay-hint>", "<upstream-author-pubkey>"]`.
Including `u` means the author does **not** assert maintainership of the primary project (the fork is a *subordinate* of the upstream).
- **EUC** (`r` tag with `euc` marker): shared between the fork and its upstream (and other mirrors), so clients can group them. For a permanent fork, the EUC is the first commit after the fork point.
### Current state
- `Announcement::from_event` parses the `u` tag into an opaque string (`crates/signed_core/src/model.rs:212`; only the first value is kept, manually, because the SDK's `Nip34Tag` has no `Upstream` variant).
- `effective_maintainers` excludes the fork author (`model.rs:250`) — already correct per NIP-34.
- The About dialog shows the raw upstream string as a plain row (`crates/workspace/src/views/repo_detail/about.rs:68`).
- Nothing shows fork-ness in the repo list or the repo detail header, and there is no way to navigate to the upstream.
### Goal
- **Repo list card** (`crates/workspace/src/views/repo_list.rs::render_card`): show a "Forked from <upstream name>" badge instead of/in addition to the description, with a fork icon.
- **Repo detail header** (`crates/workspace/src/views/repo_detail/mod.rs::render_header`): show a "Forked from <name>" text button near the repo name.
- **Clicking the upstream** opens the upstream repository as a center panel (same as clicking any repo card).
### Design
#### 1.1 Structured `Upstream` model (`signed_core`)
Add a structured type and keep the manual parse:
```rust
pub struct Upstream {
/// `30617:<pubkey>:<id>` (navigable) or a git URL (not navigable).
pub target: UpstreamTarget,
pub relay_hint: Option<RelayUrl>,
pub author: Option<PublicKey>,
}
pub enum UpstreamTarget {
/// Parseable via the SDK `Coordinate` (`30617:<pubkey-hex>:<id>`).
Repo(RepoAddr),
/// Git https URL form: no NIP-34 announcement, not navigable.
GitUrl(Url),
}
```
- Change `Announcement.upstream: Option<String>` to `Option<Upstream>`; parse all three `u` values (the SDK's `Nip34Tag::parse` is not usable here — keep the manual `tag.kind() == "u"` branch and extend it).
- `RepoAddr` is the SDK `Coordinate` (`crates/signed_core/src/addr.rs`), so `Coordinate::from_str` gives the upstream address directly; validate it is kind `30617`.
- Update `about.rs` (renders `upstream`), `effective_maintainers`, and the `model.rs` tests (`parses_upstream_tag`, `effective_maintainers_exclude_owner_for_subordinate_forks`).
#### 1.2 Resolving the upstream announcement
Opening a panel needs an `Announcement` (`RepoDetailView::new`), so resolve the upstream announcement before (or while) opening:
1. **Lookup, no fetch**: the global `RepoListStore` holds every announcement in the local database (`crates/signed_state/src/repo_list.rs:51`). Look up the upstream `RepoAddr` there — covers the common case (upstream already browsed/known) with zero network.
2. **Miss → fetch, then open**: add a `Backend` method (e.g. `fetch_announcement(addr) -> Task<Result<Announcement>>`) doing a one-shot query with `filters::announcement(addr)` (`crates/signed_core/src/filters.rs:21`), mirroring the bootstrap fetch in `RepoStore::subscribe_remote` (`crates/signed_state/src/repo.rs:196`). Show the upstream as a disabled/loading row until it resolves; on failure fall back to showing the raw address.
3. **Git-URL upstreams**: not navigable — render as plain text with a copy action (like `copy_row` in `signed_ui`), no panel.
#### 1.3 Shared "open repo panel" helper
The open-panel sequence is currently duplicated three times:
- `crates/workspace/src/views/repo_list.rs:170` (`RepoListView::open_repo`)
- `crates/workspace/src/views/sidebar/mod.rs:155` (`SidebarPanel::open_repo`)
- `crates/workspace/src/views/sidebar/create_repo_dialog.rs:259` (`open_repo`)
Extract one helper (e.g. `open_repo_panel(dock_area, announcement, window, cx)` in the `workspace` views layer) and reuse it from all three plus the new fork button, so the fork navigation behaves exactly like clicking a repo card.
#### 1.4 Repo list badge
In `render_card`, when `announcement.upstream` is set:
- Resolve the upstream's display name via the `RepoListStore` lookup (1.2); fall back to the raw address string.
- Render a small "Forked from <name>" line (fork icon + `text_xs` muted), replacing or joining the description line. Add a `git-fork.svg` asset to `crates/assets/assets/icons/` + a `CustomIconName::GitFork` variant (lucide's `git-fork`), or reuse `git-branch.svg` if an asset addition is undesirable.
#### 1.5 Repo detail header
In `render_header` (`crates/workspace/src/views/repo_detail/mod.rs:1231`), next to the repo name:
- "Forked from <name>" as a **text button** (`gpui_base::Button` or styled `div`), which calls the shared open-panel helper with the resolved upstream announcement.
- Keep the About dialog row in sync: make it the same clickable control (or at least the same resolved display name).
- Handle "upstream not in store yet": spawn the `fetch_announcement` task; button shows a subtle loading state; on success open the panel (needs `window`/`cx` — the task is spawned on the view, `apply_announcement`-style flow).
#### 1.6 (Stretch) Fork grouping by EUC
`RepoListStore` already has `euc` per announcement; add a "N forks" count on the detail header by scanning announcements sharing the same EUC, with a filter or navigation into the explore list. Not required for the first iteration.
### Checklist
- [ ] `signed_core`: `Upstream`/`UpstreamTarget` types + full `u`-tag parse; `Announcement.upstream` type change; tests.
- [ ] `Backend::fetch_announcement(addr)` one-shot fetch.
- [ ] Shared `open_repo_panel` helper; switch the three existing call sites.
- [ ] Repo list card fork badge (+ `git-fork.svg` asset if used).
- [ ] Repo detail header "Forked from" button + About row sync.
- [ ] Manual test: fork with coordinate upstream (navigates), fork with git-URL upstream (copy only), upstream announcement absent (fetch-then-open).
---
## 2. Pull request improvement
### Why
Current PR creation (`RepoStore::open_pull_request`, `crates/signed_state/src/repo.rs:626`; dialog `crates/workspace/src/views/repo_detail/pull_requests.rs:288`) requires pasting `git format-patch` output, publishes no `merge-base`/`branch-name`, cannot update an existing PR, and advertises clone URLs the author usually cannot push to. Compared with nak (`pr send`/`pr update`/`pr merge`) and ngit (push-based PRs with merge-base inference), the gaps are:
| Area | Today | Fix (phase) |
| --- | --- | --- |
| Patch generation | manual paste | generate from a local checkout (P2) |
| `merge-base` tag | always `None` | compute vs state HEAD (P1) |
| `branch-name` tag | never | send local branch name (P1) |
| PR updates (kind 1619) | not producible | author-only update flow (P1) |
| Update reader trusts any author | `latest_update` has no author filter | filter by PR author (P1) |
| `clone` URL truthfulness | repo mirrors (author can't push) | push tip to grasp first (P3) |
| Multi-commit series | one oversized event | NIP-10 chain / size-aware (P3) |
| Pre-publish validation | none | `git am --check` dry run (P2) |
| Draft on create | no | optional 1633 status (P1) |
| Merge provenance | plain 1631 | `merge-commit`/`applied-as-commits` (P4) |
### Phase 1 — Correctness & interop (small, surgical)
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`).
- Fill the `GitPullRequest` builder's existing `merge_base`/`branch_name` fields (currently hardcoded `None`, `repo.rs:691-702`); pass the branch name and tip through from the dialog.
- Add the `r` EUC tag manually to the PR event (the SDK builder omits it; NIP-34 recommends it for subscription efficiency).
2. **Add `RepoStore::update_pull_request(root, new_tip, …)`** producing a kind-1619 event via the SDK `GitPullRequestUpdate` builder (`E`/`P`/`K` NIP-22 tags) plus a chained root-revision patch (`t root-revision`, `e` reply to the original root patch). Author-only, mirroring nak's `pr update`. Wire a button into `pull_request_detail.rs`.
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`).
### 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:
- resolves the tip (`git rev-parse`),
- computes `merge-base` vs the target tip,
- runs `format-patch base..tip --stdout` itself (add `signed_git::format_patch_between`, like nak),
- **dry-runs `git am --3way --check`** against the cached clone before publishing (`signed_git::apply_patch` infra, `crates/signed_git/src/lib.rs:176`), surfacing "does not apply" before anything hits the relays.
### Phase 3 — Truthful clone URLs (interop)
6. **Push before publishing**: add `signed_git::push_commit_ref(path, url, commit, ref)` and reuse the grasp-push infrastructure (`grasp_base_url`, `push_to_grasp_servers`, `crates/signed_state/src/backend.rs:1472,1500`) to push the tip to `refs/nostr/<event-id>` on the announced grasp servers (nak's `gitPushCommitToGraspRefs`). On success the `clone` tag carries the real URL; on failure fall back to the current patch-event model with a warning.
7. **Size-aware publishing**: split multi-commit mboxes into a NIP-10-chained 1617 series (each < 60 KB per NIP-34) or go PR-only above that size; adopt ngit's patch→PR upgrade (new PR + close-status for the original patch).
8. Optional: GRASP-06 `/prs/<npub>/<id>.git` + kind-10317 user grasp-list fallback (ngit's server-selection cascade). Fork support (section 1) makes the fork's own grasp server a natural push target here.
### Phase 4 — Merge provenance
9. In `merge_pull_request` (`crates/signed_state/src/repo.rs:817`), publish the 1631 status with `merge-commit` (or `applied-as-commits`) and `q` tags so nak/ngit/GitWorkshop show merge provenance correctly.
### 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.
- [ ] 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.
+5 -6
View File
@@ -1,12 +1,11 @@
# TODO
## Local repository scan
## Fork support
- [ ] Make the scanned directories configurable (currently fixed to Desktop and Documents).
## Create repository dialog
- [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop).
- [ ] 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).
## Performance: render path