diff --git a/crates/signed_state/src/backend.rs b/crates/signed_state/src/backend.rs index 3acef17..62dc33b 100644 --- a/crates/signed_state/src/backend.rs +++ b/crates/signed_state/src/backend.rs @@ -528,6 +528,7 @@ impl Backend { } let owner = public_key.to_bech32().unwrap(); + let addr = RepoAddr::new(public_key, repo_id.clone()); let servers = grasp_servers.clone(); let client = self.client.clone(); let signer = self.signer.clone(); @@ -542,7 +543,7 @@ impl Backend { folder.join(dir_name) }; - cx.spawn(async move |_this, cx| { + cx.spawn(async move |this, cx| { let work = cx.background_spawn({ let destination = destination.clone(); let name = name.clone(); @@ -589,8 +590,19 @@ impl Backend { mode: GitPushMode::Main, }; + let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::PushProgress { + addr: addr.clone(), + status, + }); + })?; + Ok::<(), Error>(()) + }; + let event = - announce_and_push_or_retract(&client, &signer, &state, announcement, cx).await?; + announce_and_push_or_retract(&client, &signer, &state, announcement, cx, on_step) + .await?; let announcement = Announcement::from_event(&event) .ok_or_else(|| anyhow!("failed to parse announcement"))?; @@ -638,12 +650,13 @@ impl Backend { } let owner = public_key.to_bech32().unwrap(); + let addr = RepoAddr::new(public_key, repo_id.clone()); let servers = grasp_servers.clone(); let client = self.client.clone(); let signer = self.signer.clone(); - cx.spawn(async move |_this, cx| { + cx.spawn(async move |this, cx| { let (mut state, euc) = cx .background_spawn({ let path = path.clone(); @@ -670,7 +683,19 @@ impl Backend { let euc = euc.and_then(|commit: String| Sha1Hash::from_str(&commit).ok()); let ann = build_announcement(&repo_id, &name, &description, &owner, &servers, euc); - let event = announce_and_push_or_retract(&client, &signer, &state, ann, cx).await?; + + let on_step = &mut |status: PushStepStatus, cx: &mut AsyncApp| { + this.update(cx, |_, cx| { + cx.emit(BackendEvent::PushProgress { + addr: addr.clone(), + status, + }); + })?; + Ok::<(), Error>(()) + }; + + let event = + announce_and_push_or_retract(&client, &signer, &state, ann, cx, on_step).await?; if let Some(base) = servers.first().and_then(grasp_base_url) { let url = format!("{base}/{owner}/{repo_id}.git"); @@ -1151,15 +1176,17 @@ fn build_announcement( /// Announces a repository and pushes its state to grasp servers, /// or retracts the announcement if the push fails. -async fn announce_and_push_or_retract( +async fn announce_and_push_or_retract( client: &Client, signer: &S, state: &SyncState, announcement: GitRepositoryAnnouncement, cx: &mut AsyncApp, + on_step: &mut P, ) -> Result where S: AsyncGetPublicKey + AsyncSignEvent + ?Sized, + P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, { let state_event = state .state_event(&announcement.id) @@ -1168,13 +1195,7 @@ where let announcement_event = announcement.finalize_async(signer).await?; match state - .announce_and_push( - client, - announcement_event.clone(), - state_event, - cx, - &mut |_, _| Ok(()), - ) + .announce_and_push(client, announcement_event.clone(), state_event, cx, on_step) .await { Ok(event) => Ok(event), diff --git a/crates/workspace/src/views/dialog_state.rs b/crates/workspace/src/views/dialog_state.rs index 2eda596..e7e3aa9 100644 --- a/crates/workspace/src/views/dialog_state.rs +++ b/crates/workspace/src/views/dialog_state.rs @@ -1,12 +1,19 @@ use gpui::prelude::*; -use gpui::{AnyElement, App, SharedString, div}; -use gpui_component::ActiveTheme; +use gpui::{AnyElement, App, Context, SharedString, Subscription, div}; +use gpui_component::spinner::Spinner; +use gpui_component::{ActiveTheme, Icon, IconName, Sizable, h_flex, v_flex}; +use signed_core::RepoAddr; +use signed_state::{Backend, BackendEvent, PushStepStatus}; /// Progress of an async dialog action: a busy flag that disables the form and an error shown below it. #[derive(Debug, Default)] pub struct DialogProgress { pub busy: bool, pub error: Option, + // The repository whose push checklist is shown; `None` outside a publish. + push_addr: Option, + pub push_steps: Vec, + _push_subscription: Option, } impl DialogProgress { @@ -16,6 +23,37 @@ impl DialogProgress { self.error = None; } + /// Starts a publish for the repository at `addr` and listens for its push steps. + pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context) { + self.begin(); + self.push_addr = Some(addr); + self.push_steps.clear(); + + if self._push_subscription.is_none() { + let backend = Backend::global(cx); + self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| { + let BackendEvent::PushProgress { addr, status } = event else { + return; + }; + + if this.push_addr.as_ref() != Some(addr) { + return; + } + + match this + .push_steps + .iter_mut() + .find(|step| step.step() == status.step()) + { + Some(step) => *step = status.clone(), + None => this.push_steps.push(status.clone()), + } + + cx.notify(); + })); + } + } + /// Marks an action as failed, enabling the form and showing `message`. pub fn fail(&mut self, message: impl Into) { self.busy = false; @@ -33,3 +71,46 @@ pub fn error_row(error: &Option, cx: &App) -> Option { .into_any_element() }) } + +/// The push checklist of a running publish, `None` before any step is reported. +pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option { + if steps.is_empty() { + return None; + } + + let rows = steps + .iter() + .map(|status| { + let label = SharedString::from(status.step().label()); + + match status { + PushStepStatus::Running(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child(Spinner::new().small()) + .child(label) + .into_any_element(), + PushStepStatus::Done(_) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().foreground) + .child(Icon::new(IconName::CircleCheck).small()) + .child(label) + .into_any_element(), + PushStepStatus::Failed(_, reason) => h_flex() + .gap_2() + .text_sm() + .text_color(cx.theme().danger) + .child(Icon::new(IconName::Close).small()) + .child(SharedString::from(format!( + "{}: {reason}", + status.step().label() + ))) + .into_any_element(), + } + }) + .collect::>(); + + Some(v_flex().gap_1().children(rows).into_any_element()) +} diff --git a/crates/workspace/src/views/repo/init_dialog.rs b/crates/workspace/src/views/repo/init_dialog.rs index 8b87a6a..f566408 100644 --- a/crates/workspace/src/views/repo/init_dialog.rs +++ b/crates/workspace/src/views/repo/init_dialog.rs @@ -12,12 +12,13 @@ use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::select::{Select, SelectState}; use gpui_component::{ActiveTheme, Disableable, WindowExt}; use settings::SettingsStore; +use signed_core::RepoAddr; use signed_git::Repo; use signed_state::Backend; use signed_ui::SelectOption; use super::RepoDetailView; -use crate::views::dialog_state::{DialogProgress, error_row}; +use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row}; use crate::views::sidebar::grasp_servers::{ GraspServersState, grasp_servers_field, load_user_grasp_servers, }; @@ -120,6 +121,7 @@ pub fn open( ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) + .children(push_steps_row(&state.read(cx).push_steps, cx)) .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( @@ -189,9 +191,15 @@ fn init_repository( return; } - state.update(cx, |state, _| state.begin()); - let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.fail("Sign in to publish a repository")); + return; + }; + + let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name)); + state.update(cx, |state, cx| state.begin_push(addr, cx)); + let task = backend.update(cx, |backend, cx| { backend.publish_local_repo( local_path.clone(), diff --git a/crates/workspace/src/views/sidebar/create_repo_dialog.rs b/crates/workspace/src/views/sidebar/create_repo_dialog.rs index 9cc933e..9eb7fcd 100644 --- a/crates/workspace/src/views/sidebar/create_repo_dialog.rs +++ b/crates/workspace/src/views/sidebar/create_repo_dialog.rs @@ -10,11 +10,12 @@ use gpui_component::form::{field, v_form}; use gpui_component::input::{Input, InputState, Textarea}; use gpui_component::{Disableable, IconName, WindowExt, h_flex}; use settings::SettingsStore; +use signed_core::RepoAddr; use signed_state::{Backend, CheckoutsStore}; use super::super::open_repo_panel; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; -use crate::views::dialog_state::{DialogProgress, error_row}; +use crate::views::dialog_state::{DialogProgress, error_row, push_steps_row}; /// Progress of the create-repository flow, so async results can be rendered. pub type CreateRepoState = DialogProgress; @@ -112,6 +113,7 @@ pub fn open(dock_area: WeakEntity, window: &mut Window, cx: &mut App) ) .child(grasp_servers_field(&grasp_state, &relay_input, cx)), ) + .children(push_steps_row(&state.read(cx).push_steps, cx)) .children(error_row(&error, cx)) .child( DialogFooter::new().justify_end().child( @@ -209,9 +211,15 @@ fn create_repository( return; } - state.update(cx, |state, _| state.begin()); - let backend = Backend::global(cx); + let Some(user) = backend.read(cx).current_user() else { + state.update(cx, |state, _| state.fail("Sign in to create a repository")); + return; + }; + + let addr = RepoAddr::new(user, RepoAddr::identifier_from_name(&name)); + state.update(cx, |state, cx| state.begin_push(addr, cx)); + let task = backend.update(cx, |backend, cx| { backend.create_repository(&name, &description, folder, servers, cx) }); diff --git a/docs/TODO.md b/docs/TODO.md index 57f8b40..fd9183b 100644 --- a/docs/TODO.md +++ b/docs/TODO.md @@ -28,7 +28,6 @@ ## Push -- [ ] Refactor the push pipeline onto `SyncState` with step-by-step progress, see [push-refactor.md](./push-refactor.md). - [ ] Add a fast-forward check and a force option (nak refuses non-FF without `--force`), today it relies entirely on grasp CAS. - [ ] Allow NIP-34 maintainers to push, not just the owner. diff --git a/docs/push-refactor.md b/docs/push-refactor.md deleted file mode 100644 index d2c3b29..0000000 --- a/docs/push-refactor.md +++ /dev/null @@ -1,173 +0,0 @@ -# Push refactor - -Move the push pipeline onto `SyncState` with step-by-step progress, so the UI can render a live checklist instead of a single spinner. - -## Problems - -- `push_staged_to_grasps` is a free function with 9 arguments; `refs`/`head` are just threaded through. Both call sites wrap it in another `background_spawn` with ~8 lines of cloning (`backend.rs` `push_repo_from`, `announce_repository_and_push`). -- The state event is re-signed per relay inside the loop. -- There is no progress surface: the whole flow is one opaque `Task>`. The UI only has the `pushing` flag and the post-hoc `partial_warning`. -- `RepoState` lives in `signed_core`, but its only consumers are in `signed_state`. `signed_git::RepoRefState` is structurally identical (`refs` + `head`). - -## Constraints - -- Keep it simple; no edge-case handling, no over-engineering. -- No new struct wrapping the Nostr client or signer; reference them. -- No new global entity. - -## Target shape - -- `signed_state/src/state.rs` (moved from core, `RepoState` renamed to `SyncState`): the data (`refs`, `head`, push `path`, `mode`), builders (`from_repo`, `with_mode`), event `build`/`parse`, and the whole push flow as methods on `impl SyncState`. -- `signed_state/src/push.rs`: keeps `GraspServer`, `PushOutcome` and the grasp URL helpers. No flow logic. -- `Backend`: thin wiring, emits `BackendEvent::PushProgress` per step. -- `RepoStore`: owns the displayed step list (it already subscribes to `Backend`). -- `RepoDetailView`: renders the checklist. - -## Step 1 — Move and rename - -Mechanical, no behavior change. - -- Move `crates/signed_core/src/state.rs` to `crates/signed_state/src/state.rs`, rename `RepoState` to `SyncState`. Tests move along. -- `signed_core/src/lib.rs`: drop `pub mod state;` and the re-export. -- `signed_state/src/lib.rs`: `mod state;` and `pub use state::SyncState;`. -- Update importers in `signed_state`: `push.rs`, `repo.rs`, `sync_status.rs`. Nothing else uses `signed_core::RepoState`. `Kind::RepoState` (the nostr kind) is untouched. - -## Step 2 — Push flow under `impl SyncState` - -Replace `push_staged_to_grasps` with methods on the type that owns the refs: - -```rust -/// How much to push to the grasp servers. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum GitPushMode { - /// Mirror all branches and tags. - #[default] - All, - /// Push `refs/heads/main` only (repo creation). - Main, -} - -/// A git ref with the commit it points at. -pub struct GitRef { - /// Full ref name, like `refs/heads/main` or `refs/tags/v1.0`. - pub name: String, - /// Commit id, `bitcoin_hashes::sha1::Hash`, the type NIP-34 tags carry. - pub commit: Sha1Hash, -} - -pub struct SyncState { - pub refs: Vec, - pub head: Option, - /// Local repository to push from; `None` for states parsed from events. - pub path: Option, - pub mode: GitPushMode, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum PushStep { - AnnounceRepository, - StageStateEvent(RelayUrl), // publish state event to one grasp server - PushObjects(RelayUrl), // git push to one grasp server - BroadcastStateEvent, // fan the state event out to relays -} - -#[derive(Debug, Clone)] -pub enum PushStepStatus { - Running(PushStep), - Done(PushStep), - Failed(PushStep, String), -} -``` - -```rust -impl SyncState { - /// Reads the refs and head of a local repository, ready to push. - pub fn from_repo(path: &Path) -> Result { - let repo = Repo::open(path)?; - let state = repo.ref_state()?; - Ok(Self { - refs: state.refs, - head: state.head, - path: Some(path.to_path_buf()), - mode: GitPushMode::default(), - }) - } - - pub fn with_mode(mut self, mode: GitPushMode) -> Self { - self.mode = mode; - self - } - - /// State event for these refs; the caller signs it. - pub fn state_event(&self, repo_id: &str) -> EventBuilder { - Self::build(repo_id, &self.refs, self.head.as_deref()) - } - - // Per server: connect + send the pre-signed state event, then git push - // via cx.background_spawn (blocking gix work). Reports every transition - // through on_step. Ends with the relay fan-out broadcast. Errors once - // up front when self.path is None (state not built from a local repo). - pub async fn push_to_grasps

( - &self, - cx: &mut AsyncApp, - client: &Client, - event: Event, - announcement: &Announcement, - on_step: &mut P, - ) -> Result - where - P: FnMut(PushStepStatus, &mut AsyncApp) -> Result<(), Error>, - { ... } - - // Announces the pre-signed announcement (AnnounceRepository step), then - // delegates to push_to_grasps with the pre-signed state event. No signer: - // on failure the call site retracts the announcement by id. - pub async fn announce_and_push

(...) -> Result { ... } -} -``` - -### Why these arguments - -The struct carries the job description — what (`refs`, `head`), from where (`path`), how much (`mode`) — built with `SyncState::from_repo(path)?.with_mode(..)`. The method arguments are then only what varies per call or cannot live on the data: `cx`, `client`, the pre-signed `event`, `announcement`, `on_step`. Down from the 9-arg free function: - -- `refs`, `head`, `path`, `mode` come from `&self`. The `push: fn(&Path, &str, &str, &str) -> Result<(), Error>` callback becomes the `mode` field, resolved inside to `Repo::push_main` / `Repo::push_all`; the wildcard-refspec mirror semantics of `push_all` are preserved. States built by `SyncState::parse` (remote events) have no local repo, so `path` is `Option` and `push_to_grasps` errors once up front when it is `None`. -- `repo_id`, `owner`, `servers` collapse into `&Announcement` — its `id`, `owner` and `relays` are exactly those values, and both call sites already hold one. `push_repo_from` currently destructures the announcement into those three fields just to re-pass them. It stays an argument because states parsed from events carry no announcement. -- The state event is signed by the caller via `state_event(..).finalize_async(&signer)`, so `push_to_grasps` takes a signed `Event` and needs no signer or `S` generic — it only ever sends events. The signer lives entirely with the callers: `announce_and_push` takes the pre-signed announcement and state events, so a failing call site can retract the announcement by id. -- `client` stays as a reference per the constraints; `SyncState` remains plain data, so `SyncState::parse` keeps working for remote states that have no client or path. -- `on_step` takes `&mut AsyncApp` so it can update an entity directly. That is what lets the whole flow run in the caller's foreground `cx.spawn` task and emit progress without a channel. - -### Progress mechanics - -- Per-relay git push is backgrounded inside the method (`cx.background_spawn`), so the method itself runs in the caller's foreground task. Foreground nostr awaits are already precedent (`connect_grasp_relays`, `set_signer`). -- `on_step` returns `Result` so a dead entity propagates instead of being swallowed. -- The caller signs the state event up front instead of per relay; a signing failure aborts before any network work. -- The final fan-out broadcast moves into `push_to_grasps`; both existing flows did exactly push, bail if zero accepted, then broadcast. - -## Step 3 — Backend wiring - -- Add `BackendEvent::PushProgress { addr: RepoAddr, status: PushStepStatus }`. -- `push_repo_from`: background-spawn `SyncState::from_repo(&path)`, sign `state.state_event(&announcement.id)` with the signer, then call `push_to_grasps(cx, &client, event, &announcement, on_step)` in the same foreground task, `on_step` emitting the event via `this.update`. The nested `background_spawn` and its clone block disappear. Keep the `refs.is_empty()` early return at the call site. -- `announce_repository_and_push` is deleted from `backend.rs`; its callers build a `SyncState` first (`SyncState::from_repo` for publish; repo creation builds the single-ref state literally with `GitPushMode::Main`), sign the announcement and state events, then call `announce_and_push(client, announcement, state_event, cx, on_step)`. When it fails, the call site retracts via `SyncState::retract_announcement` — retraction is the very end of the flow, not inside the method. -- `push.rs` keeps `GraspServer`, `PushOutcome` and the URL helpers. - -## Step 4 — UI checklist - -- `RepoStore`: add `push_steps: Vec`; clear it in `run_push` start/end; handle `BackendEvent::PushProgress` in the existing `subscribe_backend` (match on `addr`, replace by `PushStep` equality, `cx.notify()` — no refresh). -- `RepoDetailView`: while `pushing`, render the checklist where the spinner is today (header menu / push banner): - - ``` - Announce repository ✓ - Publish state event → wss://relay.damus.io ✓ - Push objects → git.damus.io ⟳ - Push objects → git.ngit.org · - ``` - -- Labels come from `PushStep::label()` so user-facing copy lives in one place. -- The publish dialog (`init_dialog.rs`) can opt in the same way by constructing the `RepoAddr` up front; optional follow-up. - -Each step compiles green independently: Step 2 adds the methods while the old function still exists, Step 3 switches call sites and deletes it. - -## Validation - -- `cargo check -p signed_state` and `cargo test -p signed_state` after Steps 1 and 2 (the state round-trip tests move with the file). -- Manual: push a repo with two grasp servers where one is unreachable, and confirm the checklist shows running/✓/✗ per server and the existing `partial_warning` still appears.