feat: multi-steps push progress (#30)
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s

Reviewed-on: #30
This commit was merged in pull request #30.
This commit is contained in:
2026-10-14 02:08:20 +00:00
parent b78418d815
commit e72e04c471
12 changed files with 753 additions and 412 deletions
+83 -2
View File
@@ -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<SharedString>,
// The repository whose push checklist is shown; `None` outside a publish.
push_addr: Option<RepoAddr>,
pub push_steps: Vec<PushStepStatus>,
_push_subscription: Option<Subscription>,
}
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>) {
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<SharedString>) {
self.busy = false;
@@ -33,3 +71,46 @@ pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
.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<AnyElement> {
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::<Vec<_>>();
Some(v_flex().gap_1().children(rows).into_any_element())
}
+11 -3
View File
@@ -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(),
+58 -1
View File
@@ -26,7 +26,8 @@ use signed_core::{Announcement, RepoAddr, RepoStatus};
use signed_git::{FileCommit, GitCache, RefSync, Repo};
use signed_state::{
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
Nip34Binding, Nip34Kind, ProfileStore, RepoListStore, RepoStore, SyncStatusStore,
Nip34Binding, Nip34Kind, ProfileStore, PushStepStatus, RepoListStore, RepoStore,
SyncStatusStore,
};
use signed_ui::{
Avatar, CountBadge, DropdownButton, PixelAvatar, copy_row, menu_copy_row, ref_selector_trigger,
@@ -1747,6 +1748,59 @@ impl RepoDetailView {
)
}
pub(super) fn render_push_progress_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx);
if !store.pushing || store.push_steps.is_empty() {
return None;
}
let steps = store
.push_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::<Vec<_>>();
Some(
v_flex()
.p_4()
.gap_1()
.w_full()
.bg(cx.theme().muted)
.child(div().text_sm().child("Pushing"))
.children(steps)
.into_any_element(),
)
}
pub(super) fn render_push_warning_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
let store = self.store.read(cx);
let warning = store.last_push_warning.clone()?;
@@ -2189,6 +2243,9 @@ impl Render for RepoDetailView {
.id("repo")
.size_full()
.when_some(banner, |this, banner| this.child(banner))
.when_some(self.render_push_progress_banner(cx), |this, banner| {
this.child(banner)
})
.when_some(self.render_push_warning_banner(cx), |this, banner| {
this.child(banner)
})
@@ -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<DockArea>, 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)
});