add upstream and fork in ui

This commit is contained in:
2026-09-02 08:45:40 +07:00
parent aaa7aa7ec8
commit 6f1256757f
9 changed files with 471 additions and 67 deletions
+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