chore: bind sync status to per-view directories (#32)
Reviewed-on: #32
This commit was merged in pull request #32.
This commit is contained in:
@@ -29,7 +29,7 @@ pub use repos::RepoListStore;
|
|||||||
pub use signed_git::{Nip34Binding, Nip34Kind};
|
pub use signed_git::{Nip34Binding, Nip34Kind};
|
||||||
use signed_nostr::NostrBackend;
|
use signed_nostr::NostrBackend;
|
||||||
pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState};
|
pub use state::{GitPushMode, GitRef, PushStep, PushStepStatus, SyncState};
|
||||||
pub use sync_status::{CheckoutSyncStatus, SyncStatusStore};
|
pub use sync_status::{CheckoutSyncStatus, SyncEvent, SyncStatusDirectory, SyncStatusStore};
|
||||||
|
|
||||||
pub fn init(
|
pub fn init(
|
||||||
db_path: impl AsRef<Path>,
|
db_path: impl AsRef<Path>,
|
||||||
|
|||||||
@@ -1226,6 +1226,7 @@ impl RepoStore {
|
|||||||
Ok(outcome) => {
|
Ok(outcome) => {
|
||||||
this.last_error = None;
|
this.last_error = None;
|
||||||
this.last_push_warning = outcome.partial_warning();
|
this.last_push_warning = outcome.partial_warning();
|
||||||
|
|
||||||
if let Some((addr, path)) = &pushed_checkout {
|
if let Some((addr, path)) = &pushed_checkout {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
checkouts.update(cx, |store, cx| {
|
checkouts.update(cx, |store, cx| {
|
||||||
@@ -1233,8 +1234,13 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Re-probe so sync badges reflect the pushed state.
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
let sync_status = SyncStatusStore::global(cx);
|
||||||
sync_status.update(cx, |store, cx| store.refresh(cx));
|
let addr = pushed_checkout
|
||||||
|
.as_ref()
|
||||||
|
.map(|(addr, _)| addr.clone())
|
||||||
|
.or_else(|| this.addr.clone());
|
||||||
|
sync_status.read(cx).probe(addr);
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
this.last_error = Some(format!("Push failed: {e}"));
|
this.last_error = Some(format!("Push failed: {e}"));
|
||||||
|
|||||||
@@ -1,18 +1,17 @@
|
|||||||
use std::collections::HashMap;
|
use std::cell::RefCell;
|
||||||
|
use std::collections::{HashMap, HashSet};
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
|
use flume::Sender;
|
||||||
use nostr_sdk::prelude::*;
|
use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task};
|
||||||
use signed_core::{Deletions, Filters, RepoAddr};
|
use signed_core::{Deletions, Filters, RepoAddr};
|
||||||
use signed_git::{Repo, RepoSyncStatus};
|
use signed_git::{Repo, RepoSyncStatus};
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent};
|
use crate::backend::{Backend, BackendEvent};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::refresh::{RefreshGate, RefreshRequest};
|
use crate::state::SyncState;
|
||||||
use crate::repos::RepoListStore;
|
|
||||||
use crate::state::{GitRef, SyncState};
|
|
||||||
|
|
||||||
const SYNC_POLL: Duration = Duration::from_secs(180);
|
const SYNC_POLL: Duration = Duration::from_secs(180);
|
||||||
const MAX_SYNC_CHECKOUTS: usize = 8;
|
const MAX_SYNC_CHECKOUTS: usize = 8;
|
||||||
@@ -28,13 +27,25 @@ pub struct CheckoutSyncStatus {
|
|||||||
pub status: RepoSyncStatus,
|
pub status: RepoSyncStatus,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Statuses for these repositories may have changed; empty on signer change.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SyncEvent {
|
||||||
|
Updated(Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes tracked repositories and broadcasts results. Holds no statuses.
|
||||||
pub struct SyncStatusStore {
|
pub struct SyncStatusStore {
|
||||||
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
active: RefCell<HashSet<RepoAddr>>,
|
||||||
refresh: RefreshGate,
|
in_flight: bool,
|
||||||
|
pass_pending: bool,
|
||||||
poll_pending: bool,
|
poll_pending: bool,
|
||||||
|
sender: Sender<()>,
|
||||||
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl EventEmitter<SyncEvent> for SyncStatusStore {}
|
||||||
|
|
||||||
impl SyncStatusStore {
|
impl SyncStatusStore {
|
||||||
pub fn global(cx: &App) -> Entity<Self> {
|
pub fn global(cx: &App) -> Entity<Self> {
|
||||||
cx.global::<GlobalSyncStatusStore>().0.clone()
|
cx.global::<GlobalSyncStatusStore>().0.clone()
|
||||||
@@ -45,150 +56,131 @@ impl SyncStatusStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||||
|
let (sender, receiver) = flume::unbounded::<()>();
|
||||||
let mut subscriptions = Vec::new();
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
if !cfg!(target_arch = "wasm32") {
|
if !cfg!(target_arch = "wasm32") {
|
||||||
let repos = RepoListStore::global(cx);
|
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
|
||||||
this.refresh(cx);
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Checkout associations decide which paths get compared.
|
// Checkout associations decide which paths get compared.
|
||||||
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||||
this.refresh(cx);
|
this.run_pass(cx);
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
BackendEvent::SignerChanged => {
|
BackendEvent::SignerChanged => this.clear(cx),
|
||||||
this.statuses.clear();
|
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.run_pass(cx),
|
||||||
cx.notify();
|
|
||||||
this.refresh(cx);
|
|
||||||
}
|
|
||||||
BackendEvent::RepoUpdates(_) | BackendEvent::Synced => this.refresh(cx),
|
|
||||||
_ => {}
|
_ => {}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
let weak = cx.entity().downgrade();
|
|
||||||
cx.defer(move |cx| {
|
|
||||||
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
|
|
||||||
log::warn!("sync status store dropped before the initial refresh: {error}");
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let task = cx.spawn(async move |this, cx| {
|
||||||
|
loop {
|
||||||
|
receiver.recv_async().await?;
|
||||||
|
|
||||||
|
// One pass covers every signal queued since the wake.
|
||||||
|
while receiver.try_recv().is_ok() {}
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.run_pass(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
statuses: HashMap::new(),
|
active: RefCell::new(HashSet::new()),
|
||||||
refresh: RefreshGate::default(),
|
sender,
|
||||||
|
in_flight: false,
|
||||||
|
pass_pending: false,
|
||||||
poll_pending: false,
|
poll_pending: false,
|
||||||
|
tasks: vec![task],
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Latest sync status per associated checkout.
|
/// Queue a probe pass; folded into any pass already in flight.
|
||||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutSyncStatus> {
|
pub fn probe(&self, addrs: impl IntoIterator<Item = RepoAddr>) {
|
||||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
self.active.borrow_mut().extend(addrs);
|
||||||
|
|
||||||
|
if let Err(e) = self.sender.send(()) {
|
||||||
|
log::warn!("failed to queue sync status probe: {e}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Commits available locally but missing on Nostr.
|
// Keeps the in-flight pass so its completion releases the slot exactly once.
|
||||||
pub fn unsynced(&self, addr: &RepoAddr) -> usize {
|
fn clear(&mut self, cx: &mut Context<Self>) {
|
||||||
self.statuses
|
let had_addrs = !self.active.borrow().is_empty();
|
||||||
.get(addr)
|
self.active.borrow_mut().clear();
|
||||||
.map(|list| {
|
self.pass_pending = false;
|
||||||
list.iter()
|
|
||||||
.map(|checkout| checkout.status.ahead_total)
|
if had_addrs {
|
||||||
.sum()
|
cx.emit(SyncEvent::Updated(Vec::new()));
|
||||||
})
|
}
|
||||||
.unwrap_or(0)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if cfg!(target_arch = "wasm32") {
|
if cfg!(target_arch = "wasm32") {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if self.refresh.request() != RefreshRequest::Schedule {
|
|
||||||
|
if self.in_flight {
|
||||||
|
self.pass_pending = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
|
||||||
let Some(user) = backend.read(cx).current_user() else {
|
|
||||||
let changed = !self.statuses.is_empty();
|
|
||||||
self.statuses.clear();
|
|
||||||
if changed {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
};
|
|
||||||
|
|
||||||
self.schedule_poll(cx);
|
|
||||||
self.run_refresh(user, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
|
|
||||||
if self.poll_pending {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
self.poll_pending = true;
|
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
|
||||||
cx.background_executor().timer(SYNC_POLL).await;
|
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.poll_pending = false;
|
|
||||||
this.refresh(cx);
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
|
|
||||||
fn run_refresh(&mut self, user: PublicKey, cx: &mut Context<Self>) {
|
|
||||||
self.refresh.begin();
|
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let repos = RepoListStore::global(cx);
|
if backend.read(cx).current_user().is_none() {
|
||||||
let announcements = repos.read(cx).announcements_of(&user);
|
self.clear(cx);
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = announcements
|
if self.active.borrow().is_empty() {
|
||||||
.iter()
|
return;
|
||||||
.map(|announcement| {
|
}
|
||||||
let addr = announcement.addr();
|
|
||||||
let paths = checkouts.read(cx).associations_of(&addr);
|
self.in_flight = true;
|
||||||
(addr, paths)
|
self.schedule_poll(cx);
|
||||||
})
|
|
||||||
.collect();
|
let targets: Vec<(RepoAddr, Vec<PathBuf>)> = {
|
||||||
|
let active = self.active.borrow();
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
|
||||||
|
active
|
||||||
|
.iter()
|
||||||
|
.map(|addr| {
|
||||||
|
let paths = checkouts.read(cx).associations_of(addr);
|
||||||
|
(addr.clone(), paths)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
};
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let db = client.database();
|
let db = client.database();
|
||||||
let deletion_events = db.query(Filters::deletions()).await?;
|
let deletion_events = db.query(Filters::deletions()).await?;
|
||||||
let deletions = Deletions::from_events(deletion_events);
|
let deletions = Deletions::from_events(deletion_events);
|
||||||
|
|
||||||
let mut states: HashMap<RepoAddr, Vec<GitRef>> = HashMap::new();
|
let mut updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)> = Vec::new();
|
||||||
for (addr, _) in &targets {
|
|
||||||
|
for (addr, paths) in &targets {
|
||||||
let events = db.query(addr.state_filter()).await?;
|
let events = db.query(addr.state_filter()).await?;
|
||||||
let latest = utils::latest(
|
let latest = utils::latest(
|
||||||
events
|
events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|event| !deletions.is_deleted(event)),
|
.filter(|event| !deletions.is_deleted(event)),
|
||||||
);
|
);
|
||||||
|
|
||||||
let Some(event) = latest else {
|
let Some(event) = latest else {
|
||||||
continue;
|
updates.push((addr.clone(), Vec::new()));
|
||||||
};
|
|
||||||
states.insert(addr.clone(), SyncState::parse(&event).refs);
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>> = HashMap::new();
|
|
||||||
for (addr, paths) in &targets {
|
|
||||||
let Some(nostr_refs) = states.get(addr) else {
|
|
||||||
continue;
|
continue;
|
||||||
};
|
};
|
||||||
|
|
||||||
let remote_refs: Vec<(String, String)> = nostr_refs
|
let remote_refs: Vec<(String, String)> = SyncState::parse(&event)
|
||||||
|
.refs
|
||||||
.iter()
|
.iter()
|
||||||
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
.map(|git_ref| (git_ref.name.clone(), git_ref.commit.to_string()))
|
||||||
.collect();
|
.collect();
|
||||||
@@ -206,41 +198,170 @@ impl SyncStatusStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if !list.is_empty() {
|
updates.push((addr.clone(), list));
|
||||||
statuses.insert(addr.clone(), list);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(statuses)
|
Ok::<_, Error>(updates)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
let statuses = match work.await {
|
// Reset the slot even on failure, or every later pass folds forever.
|
||||||
Ok(statuses) => statuses,
|
let updates = match work.await {
|
||||||
|
Ok(updates) => updates,
|
||||||
Err(error) => {
|
Err(error) => {
|
||||||
log::warn!("failed to compute nostr sync statuses: {error}");
|
log::warn!("failed to compute nostr sync statuses: {error}");
|
||||||
return this.update(cx, |this, _cx| {
|
Vec::new()
|
||||||
this.refresh.abort();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let again = this.update(cx, |this, cx| {
|
let mut again = false;
|
||||||
if this.statuses != statuses {
|
|
||||||
this.statuses = statuses;
|
this.update(cx, |this, cx| {
|
||||||
cx.notify();
|
this.in_flight = false;
|
||||||
|
if !updates.is_empty() {
|
||||||
|
cx.emit(SyncEvent::Updated(updates));
|
||||||
}
|
}
|
||||||
this.refresh.finish()
|
again = std::mem::take(&mut this.pass_pending);
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
if again {
|
if again {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.refresh(cx);
|
this.run_pass(cx);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
fn schedule_poll(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.poll_pending {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.poll_pending = true;
|
||||||
|
|
||||||
|
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||||
|
cx.background_executor().timer(SYNC_POLL).await;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.poll_pending = false;
|
||||||
|
this.run_pass(cx);
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok::<(), anyhow::Error>(())
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sync status cache bound to one panel or screen.
|
||||||
|
pub struct SyncStatusDirectory {
|
||||||
|
statuses: HashMap<RepoAddr, Vec<CheckoutSyncStatus>>,
|
||||||
|
pending: RefCell<HashSet<RepoAddr>>,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SyncStatusDirectory {
|
||||||
|
/// Create a directory, following the sync store's events.
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
let store = SyncStatusStore::global(cx);
|
||||||
|
|
||||||
|
cx.new(|cx| {
|
||||||
|
let subscription =
|
||||||
|
cx.subscribe(&store, |this: &mut Self, _store, event, cx| match event {
|
||||||
|
SyncEvent::Updated(updates) => this.apply(updates.clone(), cx),
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
statuses: HashMap::new(),
|
||||||
|
pending: RefCell::new(HashSet::new()),
|
||||||
|
_subscriptions: vec![subscription],
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.detach();
|
}
|
||||||
|
|
||||||
|
/// Queue a probe for addrs this surface renders.
|
||||||
|
pub fn track(&mut self, addrs: impl IntoIterator<Item = RepoAddr>, cx: &mut Context<Self>) {
|
||||||
|
let missing: Vec<RepoAddr> = addrs
|
||||||
|
.into_iter()
|
||||||
|
.filter(|addr| {
|
||||||
|
!self.statuses.contains_key(addr)
|
||||||
|
&& !self.with_pending(|pending| pending.contains(addr))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.with_pending(|pending| pending.extend(missing.iter().cloned()));
|
||||||
|
|
||||||
|
let store = SyncStatusStore::global(cx);
|
||||||
|
store.read(cx).probe(missing);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Statuses for a repo, or empty; a miss queues a probe.
|
||||||
|
pub fn statuses_of(&self, addr: &RepoAddr, cx: &App) -> Vec<CheckoutSyncStatus> {
|
||||||
|
self.queue(addr, cx);
|
||||||
|
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Commits available locally but missing on Nostr.
|
||||||
|
pub fn unsynced(&self, addr: &RepoAddr, cx: &App) -> usize {
|
||||||
|
self.queue(addr, cx);
|
||||||
|
|
||||||
|
self.statuses
|
||||||
|
.get(addr)
|
||||||
|
.map(|list| {
|
||||||
|
list.iter()
|
||||||
|
.map(|checkout| checkout.status.ahead_total)
|
||||||
|
.sum()
|
||||||
|
})
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn queue(&self, addr: &RepoAddr, cx: &App) {
|
||||||
|
if self.with_pending(|pending| pending.insert(addr.clone())) {
|
||||||
|
let store = SyncStatusStore::global(cx);
|
||||||
|
store.read(cx).probe([addr.clone()]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply(&mut self, updates: Vec<(RepoAddr, Vec<CheckoutSyncStatus>)>, cx: &mut Context<Self>) {
|
||||||
|
if updates.is_empty() {
|
||||||
|
let changed = !self.statuses.is_empty();
|
||||||
|
self.statuses.clear();
|
||||||
|
self.with_pending(|pending| pending.clear());
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut changed = false;
|
||||||
|
|
||||||
|
for (addr, list) in updates {
|
||||||
|
let relevant = self.statuses.contains_key(&addr)
|
||||||
|
|| self.with_pending(|pending| pending.remove(&addr));
|
||||||
|
|
||||||
|
if !relevant {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.statuses.get(&addr) != Some(&list) {
|
||||||
|
self.statuses.insert(addr, list);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_pending<R>(&self, then: impl FnOnce(&mut HashSet<RepoAddr>) -> R) -> R {
|
||||||
|
let mut pending = self.pending.borrow_mut();
|
||||||
|
then(&mut pending)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ use signed_core::{Announcement, RepoAddr, RepoStatus};
|
|||||||
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
use signed_git::{FileCommit, GitCache, RefSync, Repo};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors,
|
||||||
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusStore,
|
Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory,
|
||||||
};
|
};
|
||||||
use signed_ui::{
|
use signed_ui::{
|
||||||
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
Avatar, CountBadge, DropdownButton, copy_row, menu_copy_row, ref_selector_trigger,
|
||||||
@@ -96,6 +96,8 @@ pub struct RepoDetailView {
|
|||||||
tasks: Vec<Task<Result<(), Error>>>,
|
tasks: Vec<Task<Result<(), Error>>>,
|
||||||
/// Profiles rendered by the header, maintainers and tab title.
|
/// Profiles rendered by the header, maintainers and tab title.
|
||||||
directory: Entity<ProfileDirectory>,
|
directory: Entity<ProfileDirectory>,
|
||||||
|
/// Sync status feeding the sync-to-Nostr banner.
|
||||||
|
sync_directory: Entity<SyncStatusDirectory>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,7 +158,6 @@ impl RepoDetailView {
|
|||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
|
||||||
let files = cx.new(RepoFilesView::new);
|
let files = cx.new(RepoFilesView::new);
|
||||||
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
|
let history = cx.new(|_cx| RepoHistoryView::new(store.clone(), dock_area.clone()));
|
||||||
let refs = RefSwitcher::new(window, cx);
|
let refs = RefSwitcher::new(window, cx);
|
||||||
@@ -193,11 +194,6 @@ impl RepoDetailView {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// The sync-to-Nostr banner reads the global sync store directly.
|
|
||||||
subscriptions.push(cx.observe(&sync_status, |_this, _sync, cx| {
|
|
||||||
cx.notify();
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Defer loading the repository until the window is ready.
|
// Defer loading the repository until the window is ready.
|
||||||
cx.defer_in(window, |this, window, cx| {
|
cx.defer_in(window, |this, window, cx| {
|
||||||
this.load_repo(window, cx);
|
this.load_repo(window, cx);
|
||||||
@@ -219,6 +215,7 @@ impl RepoDetailView {
|
|||||||
banners: Banners::default(),
|
banners: Banners::default(),
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
directory: ProfileDirectory::new(cx),
|
directory: ProfileDirectory::new(cx),
|
||||||
|
sync_directory: SyncStatusDirectory::new(cx),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -255,6 +252,7 @@ impl RepoDetailView {
|
|||||||
.push(cx.observe_in(store, window, |this, store, window, cx| {
|
.push(cx.observe_in(store, window, |this, store, window, cx| {
|
||||||
this.refresh_ready_statuses(cx);
|
this.refresh_ready_statuses(cx);
|
||||||
this.track_profiles(cx);
|
this.track_profiles(cx);
|
||||||
|
this.track_sync_status(cx);
|
||||||
if !this.repo_started && store.read(cx).announcement.is_some() {
|
if !this.repo_started && store.read(cx).announcement.is_some() {
|
||||||
this.load_repo(window, cx);
|
this.load_repo(window, cx);
|
||||||
}
|
}
|
||||||
@@ -292,6 +290,17 @@ impl RepoDetailView {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Track the repository address so the sync banner stays current.
|
||||||
|
fn track_sync_status(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
self.sync_directory.update(cx, |directory, cx| {
|
||||||
|
directory.track([addr], cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
fn refresh_ready_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
let Some(addr) = self.store.read(cx).addr().cloned() else {
|
||||||
return;
|
return;
|
||||||
@@ -349,6 +358,7 @@ impl RepoDetailView {
|
|||||||
self.loading = true;
|
self.loading = true;
|
||||||
self.error = None;
|
self.error = None;
|
||||||
self.track_profiles(cx);
|
self.track_profiles(cx);
|
||||||
|
self.track_sync_status(cx);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
let (announcement, local_path) = {
|
let (announcement, local_path) = {
|
||||||
@@ -1602,8 +1612,7 @@ impl RepoDetailView {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
let statuses = self.sync_directory.read(cx).statuses_of(addr, cx);
|
||||||
let statuses = sync_status.read(cx).statuses_of(addr);
|
|
||||||
|
|
||||||
statuses.into_iter().find(|checkout| {
|
statuses.into_iter().find(|checkout| {
|
||||||
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
checkout.status.ahead_total > 0 && !self.banners.sync_dismissal(checkout)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ use nostr::prelude::{PublicKey, RelayUrl};
|
|||||||
use signed_core::{Announcement, RepoAddr};
|
use signed_core::{Announcement, RepoAddr};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore,
|
||||||
ResolvedLocalRepo, SyncStatusStore,
|
ResolvedLocalRepo, SyncStatusDirectory,
|
||||||
};
|
};
|
||||||
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
use signed_ui::{Avatar, NavItem, title_bar_drag_handlers};
|
||||||
|
|
||||||
@@ -68,10 +68,10 @@ pub struct SidebarPanel {
|
|||||||
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||||
/// Unpushed commit counts per announced repository.
|
/// Unpushed commit counts per announced repository.
|
||||||
unpushed: HashMap<RepoAddr, usize>,
|
unpushed: HashMap<RepoAddr, usize>,
|
||||||
/// Commits not on Nostr per announced repository.
|
|
||||||
unsynced: HashMap<RepoAddr, usize>,
|
|
||||||
/// Profiles rendered by the sidebar.
|
/// Profiles rendered by the sidebar.
|
||||||
directory: Entity<ProfileDirectory>,
|
directory: Entity<ProfileDirectory>,
|
||||||
|
/// Sync statuses for the announced repositories.
|
||||||
|
sync_directory: Entity<SyncStatusDirectory>,
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +81,6 @@ impl SidebarPanel {
|
|||||||
let repos = RepoListStore::global(cx);
|
let repos = RepoListStore::global(cx);
|
||||||
let local = LocalReposStore::global(cx);
|
let local = LocalReposStore::global(cx);
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
|
||||||
|
|
||||||
let mut subscriptions = Vec::new();
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
@@ -119,12 +118,6 @@ impl SidebarPanel {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
subscriptions.push(cx.observe(&sync_status, |this, _sync, cx| {
|
|
||||||
if this.refresh_unsynced(cx) {
|
|
||||||
cx.notify();
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
dock_area,
|
dock_area,
|
||||||
@@ -137,8 +130,8 @@ impl SidebarPanel {
|
|||||||
local_repos: Arc::new(Vec::new()),
|
local_repos: Arc::new(Vec::new()),
|
||||||
scanning: false,
|
scanning: false,
|
||||||
unpushed: HashMap::new(),
|
unpushed: HashMap::new(),
|
||||||
unsynced: HashMap::new(),
|
|
||||||
directory: ProfileDirectory::new(cx),
|
directory: ProfileDirectory::new(cx),
|
||||||
|
sync_directory: SyncStatusDirectory::new(cx),
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -189,10 +182,19 @@ impl SidebarPanel {
|
|||||||
directory.track(profiles, cx);
|
directory.track(profiles, cx);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let addrs: Vec<RepoAddr> = self
|
||||||
|
.announcements
|
||||||
|
.iter()
|
||||||
|
.map(|announcement| announcement.addr())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.sync_directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(addrs, cx);
|
||||||
|
});
|
||||||
|
|
||||||
if announcements_changed {
|
if announcements_changed {
|
||||||
self.request_push_watches(cx);
|
self.request_push_watches(cx);
|
||||||
self.unpushed.clear();
|
self.unpushed.clear();
|
||||||
self.unsynced.clear();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
announcements_changed || local_changed || scanning_changed
|
announcements_changed || local_changed || scanning_changed
|
||||||
@@ -218,26 +220,6 @@ impl SidebarPanel {
|
|||||||
true
|
true
|
||||||
}
|
}
|
||||||
|
|
||||||
fn refresh_unsynced(&mut self, cx: &mut Context<Self>) -> bool {
|
|
||||||
let sync_status = SyncStatusStore::global(cx);
|
|
||||||
let mut unsynced = HashMap::with_capacity(self.announcements.len());
|
|
||||||
|
|
||||||
for announcement in self.announcements.iter() {
|
|
||||||
let addr = announcement.addr();
|
|
||||||
let count = sync_status.read(cx).unsynced(&addr);
|
|
||||||
if count > 0 {
|
|
||||||
unsynced.insert(addr, count);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if unsynced == self.unsynced {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
self.unsynced = unsynced;
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||||
let checkouts = CheckoutsStore::global(cx);
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
checkouts.update(cx, |checkouts, cx| {
|
checkouts.update(cx, |checkouts, cx| {
|
||||||
@@ -538,7 +520,7 @@ impl SidebarPanel {
|
|||||||
|
|
||||||
let addr = announcement.addr();
|
let addr = announcement.addr();
|
||||||
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
let unpushed = self.unpushed.get(&addr).copied().unwrap_or(0);
|
||||||
let unsynced = self.unsynced.get(&addr).copied().unwrap_or(0);
|
let unsynced = self.sync_directory.read(cx).unsynced(&addr, cx);
|
||||||
|
|
||||||
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user