feat: pull request and patch #13
@@ -3,6 +3,8 @@ use std::collections::HashSet;
|
|||||||
use gpui::SharedString;
|
use gpui::SharedString;
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
use crate::RepoAddr;
|
||||||
|
|
||||||
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
/// Parsed NIP-34 repository announcement (plain data, ready for the UI).
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct Announcement {
|
pub struct Announcement {
|
||||||
@@ -28,11 +30,53 @@ pub struct Announcement {
|
|||||||
pub maintainers: Vec<PublicKey>,
|
pub maintainers: Vec<PublicKey>,
|
||||||
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
/// Value of a `u` tag, if any: this repository is a subordinate fork of
|
||||||
/// the referenced upstream (NIP-34).
|
/// the referenced upstream (NIP-34).
|
||||||
pub upstream: Option<String>,
|
pub upstream: Option<Upstream>,
|
||||||
/// Hashtags labelling the repository (`t` tags).
|
/// Hashtags labelling the repository (`t` tags).
|
||||||
pub hashtags: Vec<String>,
|
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,
|
/// Subject of a NIP-34 issue or pull request event: the `subject` tag,
|
||||||
/// falling back to the first non-empty line of the content.
|
/// falling back to the first non-empty line of the content.
|
||||||
pub fn activity_subject(event: &Event) -> SharedString {
|
pub fn activity_subject(event: &Event) -> SharedString {
|
||||||
@@ -193,7 +237,7 @@ impl Announcement {
|
|||||||
let mut relays: Vec<RelayUrl> = Vec::new();
|
let mut relays: Vec<RelayUrl> = Vec::new();
|
||||||
let mut euc: Option<String> = None;
|
let mut euc: Option<String> = None;
|
||||||
let mut maintainers: Vec<PublicKey> = Vec::new();
|
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() {
|
for tag in event.tags.iter() {
|
||||||
match Nip34Tag::parse(tag.as_slice()) {
|
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
|
// 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" {
|
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() {
|
fn parses_upstream_tag() {
|
||||||
let event = announcement_event(&[
|
let event = announcement_event(&[
|
||||||
&["d", "my-fork"],
|
&["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 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!(
|
assert_eq!(
|
||||||
announcement.upstream.as_deref(),
|
upstream.addr,
|
||||||
Some("30617:abc:upstream|https://example.com/upstream.git")
|
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"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,5 +3,6 @@ mod repo_list;
|
|||||||
pub(crate) mod sidebar;
|
pub(crate) mod sidebar;
|
||||||
|
|
||||||
pub use repo_detail::RepoDetailView;
|
pub use repo_detail::RepoDetailView;
|
||||||
|
pub(crate) use repo_detail::open_repo_panel;
|
||||||
pub use repo_list::RepoListView;
|
pub use repo_list::RepoListView;
|
||||||
pub use sidebar::SidebarPanel;
|
pub use sidebar::SidebarPanel;
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(euc) = &announcement.euc {
|
if let Some(euc) = &announcement.euc {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Earliest Commit",
|
"Earliest Commit",
|
||||||
@@ -67,13 +68,11 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(upstream) = &announcement.upstream {
|
if let Some(upstream) = &announcement.upstream {
|
||||||
rows.push(row(
|
rows.push(row("Upstream", text(upstream.display()), cx));
|
||||||
"Upstream",
|
|
||||||
text(SharedString::from(upstream.clone())),
|
|
||||||
cx,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !announcement.hashtags.is_empty() {
|
if !announcement.hashtags.is_empty() {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Hashtags",
|
"Hashtags",
|
||||||
@@ -81,6 +80,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !announcement.clone.is_empty() {
|
if !announcement.clone.is_empty() {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Clone URLs",
|
"Clone URLs",
|
||||||
@@ -92,6 +92,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !announcement.relays.is_empty() {
|
if !announcement.relays.is_empty() {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Grasp Relays",
|
"Grasp Relays",
|
||||||
@@ -103,6 +104,7 @@ fn announcement_rows(announcement: &Announcement, cx: &App) -> AnyElement {
|
|||||||
cx,
|
cx,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if !announcement.maintainers.is_empty() {
|
if !announcement.maintainers.is_empty() {
|
||||||
rows.push(row(
|
rows.push(row(
|
||||||
"Maintainers",
|
"Maintainers",
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use std::collections::{HashMap, HashSet, VecDeque};
|
use std::collections::{HashMap, HashSet, VecDeque};
|
||||||
use std::path::{Component, Path, PathBuf};
|
use std::path::{Component, Path, PathBuf};
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
@@ -26,9 +27,9 @@ use gpui_component::{
|
|||||||
VirtualListScrollHandle, h_flex, v_flex,
|
VirtualListScrollHandle, h_flex, v_flex,
|
||||||
};
|
};
|
||||||
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||||
use signed_core::Announcement;
|
use signed_core::{Announcement, RepoAddr, filters};
|
||||||
use signed_git::{CommitList, FileCommit};
|
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::image_cache::{MAX_IMAGES, image_cache};
|
||||||
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
use signed_ui::{DropdownButton, PixelAvatar, UserAvatar, copy_row};
|
||||||
|
|
||||||
@@ -188,6 +189,9 @@ pub struct RepoDetailView {
|
|||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Subscriptions keeping the selectors' confirm events alive.
|
/// Subscriptions keeping the selectors' confirm events alive.
|
||||||
_subscriptions: Vec<Subscription>,
|
_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 {
|
impl RepoDetailView {
|
||||||
@@ -315,6 +319,7 @@ impl RepoDetailView {
|
|||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
tasks: Vec::new(),
|
tasks: Vec::new(),
|
||||||
_subscriptions: subscriptions,
|
_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
|
/// Check out `name` (a branch or tag picked in the header) and refresh
|
||||||
/// the explorer once the switch completes.
|
/// the explorer once the switch completes.
|
||||||
fn switch_ref(
|
fn switch_ref(
|
||||||
@@ -1332,6 +1404,7 @@ impl RepoDetailView {
|
|||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.child(description),
|
.child(description),
|
||||||
)
|
)
|
||||||
|
.when_some(fork_row(&announcement, cx), |this, row| this.child(row))
|
||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.mt_2()
|
.mt_2()
|
||||||
@@ -2015,3 +2088,76 @@ fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedSt
|
|||||||
|
|
||||||
SharedString::from(url)
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
||||||
use assets::CustomIconName;
|
use assets::CustomIconName;
|
||||||
use dock::{BasePanel, DockArea, DockPlacement, Panel, PanelEvent, panel_handle};
|
use dock::{BasePanel, DockArea, Panel, PanelEvent};
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
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 signed_ui::{SegmentButton, UserAvatar};
|
||||||
use utils::relative_time;
|
use utils::relative_time;
|
||||||
|
|
||||||
use super::RepoDetailView;
|
use super::open_repo_panel;
|
||||||
|
|
||||||
const COLUMNS: usize = 2;
|
const COLUMNS: usize = 2;
|
||||||
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.;
|
||||||
@@ -173,21 +173,7 @@ impl RepoListView {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let dock_area = self.dock_area.clone();
|
open_repo_panel(&self.dock_area, announcement, window, &mut *cx);
|
||||||
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,
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_card(
|
fn render_card(
|
||||||
@@ -215,6 +201,26 @@ impl RepoListView {
|
|||||||
.map(|label| SharedString::from(format!("Updated {label}")))
|
.map(|label| SharedString::from(format!("Updated {label}")))
|
||||||
.unwrap_or_default();
|
.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()
|
v_flex()
|
||||||
.id(ix)
|
.id(ix)
|
||||||
.flex_1()
|
.flex_1()
|
||||||
@@ -228,12 +234,30 @@ impl RepoListView {
|
|||||||
.child(
|
.child(
|
||||||
h_flex()
|
h_flex()
|
||||||
.h_10()
|
.h_10()
|
||||||
|
.gap_1p5()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.min_w_0()
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.font_semibold()
|
.font_semibold()
|
||||||
.whitespace_nowrap()
|
.whitespace_nowrap()
|
||||||
.text_ellipsis()
|
.text_ellipsis()
|
||||||
.child(name),
|
.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(
|
.child(
|
||||||
div()
|
div()
|
||||||
.h_16()
|
.h_16()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
use dock::{DockArea, DockPlacement, panel_handle};
|
use dock::DockArea;
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
use gpui::{App, Entity, PathPromptOptions, SharedString, WeakEntity, Window, div, px};
|
||||||
use gpui_base::input::TextareaState;
|
use gpui_base::input::TextareaState;
|
||||||
@@ -11,7 +11,7 @@ use settings::SettingsStore;
|
|||||||
use signed_core::Announcement;
|
use signed_core::Announcement;
|
||||||
use signed_state::Backend;
|
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};
|
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.
|
/// Shared state for the Create Repository dialog, so async results can be rendered.
|
||||||
@@ -262,13 +262,5 @@ fn open_repo(
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
let Some(dock_area) = dock_area.upgrade() else {
|
open_repo_panel(&dock_area, &announcement, window, cx);
|
||||||
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);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ use signed_state::{Backend, BackendEvent, LocalReposStore, Profile, ProfileStore
|
|||||||
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
use signed_ui::image_cache::{MAX_IMAGES, image_cache};
|
||||||
use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers};
|
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;
|
mod create_repo_dialog;
|
||||||
pub(crate) mod grasp_servers;
|
pub(crate) mod grasp_servers;
|
||||||
@@ -158,19 +158,7 @@ impl SidebarPanel {
|
|||||||
window: &mut Window,
|
window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) {
|
) {
|
||||||
let detail = cx.new(|cx| {
|
open_repo_panel(&self.dock_area, announcement, window, &mut *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 a local repository's detail view in the dock's center; the
|
/// Open a local repository's detail view in the dock's center; the
|
||||||
|
|||||||
+163
@@ -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
@@ -1,12 +1,11 @@
|
|||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
## Local repository scan
|
## Fork support
|
||||||
|
|
||||||
- [ ] Make the scanned directories configurable (currently fixed to Desktop and Documents).
|
- [ ] Add UI for fork (see `PLAN.md` section 1):
|
||||||
|
- [ ] Fork badge on repo list cards (`repo_list.rs::render_card`).
|
||||||
## Create repository dialog
|
- [ ] "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).
|
||||||
- [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop).
|
|
||||||
|
|
||||||
## Performance: render path
|
## Performance: render path
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user