From 6f1256757f8fe10c65332bb021f65f3d60ecdadc Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Wed, 2 Sep 2026 08:45:40 +0700 Subject: [PATCH] add upstream and fork in ui --- crates/signed_core/src/model.rs | 103 ++++++++++- crates/workspace/src/views/mod.rs | 1 + .../workspace/src/views/repo_detail/about.rs | 12 +- crates/workspace/src/views/repo_detail/mod.rs | 150 +++++++++++++++- crates/workspace/src/views/repo_list.rs | 68 +++++--- .../src/views/sidebar/create_repo_dialog.rs | 14 +- crates/workspace/src/views/sidebar/mod.rs | 16 +- docs/PLAN.md | 163 ++++++++++++++++++ docs/TODO.md | 11 +- 9 files changed, 471 insertions(+), 67 deletions(-) create mode 100644 docs/PLAN.md diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index 2dd190c..f2a712f 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -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, /// Value of a `u` tag, if any: this repository is a subordinate fork of /// the referenced upstream (NIP-34). - pub upstream: Option, + pub upstream: Option, /// Hashtags labelling the repository (`t` tags). pub hashtags: Vec, } +/// 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::`) 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::` coordinate, when the `u` tag + /// references a NIP-34 repository; `None` for the git-URL form. + pub addr: Option, + /// Relay hint for the upstream, if the `u` tag carries one. + pub relay_hint: Option, +} + +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::() + .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 = Vec::new(); let mut euc: Option = None; let mut maintainers: Vec = Vec::new(); - let mut upstream: Option = None; + let mut upstream: Option = 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" ); } diff --git a/crates/workspace/src/views/mod.rs b/crates/workspace/src/views/mod.rs index 9c95334..1ec23e7 100644 --- a/crates/workspace/src/views/mod.rs +++ b/crates/workspace/src/views/mod.rs @@ -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; diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index 290954e..e0b9535 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -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", diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 16038c2..dfce1af 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -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>>, /// Subscriptions keeping the selectors' confirm events alive. _subscriptions: Vec, + /// Upstream repository (from this fork's `u` tag) the user asked to + /// open, while its announcement is still being fetched. + pending_upstream: Option, } 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) { + 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) -> Option { + 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, + announcement: &Announcement, + window: &mut Window, + cx: &mut App, +) -> Entity { + 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 +} diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 0582f59..7008656 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -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, ) { - 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 = + 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() diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 21eb909..bad974f 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -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); } diff --git a/crates/workspace/src/views/sidebar/mod.rs b/crates/workspace/src/views/sidebar/mod.rs index dbe6311..5d29f7e 100644 --- a/crates/workspace/src/views/sidebar/mod.rs +++ b/crates/workspace/src/views/sidebar/mod.rs @@ -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, ) { - 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 diff --git a/docs/PLAN.md b/docs/PLAN.md new file mode 100644 index 0000000..9409642 --- /dev/null +++ b/docs/PLAN.md @@ -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::|", "", ""]`. + 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 " 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 " 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::` (navigable) or a git URL (not navigable). + pub target: UpstreamTarget, + pub relay_hint: Option, + pub author: Option, +} + +pub enum UpstreamTarget { + /// Parseable via the SDK `Coordinate` (`30617::`). + Repo(RepoAddr), + /// Git https URL form: no NIP-34 announcement, not navigable. + GitUrl(Url), +} +``` + +- Change `Announcement.upstream: Option` to `Option`; 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>`) 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 " 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 " 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/` 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//.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. diff --git a/docs/TODO.md b/docs/TODO.md index 6f64fa2..695a787 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -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