feat: push checkout #14
@@ -105,6 +105,7 @@ impl SignedDockSkin {
|
||||
}
|
||||
|
||||
/// Payload a dock's resize handle drags.
|
||||
///
|
||||
/// It draws nothing, the handle element is the visible affordance.
|
||||
#[derive(Clone)]
|
||||
struct ResizePanel;
|
||||
|
||||
@@ -94,13 +94,6 @@ pub fn comments_for(roots: impl IntoIterator<Item = EventId>) -> Vec<Filter> {
|
||||
]
|
||||
}
|
||||
|
||||
/// All repositories announced by an author.
|
||||
pub fn announcements_by(public_key: PublicKey) -> Filter {
|
||||
Filter::new()
|
||||
.kind(Kind::GitRepoAnnouncement)
|
||||
.author(public_key)
|
||||
}
|
||||
|
||||
/// All repository announcements, for global discovery.
|
||||
pub fn all_announcements() -> Filter {
|
||||
Filter::new().kind(Kind::GitRepoAnnouncement)
|
||||
|
||||
@@ -2,7 +2,7 @@ use std::collections::HashSet;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
use crate::{RepoAddr, repo_addr};
|
||||
|
||||
/// Parsed NIP-34 repository announcement, plain data ready for the UI.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
@@ -267,8 +267,8 @@ impl Announcement {
|
||||
}
|
||||
|
||||
/// The repository address of this announcement.
|
||||
pub fn addr(&self) -> crate::RepoAddr {
|
||||
crate::repo_addr(self.owner, self.id.clone())
|
||||
pub fn addr(&self) -> RepoAddr {
|
||||
repo_addr(self.owner, self.id.clone())
|
||||
}
|
||||
|
||||
/// The name of the repository, or a default if none is provided.
|
||||
|
||||
@@ -64,10 +64,17 @@ struct Remembered {
|
||||
}
|
||||
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
///
|
||||
/// Readers (the sidebar rows, the repository panels) observe this store and
|
||||
/// derive what they display from their own snapshots, so publishing needs no
|
||||
/// fine-grained entities: the store notifies when a slice changed and each
|
||||
/// reader re-derives only what it shows.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
///
|
||||
/// Those are the repository detail panels currently open.
|
||||
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
///
|
||||
@@ -77,7 +84,7 @@ pub struct CheckoutsStore {
|
||||
///
|
||||
/// The sidebar rows of the user's own repositories and their detail panels.
|
||||
push_requested: HashSet<RepoAddr>,
|
||||
/// Ready-to-push statuses of the requested own repositories.
|
||||
/// The ready-to-push statuses of the requested own repositories.
|
||||
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
|
||||
/// Last announced head branch per requested repository.
|
||||
///
|
||||
@@ -85,8 +92,8 @@ pub struct CheckoutsStore {
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl CheckoutsStore {
|
||||
@@ -127,8 +134,9 @@ impl CheckoutsStore {
|
||||
this.status_requested.clear();
|
||||
this.push_requested.clear();
|
||||
this.requested_head.clear();
|
||||
this.statuses = HashMap::new();
|
||||
this.push_statuses = HashMap::new();
|
||||
this.statuses.clear();
|
||||
this.push_statuses.clear();
|
||||
cx.notify();
|
||||
this.refresh(cx);
|
||||
}
|
||||
}));
|
||||
@@ -142,8 +150,8 @@ impl CheckoutsStore {
|
||||
push_statuses: HashMap::new(),
|
||||
requested_head: HashMap::new(),
|
||||
refresh: RefreshGate::default(),
|
||||
_subscriptions: subscriptions,
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
@@ -217,7 +225,7 @@ impl CheckoutsStore {
|
||||
/// The ready-to-contribute statuses of `addr`.
|
||||
///
|
||||
/// Empty while none are known or nothing is ahead.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
pub fn ready_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
@@ -228,13 +236,20 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of `addr`.
|
||||
/// Only meaningful for repositories announced by the signed-in user.
|
||||
///
|
||||
/// Empty while none are known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// The number of unpushed commits for a repository.
|
||||
pub fn unpushed(&self, addr: &RepoAddr) -> usize {
|
||||
self.push_statuses
|
||||
.get(addr)
|
||||
.map(|list| list.iter().map(|status| status.ahead as usize).sum())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Re-resolve the associations and the requested statuses.
|
||||
///
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
@@ -362,10 +377,20 @@ impl CheckoutsStore {
|
||||
};
|
||||
|
||||
let again = this.update(cx, |this, cx| {
|
||||
let associations_changed = this.by_repo != associations;
|
||||
let statuses_changed = this.statuses != statuses;
|
||||
let push_statuses_changed = this.push_statuses != push_statuses;
|
||||
|
||||
this.by_repo = associations;
|
||||
this.statuses = statuses;
|
||||
this.push_statuses = push_statuses;
|
||||
cx.notify();
|
||||
|
||||
// Poll cycles and identity re-requests recompute the same maps
|
||||
// over and over. Notify only when something actually changed,
|
||||
// so observers skip the no-op heartbeats.
|
||||
if associations_changed || statuses_changed || push_statuses_changed {
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
this.refresh.finish()
|
||||
})?;
|
||||
@@ -378,6 +403,7 @@ impl CheckoutsStore {
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && this.refresh.idle() {
|
||||
this.refresh.debounce();
|
||||
|
||||
// Open panels get the fast cadence.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
|
||||
@@ -42,7 +42,7 @@ pub fn init(
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(repos_root, cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(scan_paths, cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(CheckoutsStore::new), cx);
|
||||
@@ -57,7 +57,7 @@ pub fn init(cx: &mut App) -> Entity<Backend> {
|
||||
let entity = cx.new(|cx| Backend::new(client, signer, cx));
|
||||
Backend::set_global(entity.clone(), cx);
|
||||
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
|
||||
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
|
||||
RepoListStore::set_global(cx.new(RepoListStore::new), cx);
|
||||
GitStore::set_global(PathBuf::new(), cx);
|
||||
LocalReposStore::set_global(cx.new(|cx| LocalReposStore::new(Vec::new(), cx)), cx);
|
||||
CheckoutsStore::set_global(cx.new(|cx| CheckoutsStore::new(cx)), cx);
|
||||
|
||||
@@ -42,7 +42,7 @@ impl RepoActivityCounts {
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing repository announcements, global discovery or per-author.
|
||||
/// Store listing the discovered repository announcements, newest first.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
@@ -53,7 +53,6 @@ pub struct RepoListStore {
|
||||
///
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
/// Refresh coalescing, see [`RefreshGate`].
|
||||
refresh: RefreshGate,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -61,7 +60,7 @@ pub struct RepoListStore {
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store.
|
||||
/// Retrieve the global repository list store.
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -70,8 +69,8 @@ impl RepoListStore {
|
||||
cx.set_global(GlobalRepoListStore(entity));
|
||||
}
|
||||
|
||||
/// Create a store. If `author` is `None`, all announcements are listed.
|
||||
pub fn new(author: Option<PublicKey>, cx: &mut Context<Self>) -> Self {
|
||||
/// Create the store listing all announcements.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
@@ -87,14 +86,11 @@ impl RepoListStore {
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
let is_repo_state = update.kind == Kind::RepoState;
|
||||
let tracked = is_announcement || is_repo_state;
|
||||
tracked && this.author.is_none_or(|a| a == update.author)
|
||||
is_announcement || is_repo_state
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
let kind_match = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author_match = this.author.is_none_or(|a| a == event.pubkey);
|
||||
let announcement = kind_match && author_match;
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||
|
||||
// Locally published deletions are already in the local database.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
@@ -116,7 +112,6 @@ impl RepoListStore {
|
||||
announcements: Arc::new(Vec::new()),
|
||||
last_activity: Arc::new(HashMap::new()),
|
||||
counts: Arc::new(HashMap::new()),
|
||||
author,
|
||||
refresh: RefreshGate::default(),
|
||||
_subscription: subscription,
|
||||
tasks: Vec::new(),
|
||||
@@ -129,6 +124,15 @@ impl RepoListStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// The announcements of `user`, newest first.
|
||||
pub fn announcements_of(&self, user: &PublicKey) -> Vec<Announcement> {
|
||||
self.announcements
|
||||
.iter()
|
||||
.filter(|a| a.owner == *user)
|
||||
.cloned()
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Track a spawned task, pruning finished tasks first.
|
||||
///
|
||||
/// Keeps the store's task list bounded by the number of in-flight tasks.
|
||||
@@ -140,14 +144,9 @@ impl RepoListStore {
|
||||
/// Negentropy-sync announcements with the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let author = self.author;
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
backend.sync_bootstrap(filters::all_announcements(), cx);
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
});
|
||||
@@ -187,15 +186,11 @@ impl RepoListStore {
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let client = backend.read(cx).client();
|
||||
let author = self.author;
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
let filter = match author {
|
||||
Some(a) => filters::announcements_by(a),
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
|
||||
let filter = filters::all_announcements();
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
|
||||
@@ -184,24 +184,31 @@ pub struct RepoDetailView {
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
/// Subscriptions keeping the selectors' confirm events alive.
|
||||
_subscriptions: Vec<Subscription>,
|
||||
/// Observes the checkouts store.
|
||||
/// Its statuses feed the ready-to-contribute banner of the repository panel.
|
||||
_checkouts_subscription: Subscription,
|
||||
/// `(path, branch)` ready-suggestions dismissed by the user, per panel.
|
||||
banner_dismissed: HashSet<(PathBuf, String)>,
|
||||
/// The announced HEAD the ready statuses were last requested with.
|
||||
/// Whether they were requested at all.
|
||||
/// Re-requested only when the HEAD, the base default, changes.
|
||||
/// E.g. when the store's first refresh lands.
|
||||
/// e.g. when the store's first refresh lands.
|
||||
ready_requested: bool,
|
||||
ready_head: Option<String>,
|
||||
/// The global checkouts store's ready-to-contribute statuses of this
|
||||
/// repository, last seen when they drove a render.
|
||||
///
|
||||
/// The store notifies on any recompute pass; the observer re-renders this
|
||||
/// panel only when these slices changed.
|
||||
ready_statuses: Vec<CheckoutStatus>,
|
||||
/// The global checkouts store's ready-to-push statuses of this repository,
|
||||
/// last seen when they drove a render.
|
||||
push_statuses: Vec<CheckoutStatus>,
|
||||
/// Upstream repository, from this fork's `u` tag, the user asked to open.
|
||||
/// Its announcement is still being fetched.
|
||||
pending_upstream: Option<RepoAddr>,
|
||||
}
|
||||
|
||||
impl RepoDetailView {
|
||||
/// Open a repository announced on NIP-34.
|
||||
/// Open a repository announced.
|
||||
///
|
||||
/// The store connects to the announcement's relays and loads issues, PRs and statuses.
|
||||
pub fn new(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -228,8 +235,6 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Open a local repository discovered by the scan.
|
||||
/// There is no announcement and no nostr store until the user publishes it to NIP-34.
|
||||
/// The header shows an Init button instead of the NIP-34 actions.
|
||||
pub fn new_local(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
local_path: PathBuf,
|
||||
@@ -240,6 +245,7 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// Shared construction.
|
||||
///
|
||||
/// File explorer state, ref selectors and the deferred repository load.
|
||||
fn new_common(
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
@@ -271,7 +277,7 @@ impl RepoDetailView {
|
||||
.searchable(true)
|
||||
});
|
||||
|
||||
let subscriptions = vec![
|
||||
let mut subscriptions = vec![
|
||||
cx.subscribe_in(&branch_select, window, |this, _state, event, window, cx| {
|
||||
// `Change` fires only when the selection actually changed.
|
||||
// Picking the already-selected branch emits nothing.
|
||||
@@ -291,6 +297,17 @@ impl RepoDetailView {
|
||||
}),
|
||||
];
|
||||
|
||||
// The ready-to-contribute and ready-to-push banners are driven by the
|
||||
// global checkouts store. It notifies on every recompute; compare the
|
||||
// statuses of this repository so unrelated updates (the sidebar badges,
|
||||
// other open panels) do not re-render this panel.
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_statuses(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Defer loading the repository until the window is ready.
|
||||
cx.defer_in(window, |this, window, cx| {
|
||||
this.load_repo(window, cx);
|
||||
@@ -328,15 +345,15 @@ impl RepoDetailView {
|
||||
tag_select,
|
||||
switching_ref: false,
|
||||
ref_generation: 0,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
_checkouts_subscription: cx
|
||||
.observe(&CheckoutsStore::global(cx), |_this, _store, cx| cx.notify()),
|
||||
banner_dismissed: HashSet::new(),
|
||||
ready_requested: false,
|
||||
ready_head: None,
|
||||
ready_statuses: Vec::new(),
|
||||
push_statuses: Vec::new(),
|
||||
pending_upstream: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -371,16 +388,20 @@ impl RepoDetailView {
|
||||
})?;
|
||||
Ok(())
|
||||
});
|
||||
|
||||
self.tasks.push(task);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
let Some(initial) = self.initial.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = initial.addr();
|
||||
let clone_urls: Vec<String> = initial.clone.iter().map(ToString::to_string).collect();
|
||||
|
||||
// Captured before the loads start.
|
||||
// A branch/tag switch bumps the generation, discarding the refresh below.
|
||||
let refresh_generation = self.ref_generation;
|
||||
@@ -1414,8 +1435,7 @@ impl RepoDetailView {
|
||||
RepoAction::Delete => this.delete_repository(window, cx),
|
||||
}),
|
||||
)
|
||||
.px_4()
|
||||
.pb_4()
|
||||
.p_4()
|
||||
.w_full()
|
||||
.gap_8()
|
||||
.border_b_1()
|
||||
@@ -1880,6 +1900,26 @@ impl RepoDetailView {
|
||||
});
|
||||
}
|
||||
|
||||
/// The ready-to-push statuses of this repository in the global checkouts
|
||||
/// store changed since they last drove a render.
|
||||
///
|
||||
/// Updates the cached slices. `None` store (a local, not yet published,
|
||||
/// repository) has no statuses.
|
||||
fn refresh_statuses(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let Some(entity) = self.store.clone() else {
|
||||
return false;
|
||||
};
|
||||
let addr = entity.read(cx).addr().clone();
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let ready_statuses = checkouts.ready_statuses_of(&addr);
|
||||
let push_statuses = checkouts.push_statuses_of(&addr);
|
||||
|
||||
let changed = ready_statuses != self.ready_statuses || push_statuses != self.push_statuses;
|
||||
self.ready_statuses = ready_statuses;
|
||||
self.push_statuses = push_statuses;
|
||||
changed
|
||||
}
|
||||
|
||||
/// The first checkout ready for a pull request on this repository.
|
||||
/// Not covered by an open PR of the signed-in user.
|
||||
/// Not dismissed in this panel.
|
||||
@@ -1893,7 +1933,7 @@ impl RepoDetailView {
|
||||
return None;
|
||||
}
|
||||
|
||||
let statuses = CheckoutsStore::global(cx).read(cx).statuses_of(&addr);
|
||||
let statuses = CheckoutsStore::global(cx).read(cx).ready_statuses_of(&addr);
|
||||
|
||||
'status: for status in statuses {
|
||||
if self
|
||||
@@ -1916,15 +1956,19 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// The first checkout of this owned repository with unpushed commits.
|
||||
///
|
||||
/// Not dismissed in this panel.
|
||||
fn push_suggestion(&self, cx: &App) -> Option<CheckoutStatus> {
|
||||
let entity = self.store.as_ref()?;
|
||||
let user = Backend::global(cx).read(cx).current_user()?;
|
||||
|
||||
if !entity.read(cx).is_author(&user) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let addr = entity.read(cx).addr().clone();
|
||||
let statuses = CheckoutsStore::global(cx).read(cx).push_statuses_of(&addr);
|
||||
|
||||
statuses.into_iter().find(|status| {
|
||||
!self
|
||||
.banner_dismissed
|
||||
@@ -1933,52 +1977,60 @@ impl RepoDetailView {
|
||||
}
|
||||
|
||||
/// The ready-to-push banner of an owned repository.
|
||||
///
|
||||
/// A local checkout has unpushed commits, with a Push action and a dismiss control.
|
||||
fn render_push_banner(&self, cx: &Context<Self>) -> Option<AnyElement> {
|
||||
let status = self.push_suggestion(cx)?;
|
||||
let key = (status.path.clone(), status.branch.clone());
|
||||
let path = status.path.clone();
|
||||
|
||||
let commits = if status.ahead == 1 {
|
||||
"1 commit".to_owned()
|
||||
} else {
|
||||
format!("{} commits", status.ahead)
|
||||
};
|
||||
|
||||
let message = SharedString::from(format!(
|
||||
"{} has {} ready to push in {}",
|
||||
status.branch,
|
||||
commits,
|
||||
status.path.display()
|
||||
));
|
||||
let key = (status.path.clone(), status.branch.clone());
|
||||
let view = cx.entity().clone();
|
||||
let path = status.path.clone();
|
||||
let pushing = self.pushing;
|
||||
|
||||
Some(
|
||||
h_flex()
|
||||
.gap_2()
|
||||
.px_4()
|
||||
.pt_1()
|
||||
.gap_2()
|
||||
.w_full()
|
||||
.items_center()
|
||||
.justify_between()
|
||||
.child(div().text_sm().child(message))
|
||||
.child(
|
||||
Alert::info("repo-unpushed", message)
|
||||
.banner()
|
||||
.flex_1()
|
||||
.on_close(move |_event, _window, cx| {
|
||||
view.update(cx, |this, _| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
});
|
||||
}),
|
||||
)
|
||||
.child(
|
||||
Button::new("push-checkout-banner")
|
||||
.small()
|
||||
.icon(CustomIconName::Init)
|
||||
.label("Push")
|
||||
.loading(pushing)
|
||||
.disabled(pushing)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.push_unpushed_checkout(path.clone(), window, cx);
|
||||
})),
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(
|
||||
Button::new("push-checkout-banner")
|
||||
.icon(IconName::ArrowUp)
|
||||
.label("Push")
|
||||
.small()
|
||||
.primary()
|
||||
.loading(self.pushing)
|
||||
.disabled(self.pushing)
|
||||
.on_click(cx.listener(move |this, _event, window, cx| {
|
||||
this.push_unpushed_checkout(path.clone(), window, cx);
|
||||
})),
|
||||
)
|
||||
.child(
|
||||
Button::new("close-repo")
|
||||
.icon(IconName::Close)
|
||||
.small()
|
||||
.ghost()
|
||||
.disabled(self.pushing)
|
||||
.on_click(cx.listener(move |this, _ev, _window, cx| {
|
||||
this.banner_dismissed.insert(key.clone());
|
||||
cx.notify();
|
||||
})),
|
||||
),
|
||||
)
|
||||
.into_any_element(),
|
||||
)
|
||||
@@ -2257,8 +2309,10 @@ impl Render for RepoDetailView {
|
||||
.image_cache(gpui::retain_all("repo"))
|
||||
.id("repo")
|
||||
.size_full()
|
||||
.when_some(banner, |this, banner| {
|
||||
this.child(v_flex().gap_1().py_4().bg(cx.theme().muted).child(banner))
|
||||
})
|
||||
.child(self.render_header(cx))
|
||||
.when_some(banner, |this, banner| this.child(banner))
|
||||
.when_some(self.error.clone(), |this, error| {
|
||||
this.child(
|
||||
Alert::error("repo-error", error)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::ops::Range;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use assets::CustomIconName;
|
||||
@@ -9,7 +10,7 @@ use dock::{
|
||||
};
|
||||
use gpui::prelude::*;
|
||||
use gpui::{
|
||||
AnyElement, App, Context, Div, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render,
|
||||
SharedString, Subscription, WeakEntity, Window, div, img, px, uniform_list,
|
||||
};
|
||||
use gpui_base::Button as BaseButton;
|
||||
@@ -17,8 +18,7 @@ use gpui_component::badge::Badge;
|
||||
use gpui_component::button::{Button, ButtonVariants};
|
||||
use gpui_component::input::InputState;
|
||||
use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex};
|
||||
use nostr::nips::nip01::Coordinate;
|
||||
use signed_core::{Announcement, identifier_from_name};
|
||||
use signed_core::{Announcement, RepoAddr, identifier_from_name};
|
||||
use signed_state::{
|
||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore,
|
||||
};
|
||||
@@ -39,90 +39,172 @@ pub struct SidebarPanel {
|
||||
focus_handle: FocusHandle,
|
||||
dock_area: WeakEntity<DockArea>,
|
||||
explore: Option<WeakEntity<RepoListView>>,
|
||||
logged_in: bool,
|
||||
/// Repositories the current user announced, listed under the All Repositories heading.
|
||||
repos: Option<Entity<RepoListStore>>,
|
||||
/// Observes the current user's repo store so the list re-renders.
|
||||
repos_subscription: Option<Subscription>,
|
||||
/// Banner artwork behind the sign-in screen.
|
||||
/// Artwork for the sign-in screen.
|
||||
banner: SharedString,
|
||||
_subscription: Subscription,
|
||||
/// The signed-in user's announced repositories, newest first.
|
||||
announcements: Arc<Vec<Announcement>>,
|
||||
/// Local repositories found by the scan that are not announced yet.
|
||||
local_repos: Arc<Vec<PathBuf>>,
|
||||
/// A local scan is currently running.
|
||||
scanning: bool,
|
||||
/// Unpushed local commits per announced repository, the row badge counts.
|
||||
unpushed: HashMap<RepoAddr, usize>,
|
||||
_subscriptions: Vec<Subscription>,
|
||||
}
|
||||
|
||||
impl SidebarPanel {
|
||||
pub fn new(dock_area: WeakEntity<DockArea>, cx: &mut Context<Self>) -> Self {
|
||||
let backend = Backend::global(cx);
|
||||
let logged_in = backend.read(cx).current_user().is_some();
|
||||
let repos = RepoListStore::global(cx);
|
||||
let local = LocalReposStore::global(cx);
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
|
||||
let subscription = cx.subscribe(&backend, |this, backend, event, cx| {
|
||||
match event {
|
||||
BackendEvent::SignerChanged => {
|
||||
this.logged_in = backend.read(cx).current_user().is_some();
|
||||
this.refresh_repos(cx);
|
||||
}
|
||||
BackendEvent::SignerRequired => {
|
||||
this.logged_in = false;
|
||||
this.banner = pick_banner();
|
||||
this.repos = None;
|
||||
this.repos_subscription = None;
|
||||
}
|
||||
_ => return,
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||
|
||||
if !signer_changed && !signer_required {
|
||||
return;
|
||||
}
|
||||
cx.notify();
|
||||
});
|
||||
|
||||
let mut panel = Self {
|
||||
if signer_required {
|
||||
this.banner = pick_banner();
|
||||
}
|
||||
|
||||
if this.refresh(cx) || signer_required {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The merged list re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// The local scan re-derives when announcements or the local scan change.
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
if this.refresh(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
// Push statuses are recomputed in the background; only the badge counts change.
|
||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||
if this.refresh_unpushed(cx) {
|
||||
cx.notify();
|
||||
}
|
||||
}));
|
||||
|
||||
let mut this = Self {
|
||||
focus_handle: cx.focus_handle(),
|
||||
dock_area,
|
||||
logged_in,
|
||||
explore: None,
|
||||
repos: None,
|
||||
repos_subscription: None,
|
||||
banner: pick_banner(),
|
||||
_subscription: subscription,
|
||||
announcements: Arc::new(Vec::new()),
|
||||
local_repos: Arc::new(Vec::new()),
|
||||
scanning: false,
|
||||
unpushed: HashMap::new(),
|
||||
_subscriptions: subscriptions,
|
||||
};
|
||||
|
||||
if logged_in {
|
||||
panel.refresh_repos(cx);
|
||||
cx.notify();
|
||||
}
|
||||
// Seed the snapshot right away.
|
||||
// The stores may already hold data from before the panel opened.
|
||||
// The first render must not depend on a later store update.
|
||||
this.refresh(cx);
|
||||
|
||||
panel
|
||||
this
|
||||
}
|
||||
|
||||
/// Recreate the store listing the current user's repositories.
|
||||
/// The sidebar renders only its own derived fields, never the stores
|
||||
/// directly. Because the panel is a cached view, a store update alone does
|
||||
/// not re-render it: the observers notify this panel, which re-runs
|
||||
/// `render` over the fresh snapshot.
|
||||
///
|
||||
/// Watch each repository for unpushed local work.
|
||||
fn refresh_repos(&mut self, cx: &mut Context<Self>) {
|
||||
self.repos_subscription = None;
|
||||
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
/// Returns `true` when a rendered field changed.
|
||||
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let backend = Backend::global(cx);
|
||||
let author = backend.read(cx).current_user();
|
||||
let user = backend.read(cx).current_user();
|
||||
|
||||
// Create a new repo list store for the signed-in user, if they are logged in.
|
||||
self.repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx)));
|
||||
let repo_list = RepoListStore::global(cx);
|
||||
let announcements = user
|
||||
.as_ref()
|
||||
.map(|user| repo_list.read(cx).announcements_of(user))
|
||||
.unwrap_or_default();
|
||||
|
||||
if let Some(store) = self.repos.as_ref() {
|
||||
self.repos_subscription = Some(cx.observe(store, move |_, store, cx| {
|
||||
let addrs: Vec<Coordinate> = store
|
||||
.read(cx)
|
||||
.announcements
|
||||
.iter()
|
||||
.map(|a| a.addr())
|
||||
.collect();
|
||||
// A scanned repository is dropped from the local list
|
||||
// once the user announces it, so it is not listed twice.
|
||||
let local = LocalReposStore::global(cx);
|
||||
let scanning = local.read(cx).scanning;
|
||||
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for addr in addrs {
|
||||
checkouts.request_push_statuses(&addr, cx);
|
||||
}
|
||||
});
|
||||
}));
|
||||
let local_repos = {
|
||||
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
|
||||
local
|
||||
.read(cx)
|
||||
.repos
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let Some(name) = path.file_name() else {
|
||||
return true;
|
||||
};
|
||||
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
||||
})
|
||||
.cloned()
|
||||
.collect()
|
||||
};
|
||||
|
||||
let announcements_changed = *self.announcements != announcements;
|
||||
let local_changed = *self.local_repos != local_repos;
|
||||
let scanning_changed = self.scanning != scanning;
|
||||
|
||||
self.announcements = Arc::new(announcements);
|
||||
self.local_repos = Arc::new(local_repos);
|
||||
self.scanning = scanning;
|
||||
|
||||
if announcements_changed {
|
||||
self.request_push_watches(cx);
|
||||
self.unpushed.clear();
|
||||
}
|
||||
|
||||
announcements_changed || local_changed || scanning_changed
|
||||
}
|
||||
|
||||
/// Recompute the badge counts from the global checkouts store's ready-to-push statuses
|
||||
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||
let checkouts = CheckoutsStore::global(cx).read(cx);
|
||||
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||
|
||||
for announcement in self.announcements.iter() {
|
||||
let addr = announcement.addr();
|
||||
let count = checkouts.unpushed(&addr);
|
||||
if count > 0 {
|
||||
unpushed.insert(addr, count);
|
||||
}
|
||||
}
|
||||
|
||||
if unpushed == self.unpushed {
|
||||
return false;
|
||||
}
|
||||
|
||||
self.unpushed = unpushed;
|
||||
true
|
||||
}
|
||||
|
||||
/// Keep the `ready to push` statuses of the announced repositories current.
|
||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||
let checkouts = CheckoutsStore::global(cx);
|
||||
checkouts.update(cx, |checkouts, cx| {
|
||||
for announcement in self.announcements.iter() {
|
||||
checkouts.request_push_statuses(&announcement.addr(), cx);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Open the Explore repository list panel in the dock area's center.
|
||||
/// No-op if it is already open.
|
||||
pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||
if self
|
||||
.explore
|
||||
@@ -189,10 +271,9 @@ impl SidebarPanel {
|
||||
}
|
||||
|
||||
fn render_repos(&self, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let store = self.repos.as_ref();
|
||||
let local = LocalReposStore::global(cx);
|
||||
let local_repos = local.read(cx).repos.clone();
|
||||
let scanning = local.read(cx).scanning;
|
||||
let announcements = self.announcements.clone();
|
||||
let local_repos = self.local_repos.clone();
|
||||
let scanning = self.scanning;
|
||||
|
||||
v_flex()
|
||||
.px_2()
|
||||
@@ -239,27 +320,12 @@ impl SidebarPanel {
|
||||
),
|
||||
),
|
||||
)
|
||||
.when_some(store, |builder, store| {
|
||||
let announcements = store.read(cx).announcements.clone();
|
||||
let ids: HashSet<String> = announcements.iter().map(|a| a.id.clone()).collect();
|
||||
|
||||
let local_repos: Vec<PathBuf> = local_repos
|
||||
.iter()
|
||||
.filter(|path| {
|
||||
let Some(name) = path.file_name() else {
|
||||
return true;
|
||||
};
|
||||
!ids.contains(&identifier_from_name(&name.to_string_lossy()))
|
||||
})
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
// One merged list, the user's NIP-34 repositories first.
|
||||
// Local repositories discovered by the scan follow.
|
||||
.map(|this| {
|
||||
// Merged list, the user's NIP-34 repositories and local repositories discovered.
|
||||
let total = announcements.len() + local_repos.len();
|
||||
|
||||
if total == 0 {
|
||||
builder.child(
|
||||
this.child(
|
||||
div()
|
||||
.flex_1()
|
||||
.px_2()
|
||||
@@ -275,7 +341,7 @@ impl SidebarPanel {
|
||||
}),
|
||||
)
|
||||
} else {
|
||||
builder.child(
|
||||
this.child(
|
||||
uniform_list(
|
||||
"repos",
|
||||
total,
|
||||
@@ -325,14 +391,11 @@ impl SidebarPanel {
|
||||
let announcement = announcement.clone();
|
||||
|
||||
// Badge with the unpushed commit count of the repository's local checkouts.
|
||||
// The commits are ready to push to the grasp servers.
|
||||
let checkout = CheckoutsStore::global(cx);
|
||||
let unpushed = checkout
|
||||
.read(cx)
|
||||
.push_statuses_of(&announcement.addr())
|
||||
.iter()
|
||||
.map(|status| status.ahead as usize)
|
||||
.sum();
|
||||
let unpushed = self
|
||||
.unpushed
|
||||
.get(&announcement.addr())
|
||||
.copied()
|
||||
.unwrap_or(0);
|
||||
|
||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||
|
||||
@@ -352,7 +415,7 @@ impl SidebarPanel {
|
||||
let name = path
|
||||
.file_name()
|
||||
.map(|name| name.to_string_lossy().into_owned())
|
||||
.unwrap_or_else(|| path.display().to_string());
|
||||
.unwrap_or("Untitled".into());
|
||||
let path = path.to_path_buf();
|
||||
let avatar = PixelAvatar::new(path.to_string_lossy());
|
||||
|
||||
@@ -508,10 +571,6 @@ impl Focusable for SidebarPanel {
|
||||
|
||||
impl Render for SidebarPanel {
|
||||
fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
if !self.logged_in {
|
||||
return self.render_sign_in(window, cx);
|
||||
}
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
|
||||
@@ -520,6 +579,10 @@ impl Render for SidebarPanel {
|
||||
.current_user()
|
||||
.map(|public_key| profile_store.read(cx).get(&public_key));
|
||||
|
||||
if profile.is_none() {
|
||||
return self.render_sign_in(window, cx);
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.size_full()
|
||||
.justify_between()
|
||||
|
||||
Reference in New Issue
Block a user