From 1dae0cb4b5db4dd097896621e7e6b1a1ae322f4e Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 31 Aug 2026 15:11:32 +0700 Subject: [PATCH] update --- crates/signed_state/src/backend.rs | 212 ++++++++++++++++-- crates/signed_state/src/repo.rs | 7 +- crates/signed_state/src/repo_list.rs | 10 +- .../workspace/src/views/repo_detail/about.rs | 15 +- crates/workspace/src/views/repo_detail/mod.rs | 168 ++++++++++++-- 5 files changed, 364 insertions(+), 48 deletions(-) diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 4deedb5..d6d31d6 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -10,7 +10,7 @@ use nostr::event::IntoEventBuilder; use nostr_connect::prelude::*; use nostr_sdk::client::SyncSummary; 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 crate::git_store::GitStore; @@ -497,15 +497,30 @@ impl Backend { })? .await?; - this.update(cx, |this, cx| { - let builder = build_state( - &repo_id, - &[("refs/heads/main".to_owned(), commit)], - Some("main"), - ); - this.send(builder, cx) - })? - .await?; + let state_event = match this + .update(cx, |this, cx| { + let builder = build_state( + &repo_id, + &[("refs/heads/main".to_owned(), commit)], + Some("main"), + ); + this.send(builder, cx) + })? + .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 // that fails to accept the push is logged, but the creation @@ -517,7 +532,19 @@ impl Backend { let servers = servers.clone(); 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")) }) @@ -624,11 +651,26 @@ impl Backend { 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?; + let state_event = match this + .update(cx, |this, cx| { + let builder = build_state(&repo_id, &refs, head.as_deref()); + this.send(builder, cx) + })? + .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 // that fails to accept the push is logged, but the init only @@ -642,7 +684,17 @@ impl Backend { let servers = servers.clone(); 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 @@ -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, + ) -> Task> { + 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, + ) -> Task> { + 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 /// the credential's prefix. pub fn login(&mut self, credential: &str, cx: &mut Context) { @@ -1149,6 +1301,32 @@ impl Backend { 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) { + if events.is_empty() { + return; + } + + let mut tags: Vec = 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 diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 6456352..9f00170 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -86,8 +86,13 @@ impl RepoStore { let kind = event.kind == Kind::GitRepoAnnouncement; let author = event.pubkey == this.addr.public_key; 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, }; diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index e547c95..3d8d91f 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -98,8 +98,14 @@ impl RepoListStore { } } BackendEvent::Published(event) => { - event.kind == Kind::GitRepoAnnouncement - && this.author.is_none_or(|a| a == event.pubkey) + let announcement = event.kind == Kind::GitRepoAnnouncement + && 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, _ => false, diff --git a/crates/workspace/src/views/repo_detail/about.rs b/crates/workspace/src/views/repo_detail/about.rs index 1bad904..36c2091 100644 --- a/crates/workspace/src/views/repo_detail/about.rs +++ b/crates/workspace/src/views/repo_detail/about.rs @@ -204,21 +204,22 @@ fn list(id: &'static str, items: impl IntoIterator, cx: &App) -> .min_w_0() .children(items.into_iter().enumerate().map(|(ix, item)| { h_flex() + .id(ix) + .h_8() + .px_2() .gap_2() - .items_center() .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( - h_flex() - .h_5() - .px_1() + div() .flex_1() .min_w_0() - .overflow_hidden() .whitespace_nowrap() .text_ellipsis() .text_sm() - .bg(cx.theme().muted) - .rounded(cx.theme().radius) .child(SharedString::from(middle_truncate(&item, 28, 16))), ) .child( diff --git a/crates/workspace/src/views/repo_detail/mod.rs b/crates/workspace/src/views/repo_detail/mod.rs index 3bbaeb8..d8558d0 100644 --- a/crates/workspace/src/views/repo_detail/mod.rs +++ b/crates/workspace/src/views/repo_detail/mod.rs @@ -13,12 +13,14 @@ use gpui::{ Task, WeakEntity, Window, div, px, relative, size, }; use gpui_base::{Button as BaseButton, Disableable, Popover}; +use gpui_component::alert::Alert; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::clipboard::Clipboard; use gpui_component::combobox::{ Caret, Combobox, ComboboxEvent, ComboboxState, ComboboxTriggerContext, }; +use gpui_component::menu::DropdownMenu; use gpui_component::searchable_list::SearchableVec; use gpui_component::tree::TreeState; use gpui_component::{ @@ -28,7 +30,7 @@ use gpui_component::{ use nostr::prelude::{RelayUrl, ToBech32}; use signed_core::Announcement; 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::pixel_avatar::PixelAvatar; @@ -74,6 +76,12 @@ enum RepoAction { NewIssue, /// Open the "new pull request" dialog. 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, @@ -148,6 +156,8 @@ pub struct RepoDetailView { loading: bool, /// The header clone button is cloning into a user-chosen folder. cloning: bool, + /// A push to the grasp servers is in flight. + pushing: bool, error: Option, /// Commit HEAD currently points to, shown in the header button. head_commit: Option, @@ -281,6 +291,7 @@ impl RepoDetailView { item_sizes: Rc::new(Vec::new()), loading: true, cloning: false, + pushing: false, error: None, head_commit: None, 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) { + 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) { + 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. fn open_issue_detail(&mut self, window: &mut Window, cx: &mut Context) { let Some(store) = self.store.clone() else { @@ -1145,7 +1210,12 @@ impl RepoDetailView { return div().into_any_element(); }; 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(); }; let issue_count = SharedString::from(store.issue_count().to_string()); @@ -1154,9 +1224,9 @@ impl RepoDetailView { let name = self.display_name(cx); let description = announcement.description(); 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 nak_command = SharedString::from(format!("nak git clone {nostr_url}")); let git_commands = announcement.clone_urls(); @@ -1174,6 +1244,13 @@ impl RepoDetailView { 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() @@ -1263,11 +1340,13 @@ impl RepoDetailView { })), ) .dropdown_menu(|menu, _, _| { - menu.menu_element_with_icon( - IconName::Plus, - Box::new(RepoAction::NewIssue), - |_, _| div().text_xs().child("New issue"), - ) + menu.menu_element(Box::new(RepoAction::NewIssue), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New issue") + }) }), ) .child( @@ -1304,11 +1383,13 @@ impl RepoDetailView { })), ) .dropdown_menu(|menu, _, _| { - menu.menu_element_with_icon( - IconName::Plus, - Box::new(RepoAction::NewPR), - |_, _| div().text_xs().child("New PR"), - ) + menu.menu_element(Box::new(RepoAction::NewPR), |_, _| { + h_flex() + .gap_2() + .text_sm() + .child(Icon::new(IconName::Plus)) + .child("New PR") + }) }), ) .child( @@ -1330,15 +1411,50 @@ impl RepoDetailView { .dropdown_menu(move |menu, _, _| share.menu(menu)), ) .child( - Button::new("info") - .icon(IconName::Info) - .tooltip("About") + Button::new("repo-menu-open") + .icon(IconName::EllipsisVertical) + .tooltip("Repository management") + .compact() .secondary() - .on_click(cx.listener(|this, _event, window, cx| { - if let Some(announcement) = this.announcement(cx) { - open_about_dialog(announcement.clone(), window, cx); + .loading(self.pushing) + .disabled(self.pushing) + .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({ let view = cx.entity(); @@ -1781,6 +1897,16 @@ impl Render for RepoDetailView { .id("repo") .size_full() .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 { 0 => this.child( h_flex()