This commit is contained in:
2026-08-31 15:11:32 +07:00
parent a2ef6bb1a3
commit 1dae0cb4b5
5 changed files with 364 additions and 48 deletions
+185 -7
View File
@@ -10,7 +10,7 @@ use nostr::event::IntoEventBuilder;
use nostr_connect::prelude::*; use nostr_connect::prelude::*;
use nostr_sdk::client::SyncSummary; use nostr_sdk::client::SyncSummary;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use signed_core::{Announcement, build_state, filters, identifier_from_name, repo_addr}; use signed_core::{Announcement, RepoAddr, build_state, filters, identifier_from_name, repo_addr};
use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update}; use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
use crate::git_store::GitStore; use crate::git_store::GitStore;
@@ -497,7 +497,8 @@ impl Backend {
})? })?
.await?; .await?;
this.update(cx, |this, cx| { let state_event = match this
.update(cx, |this, cx| {
let builder = build_state( let builder = build_state(
&repo_id, &repo_id,
&[("refs/heads/main".to_owned(), commit)], &[("refs/heads/main".to_owned(), commit)],
@@ -505,7 +506,21 @@ impl Backend {
); );
this.send(builder, cx) this.send(builder, cx)
})? })?
.await?; .await
{
Ok(state_event) => state_event,
Err(e) => {
this.update(cx, |this, cx| {
this.retract_events(std::slice::from_ref(&event), cx);
})
.ok();
return Err(e.context(
"The repository was announced, but its state could not be published. \
The announcement has been retracted",
));
}
};
// 4. Push the initial commit to every grasp server. A server // 4. Push the initial commit to every grasp server. A server
// that fails to accept the push is logged, but the creation // that fails to accept the push is logged, but the creation
@@ -517,7 +532,19 @@ impl Backend {
let servers = servers.clone(); let servers = servers.clone();
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main) push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_main)
}); });
push.await?; if let Err(e) = push.await {
// The events are already published; retract them so the
// repository doesn't remain announced without content.
this.update(cx, |this, cx| {
this.retract_events(&[event.clone(), state_event.clone()], cx);
})
.ok();
return Err(e.context(
"The repository was announced, but the push to every grasp server failed. \
The announcement has been retracted",
));
}
Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement")) Announcement::from_event(&event).ok_or_else(|| anyhow!("failed to parse announcement"))
}) })
@@ -624,11 +651,26 @@ impl Backend {
let refs = state.refs.clone(); let refs = state.refs.clone();
let head = state.head.clone(); let head = state.head.clone();
this.update(cx, |this, cx| { let state_event = match this
.update(cx, |this, cx| {
let builder = build_state(&repo_id, &refs, head.as_deref()); let builder = build_state(&repo_id, &refs, head.as_deref());
this.send(builder, cx) this.send(builder, cx)
})? })?
.await?; .await
{
Ok(state_event) => state_event,
Err(e) => {
this.update(cx, |this, cx| {
this.retract_events(std::slice::from_ref(&event), cx);
})
.ok();
return Err(e.context(
"The repository was announced, but its state could not be published. \
The announcement has been retracted",
));
}
};
// 4. Push every branch and tag to each grasp server. A server // 4. Push every branch and tag to each grasp server. A server
// that fails to accept the push is logged, but the init only // that fails to accept the push is logged, but the init only
@@ -642,7 +684,17 @@ impl Backend {
let servers = servers.clone(); let servers = servers.clone();
push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all) push_to_grasp_servers(path, owner, repo_id, servers, signed_git::push_all)
}); });
push.await?; if let Err(e) = push.await {
this.update(cx, |this, cx| {
this.retract_events(&[event.clone(), state_event.clone()], cx);
})
.ok();
return Err(e.context(
"The repository was announced, but the push to every grasp server failed. \
The announcement has been retracted",
));
}
} }
// 5. Point `origin` at the first grasp server so later pushes // 5. Point `origin` at the first grasp server so later pushes
@@ -660,6 +712,106 @@ impl Backend {
}) })
} }
/// Re-push the repository's current refs to the grasp servers announced
/// in its `relays` tag: publishes a fresh state event (the push
/// authorization), then pushes every branch and tag, like the init
/// flow. The repository must have a local clone in the cache.
pub fn push_repository(
&mut self,
announcement: Announcement,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let addr = announcement.addr();
let cache = GitStore::global(cx).cache().clone();
let path = cache.repo_path(&addr);
let owner = announcement
.owner
.to_bech32()
.unwrap_or_else(|_| announcement.owner.to_hex());
let repo_id = announcement.id.clone();
let relays = announcement.relays.clone();
cx.spawn(async move |this, cx| {
// 1. Read the current refs of the local clone.
let work = cx.background_spawn({
let path = path.clone();
async move { signed_git::worktree_ref_state(&path) }
});
let state = work.await?;
// 2. Publish a fresh state event; grasp servers authorize a
// push by the state they have seen.
let refs = state.refs.clone();
let head = state.head.clone();
this.update(cx, |this, cx| {
let builder = build_state(&repo_id, &refs, head.as_deref());
this.send(builder, cx)
})?
.await?;
// 3. Push every branch and tag to the announced grasp servers.
if !refs.is_empty() {
let push = cx.background_spawn({
let path = path.clone();
let owner = owner.clone();
let repo_id = repo_id.clone();
let relays = relays.clone();
async move {
push_to_grasp_servers(path, owner, repo_id, relays, signed_git::push_all)
.await
}
});
push.await?;
}
Ok(())
})
}
/// Delete the repository from nostr: publish NIP-09 deletions for its
/// announcement, state and activity events (issues, pull requests,
/// patches, statuses, comments). Only the repository owner may delete
/// it.
pub fn delete_repository(
&mut self,
addr: RepoAddr,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
let Some(public_key) = self.current_user else {
return Task::ready(Err(anyhow!("Sign in to delete a repository")));
};
if public_key != addr.public_key {
return Task::ready(Err(anyhow!("Only the repository owner can delete it")));
}
let client = self.client.clone();
let addr = addr.clone();
cx.spawn(async move |this, cx| {
// Collect every event of the repository from the local database.
let events = cx.background_spawn(async move {
let db = client.database();
let mut events = Vec::new();
for filter in [
filters::announcement(&addr),
filters::state(&addr),
filters::activity(&addr),
] {
events.extend(db.query(filter).await?);
}
Ok::<_, Error>(events)
});
let events = events.await?;
this.update(cx, |this, cx| {
this.retract_events(&events, cx);
})
.ok();
Ok(())
})
}
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on /// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
/// the credential's prefix. /// the credential's prefix.
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) { pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
@@ -1149,6 +1301,32 @@ impl Backend {
Ok(()) Ok(())
})); }));
} }
/// Publish a NIP-09 deletion event for `events` (best-effort), so a
/// publish that fails midway can retract the events that were already
/// broadcast to relays. Failures are logged, not surfaced: the caller's
/// error already told the user what happened.
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
if events.is_empty() {
return;
}
let mut tags: Vec<Tag> = Vec::with_capacity(events.len() * 2);
for event in events {
tags.push(Tag::event(event.id));
tags.push(Tag::parse(["k", &event.kind.to_string()]).expect("valid kind tag"));
}
let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
self.tasks.push(cx.spawn(async move |_this, _cx| {
if let Err(e) = task.await {
log::warn!("failed to retract repository events: {e}");
}
Ok(())
}));
}
} }
/// Add the given relays, connect to them, and fetch the filters: a one-shot /// Add the given relays, connect to them, and fetch the filters: a one-shot
+6 -1
View File
@@ -86,8 +86,13 @@ impl RepoStore {
let kind = event.kind == Kind::GitRepoAnnouncement; let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key; let author = event.pubkey == this.addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr); let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
// Locally published deletions may target any event of
// this repository; refresh so they take effect
// immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) coordinate || (kind && author) || deletion
} }
_ => false, _ => false,
}; };
+8 -2
View File
@@ -98,8 +98,14 @@ impl RepoListStore {
} }
} }
BackendEvent::Published(event) => { BackendEvent::Published(event) => {
event.kind == Kind::GitRepoAnnouncement let announcement = event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey) && this.author.is_none_or(|a| a == event.pubkey);
// Locally published deletions (e.g. deleting a repo)
// are already in the local database; refresh so they
// take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || deletion
} }
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true, BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
_ => false, _ => false,
@@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator<Item = String>, cx: &App) ->
.min_w_0() .min_w_0()
.children(items.into_iter().enumerate().map(|(ix, item)| { .children(items.into_iter().enumerate().map(|(ix, item)| {
h_flex() h_flex()
.id(ix)
.h_8()
.px_2()
.gap_2() .gap_2()
.items_center()
.min_w_0() .min_w_0()
.bg(cx.theme().secondary)
.hover(|this| this.bg(cx.theme().secondary_hover))
.rounded(cx.theme().radius)
.text_color(cx.theme().secondary_foreground)
.child( .child(
h_flex() div()
.h_5()
.px_1()
.flex_1() .flex_1()
.min_w_0() .min_w_0()
.overflow_hidden()
.whitespace_nowrap() .whitespace_nowrap()
.text_ellipsis() .text_ellipsis()
.text_sm() .text_sm()
.bg(cx.theme().muted)
.rounded(cx.theme().radius)
.child(SharedString::from(middle_truncate(&item, 28, 16))), .child(SharedString::from(middle_truncate(&item, 28, 16))),
) )
.child( .child(
+147 -21
View File
@@ -13,12 +13,14 @@ use gpui::{
Task, WeakEntity, Window, div, px, relative, size, Task, WeakEntity, Window, div, px, relative, size,
}; };
use gpui_base::{Button as BaseButton, Disableable, Popover}; use gpui_base::{Button as BaseButton, Disableable, Popover};
use gpui_component::alert::Alert;
use gpui_component::avatar::Avatar; use gpui_component::avatar::Avatar;
use gpui_component::button::{Button, ButtonVariants}; use gpui_component::button::{Button, ButtonVariants};
use gpui_component::clipboard::Clipboard; use gpui_component::clipboard::Clipboard;
use gpui_component::combobox::{ use gpui_component::combobox::{
Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext,
}; };
use gpui_component::menu::DropdownMenu;
use gpui_component::searchable_list::SearchableVec; use gpui_component::searchable_list::SearchableVec;
use gpui_component::tree::TreeState; use gpui_component::tree::TreeState;
use gpui_component::{ use gpui_component::{
@@ -28,7 +30,7 @@ use gpui_component::{
use nostr::prelude::{RelayUrl, ToBech32}; use nostr::prelude::{RelayUrl, ToBech32};
use signed_core::Announcement; use signed_core::Announcement;
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{GitStore, LocalReposStore, ProfileStore, RepoStore}; use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::image_cache::{MAX_IMAGES, image_cache};
use crate::pixel_avatar::PixelAvatar; use crate::pixel_avatar::PixelAvatar;
@@ -74,6 +76,12 @@ enum RepoAction {
NewIssue, NewIssue,
/// Open the "new pull request" dialog. /// Open the "new pull request" dialog.
NewPR, NewPR,
/// Open the about dialog.
About,
/// Re-push the repository to its grasp servers.
Push,
/// Delete the repository from nostr (owner only).
Delete,
} }
/// Everything loaded from the local clone for the explorer: the tree seeds, /// Everything loaded from the local clone for the explorer: the tree seeds,
@@ -148,6 +156,8 @@ pub struct RepoDetailView {
loading: bool, loading: bool,
/// The header clone button is cloning into a user-chosen folder. /// The header clone button is cloning into a user-chosen folder.
cloning: bool, cloning: bool,
/// A push to the grasp servers is in flight.
pushing: bool,
error: Option<SharedString>, error: Option<SharedString>,
/// Commit HEAD currently points to, shown in the header button. /// Commit HEAD currently points to, shown in the header button.
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
@@ -281,6 +291,7 @@ impl RepoDetailView {
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
loading: true, loading: true,
cloning: false, cloning: false,
pushing: false,
error: None, error: None,
head_commit: None, head_commit: None,
branch_select, branch_select,
@@ -828,6 +839,60 @@ impl RepoDetailView {
}); });
} }
/// Re-push the repository's refs to its announced grasp servers; the
/// menu trigger shows a spinner while the push is in flight, failures
/// appear in the panel's error banner.
fn push_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
if self.pushing {
return;
}
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
self.pushing = true;
self.error = None;
cx.notify();
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.push_repository(announcement, cx));
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, _window, cx| {
if let Err(error) = result {
this.error = Some(format!("Push failed: {error}").into());
}
this.pushing = false;
cx.notify();
})?;
Ok(())
}));
}
/// Delete the repository from nostr (announcement, state and activity);
/// only offered to the repository owner. The sidebar list updates when
/// the deletion events arrive.
fn delete_repository(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(announcement) = self.announcement(cx).cloned() else {
return;
};
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| {
backend.delete_repository(announcement.addr(), cx)
});
self.tasks.push(cx.spawn_in(window, async move |this, cx| {
let result = task.await;
this.update_in(cx, |this, _window, cx| {
if let Err(error) = result {
this.error = Some(format!("Delete failed: {error}").into());
}
cx.notify();
})?;
Ok(())
}));
}
/// Open the issues panel at the bottom of the dock area. /// Open the issues panel at the bottom of the dock area.
fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) { fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context<Self>) {
let Some(store) = self.store.clone() else { let Some(store) = self.store.clone() else {
@@ -1145,7 +1210,12 @@ impl RepoDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
let store = store_entity.read(cx); let store = store_entity.read(cx);
let Some(announcement) = store.announcement.as_ref().or(self.initial.as_ref()) else { let Some(announcement) = store
.announcement
.as_ref()
.or(self.initial.as_ref())
.cloned()
else {
return div().into_any_element(); return div().into_any_element();
}; };
let issue_count = SharedString::from(store.issue_count().to_string()); let issue_count = SharedString::from(store.issue_count().to_string());
@@ -1154,9 +1224,9 @@ impl RepoDetailView {
let name = self.display_name(cx); let name = self.display_name(cx);
let description = announcement.description(); let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let share = ShareTargets::from_announcement(announcement); let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(announcement, cx); let nostr_url = nostr_clone_url(&announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}")); let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls(); let git_commands = announcement.clone_urls();
@@ -1174,6 +1244,13 @@ impl RepoDetailView {
open_new_pull_request_dialog(store, window, cx); open_new_pull_request_dialog(store, window, cx);
} }
} }
RepoAction::About => {
if let Some(announcement) = this.announcement(cx) {
open_about_dialog(announcement.clone(), window, cx);
}
}
RepoAction::Push => this.push_repository(window, cx),
RepoAction::Delete => this.delete_repository(window, cx),
}), }),
) )
.px_4() .px_4()
@@ -1263,11 +1340,13 @@ impl RepoDetailView {
})), })),
) )
.dropdown_menu(|menu, _, _| { .dropdown_menu(|menu, _, _| {
menu.menu_element_with_icon( menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| {
IconName::Plus, h_flex()
Box::new(RepoAction::NewIssue), .gap_2()
|_, _| div().text_xs().child("New issue"), .text_sm()
) .child(Icon::new(IconName::Plus))
.child("New issue")
})
}), }),
) )
.child( .child(
@@ -1304,11 +1383,13 @@ impl RepoDetailView {
})), })),
) )
.dropdown_menu(|menu, _, _| { .dropdown_menu(|menu, _, _| {
menu.menu_element_with_icon( menu.menu_element(Box::new(RepoAction::NewPR), |_, _| {
IconName::Plus, h_flex()
Box::new(RepoAction::NewPR), .gap_2()
|_, _| div().text_xs().child("New PR"), .text_sm()
) .child(Icon::new(IconName::Plus))
.child("New PR")
})
}), }),
) )
.child( .child(
@@ -1330,15 +1411,50 @@ impl RepoDetailView {
.dropdown_menu(move |menu, _, _| share.menu(menu)), .dropdown_menu(move |menu, _, _| share.menu(menu)),
) )
.child( .child(
Button::new("info") Button::new("repo-menu-open")
.icon(IconName::Info) .icon(IconName::EllipsisVertical)
.tooltip("About") .tooltip("Repository management")
.compact()
.secondary() .secondary()
.on_click(cx.listener(|this, _event, window, cx| { .loading(self.pushing)
if let Some(announcement) = this.announcement(cx) { .disabled(self.pushing)
open_about_dialog(announcement.clone(), window, cx); .dropdown_menu(move |menu, _, cx| {
let backend = Backend::global(cx);
let current_user = backend.read(cx).current_user();
let owner = current_user == Some(announcement.owner);
let menu = menu.menu_element(
Box::new(RepoAction::About),
|_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(IconName::Info))
.child("About")
},
);
if owner {
menu.menu_element(Box::new(RepoAction::Push), |_, _| {
h_flex()
.gap_2()
.text_sm()
.child(Icon::new(CustomIconName::Init))
.child("Republish")
})
.separator()
.menu_element(Box::new(RepoAction::Delete), |_, cx| {
h_flex()
.gap_2()
.text_sm()
.text_color(cx.theme().danger)
.child(Icon::new(IconName::Delete))
.child("Delete")
})
} else {
menu
} }
})), }),
) )
.child({ .child({
let view = cx.entity(); let view = cx.entity();
@@ -1781,6 +1897,16 @@ impl Render for RepoDetailView {
.id("repo") .id("repo")
.size_full() .size_full()
.child(self.render_header(cx)) .child(self.render_header(cx))
.when_some(self.error.clone(), |this, error| {
this.child(
Alert::error("repo-error", error)
.banner()
.on_close(cx.listener(|this, _event, _window, cx| {
this.error = None;
cx.notify();
})),
)
})
.map(|this| match self.active_tab { .map(|this| match self.active_tab {
0 => this.child( 0 => this.child(
h_flex() h_flex()