feat: push checkout (#14)

Reviewed-on: https://git.reya.su/reya/signed/pulls/14
This commit was merged in pull request #14.
This commit is contained in:
2026-09-06 13:14:11 +00:00
parent 33cbe42551
commit 00167c6a8d
85 changed files with 6282 additions and 4487 deletions
+4
View File
@@ -8,6 +8,7 @@ publish.workspace = true
signed_core = { path = "../signed_core" }
signed_git = { path = "../signed_git" }
signed_nostr = { path = "../signed_nostr" }
settings = { path = "../settings" }
utils = { path = "../utils" }
nostr.workspace = true
@@ -24,3 +25,6 @@ log.workspace = true
[target.'cfg(not(target_arch = "wasm32"))'.dependencies]
rustls = "0.23"
[dev-dependencies]
tempfile = "3"
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -10
View File
@@ -7,17 +7,12 @@ struct GlobalGitStore(GitCache);
impl Global for GlobalGitStore {}
/// Global access to the on-disk git clone cache (grasp mirrors).
///
/// Installed at startup via [`GitStore::set_global`]; see also
/// [`signed_state::init`].
/// Global access to the on-disk git clone cache, the grasp mirrors.
#[derive(Debug, Clone)]
pub struct GitStore(GitCache);
impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global.
/// Replaces any previously installed store (see [`signed_state::init`], which
/// installs an empty one).
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
@@ -25,10 +20,6 @@ impl GitStore {
}
/// The app-wide clone cache.
///
/// # Panics
///
/// Panics if [`GitStore::set_global`] was never called.
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
+17 -19
View File
@@ -1,13 +1,16 @@
mod backend;
mod checkouts;
mod git_store;
mod local_repos;
mod profile;
mod refresh;
mod repo;
mod repo_list;
use std::path::{Path, PathBuf};
pub use backend::{Backend, BackendEvent};
pub use backend::{Backend, BackendEvent, user_grasp_list_servers};
pub use checkouts::{CheckoutStatus, CheckoutsStore, pr_proposes_checkout};
pub use git_store::GitStore;
use gpui::{App, AppContext, Entity};
pub use local_repos::LocalReposStore;
@@ -16,16 +19,18 @@ pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore;
pub use repo_list::{RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
pub use utils::shorten_pubkey;
/// Initialize the backend and stores, and install them as globals.
/// Call once at startup, before opening any window that uses the stores.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default; ignore if already installed.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
.ok();
pub fn init(
db_path: impl AsRef<Path>,
repos_root: impl Into<PathBuf>,
scan_paths: Vec<PathBuf>,
cx: &mut App,
) -> Entity<Backend> {
// rustls uses the `aws_lc_rs` provider by default.
let _ = rustls::crypto::aws_lc_rs::default_provider().install_default();
let (client, signer) = cx.foreground_executor().block_on(async move {
let path = db_path.as_ref().to_path_buf();
@@ -37,16 +42,10 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
let entity = cx.new(|cx| Backend::new(client, signer, cx));
Backend::set_global(entity.clone(), cx);
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
// Seed the explore list from the local database; relay syncs continue
// in the background.
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
// The clone cache is native-only; wasm registers an empty store so
// `GitStore::global` still works.
GitStore::set_global(PathBuf::new(), 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);
entity
}
@@ -55,13 +54,12 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
#[cfg(target_arch = "wasm32")]
pub fn init(cx: &mut App) -> Entity<Backend> {
let (client, signer) = new_backend().expect("failed to initialize nostr 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);
entity
}
+2 -5
View File
@@ -45,9 +45,7 @@ impl LocalReposStore {
store
}
/// Forget a repository that has just been published to NIP-34,
/// so it leaves the local list immediately. A later rescan re-discovers it from disk,
/// the sidebar additionally hides published repositories by identifier.
/// Forget a repository that has just been published to NIP-34.
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
self.repos = Arc::new(
self.repos
@@ -95,8 +93,7 @@ impl LocalReposStore {
dirty
})?;
// Scans requested while this one was running are coalesced into
// a single follow-up scan.
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
+64 -50
View File
@@ -3,14 +3,17 @@ use std::collections::{HashMap, HashSet};
use std::time::{Duration, Instant};
use anyhow::Error;
use flume::{Receiver, RecvTimeoutError, Sender};
use gpui::{App, AppContext, Context, Entity, Global, SharedString, Subscription, Task};
use flume::{Receiver, Sender};
use gpui::{
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
WeakEntity,
};
use nostr_sdk::prelude::*;
use utils::shorten_pubkey;
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
/// A user profile (kind `0` metadata), as plain data for the UI.
/// A user profile as plain data for the UI, from the kind-0 metadata.
#[derive(Debug, Clone)]
pub struct Profile {
public_key: PublicKey,
@@ -60,20 +63,15 @@ impl Profile {
}
}
/// Message from the fetch task to the main thread.
enum Dispatch {
/// A batched sync finished; re-read seen profiles from the database.
Synced,
}
/// How long to wait for more requests before firing a batched sync.
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
/// Global profile cache. Profiles are fetched in batches and kept as plain
/// data; the whole store notifies on change.
/// Global profile cache.
///
/// Profiles are fetched in batches and kept as plain data.
pub struct ProfileStore {
profiles: HashMap<PublicKey, Profile>,
/// Public keys we've already requested this session (main thread only).
/// Public keys requested this session, main thread only.
seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>,
@@ -111,24 +109,15 @@ impl ProfileStore {
_ => {}
});
// Fetch requests are queued on a channel and synced in batches by a
// background task.
// Fetch requests are queued on a channel, batched into one sync per debounce window.
let client = backend.read(cx).client();
let (sender, receiver) = flume::unbounded::<PublicKey>();
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
let entity = cx.entity().downgrade();
let mut tasks = Vec::new();
tasks.push(cx.background_spawn(async move {
Self::handle_requests(&client, &dispatch_tx, &receiver).await
}));
// Re-read seen profiles from the database after each batch sync.
tasks.push(cx.spawn(async move |this, cx| {
while let Ok(Dispatch::Synced) = dispatch_rx.recv_async().await {
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
}
Ok(())
tasks.push(cx.spawn(async move |_this, cx| {
Self::handle_requests(entity, &client, &receiver, cx).await
}));
let mut store = Self {
@@ -143,8 +132,17 @@ impl ProfileStore {
store
}
/// Get a profile. Returns a placeholder (default metadata) and queues a
/// fetch if the profile isn't cached yet.
/// Track a spawned task, pruning finished tasks first.
///
/// Keeps the store's task list bounded by the number of in-flight tasks.
fn push_task(&mut self, task: Task<Result<(), Error>>) {
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
}
/// Get a profile.
///
/// Returns a placeholder with default metadata. Queues a fetch when the profile is not cached yet.
pub fn get(&self, public_key: &PublicKey) -> Profile {
if let Some(profile) = self.profiles.get(public_key) {
return profile.clone();
@@ -170,7 +168,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).limit(200);
let events = client.database().query(filter).await?;
// Parse off the main thread; only plain profiles cross back.
// Parse off the main thread.
// Only plain profiles cross back.
let profiles: Vec<Profile> = events
.into_iter()
.map(|event| {
@@ -182,7 +181,7 @@ impl ProfileStore {
Ok::<_, Error>(profiles)
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
@@ -205,7 +204,8 @@ impl ProfileStore {
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
let events = client.database().query(filter).await?;
// Parse off the main thread; only the profile crosses back.
// Parse off the main thread.
// Only the profile crosses back.
let profile = events
.into_iter()
.max_by_key(|e| e.created_at)
@@ -217,7 +217,7 @@ impl ProfileStore {
Ok::<_, Error>(profile)
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let profile = work.await?;
this.update(cx, |this, cx| {
@@ -231,8 +231,9 @@ impl ProfileStore {
}));
}
/// Re-read the latest metadata of every requested author from the local
/// database (used after a sync, which produces no NostrUpdate events).
/// Re-read the latest metadata of every requested author from the local database.
///
/// Used after a sync, which produces no NostrUpdate events.
fn apply_seen(&mut self, cx: &mut Context<Self>) {
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
@@ -272,7 +273,7 @@ impl ProfileStore {
Ok::<_, Error>(profiles)
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
@@ -286,44 +287,57 @@ impl ProfileStore {
}));
}
/// Sync metadata for requested authors in batches, debounced to collect
/// requests. Runs on a background thread; results are dispatched to the
/// main thread, which re-reads the database.
/// Sync metadata for requested authors in batches, debounced to collect requests.
///
/// After each batch, the seen profiles are re-read from the database on the main thread.
async fn handle_requests(
this: WeakEntity<ProfileStore>,
client: &Client,
dispatch: &Sender<Dispatch>,
receiver: &Receiver<PublicKey>,
cx: &mut AsyncApp,
) -> Result<(), Error> {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
// Wait for the first request of a batch.
match receiver.recv_timeout(BATCH_TIMEOUT) {
match receiver.recv_async().await {
Ok(public_key) => {
batch.insert(public_key);
}
Err(RecvTimeoutError::Disconnected) => return Ok(()),
Err(RecvTimeoutError::Timeout) => continue,
};
Err(_) => return Ok(()),
}
// Collect everything that arrives within the debounce window.
// The channel has no async timeout, race the receive against a timer.
let deadline = Instant::now() + BATCH_TIMEOUT;
while let Ok(public_key) = receiver.recv_deadline(deadline) {
batch.insert(public_key);
loop {
let now = Instant::now();
if now >= deadline {
break;
}
let timer = cx.background_executor().timer(deadline - now);
futures::pin_mut!(timer);
let recv = receiver.recv_async();
futures::pin_mut!(recv);
match futures::future::select(recv, timer).await {
futures::future::Either::Left((Ok(public_key), _)) => {
batch.insert(public_key);
}
futures::future::Either::Left((Err(_), _)) => return Ok(()),
futures::future::Either::Right(_) => break,
}
}
let filter = Filter::new()
.kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>());
// Negentropy-sync with the bootstrap relays. Synced events are
// written to the database directly (no NostrUpdate), so re-apply
// from the database afterwards.
// Negentropy-sync with the bootstrap relays.
// Synced events are written to the database directly, no NostrUpdate.
// Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => {
if dispatch.send(Dispatch::Synced).is_err() {
log::warn!("profile dispatch channel closed, dropping sync result");
}
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
}
Err(e) => log::warn!("profile sync failed: {e}"),
}
+69
View File
@@ -0,0 +1,69 @@
/// Refresh coalescing shared by the event stores.
///
/// [`crate::RepoStore`], [`crate::RepoListStore`] and [`crate::CheckoutsStore`]
/// re-query their inputs on a debounce timer with the same policy:
/// a request arriving while a run is in flight is folded into a follow-up run,
/// a request arriving while the debounce timer is pending is dropped by it.
#[derive(Debug, Default)]
pub struct RefreshGate {
/// A run is in flight.
running: bool,
/// A request arrived while a run was in flight.
dirty: bool,
/// The debounce timer is pending.
debouncing: bool,
}
/// What a refresh request decided.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run or timer covers the request, start the debounce timer.
Schedule,
/// A run or pending timer already covers the request.
Fold,
}
impl RefreshGate {
/// Whether a run is in flight.
pub fn running(&self) -> bool {
self.running
}
/// Whether the debounce timer is pending.
pub fn debouncing(&self) -> bool {
self.debouncing
}
/// A new refresh request arrived.
///
/// Folded into a follow-up run while one is in flight, dropped while the
/// debounce timer is pending, otherwise starts the timer.
pub fn request(&mut self) -> RefreshRequest {
if self.running {
self.dirty = true;
RefreshRequest::Fold
} else if self.debouncing {
RefreshRequest::Fold
} else {
self.debouncing = true;
RefreshRequest::Schedule
}
}
/// The debounce timer fired and the run starts now.
pub fn begin(&mut self) {
self.debouncing = false;
self.running = true;
}
/// The run ended. Whether a request arrived while it ran.
pub fn finish(&mut self) -> bool {
self.running = false;
std::mem::take(&mut self.dirty)
}
/// The run was abandoned, e.g. on error. Pending follow-up requests survive.
pub fn abort(&mut self) {
self.running = false;
}
}
File diff suppressed because it is too large Load Diff
+83 -111
View File
@@ -8,9 +8,11 @@ use nostr_sdk::prelude::*;
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use crate::backend::{Backend, BackendEvent};
use crate::refresh::{RefreshGate, RefreshRequest};
/// Delay between a refresh request and the actual re-query, so bursts of
/// events (e.g. sync progress ticks) collapse into one query.
/// Delay between a refresh request and the actual re-query.
///
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How far back activity events count toward a repository's last activity.
@@ -20,53 +22,45 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
impl Global for GlobalRepoListStore {}
/// Counts of NIP-34 activity events per repository, used to rank the
/// explore list by popularity. Each patch event is a pushed commit (or a
/// small series), the closest proxy for commit count in the event data.
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct RepoActivityCounts {
/// Root `30611` issue events addressed to the repository.
pub issues: u32,
/// Root `3063` pull request events addressed to the repository
/// (updates to a PR are not new PRs and don't count).
/// Root `3063` pull request events addressed to the repository.
///
/// PR updates are not new PRs and do not count.
pub pull_requests: u32,
/// `1617` patch events addressed to the repository.
pub commits: u32,
}
impl RepoActivityCounts {
/// Total issues + pull requests + commits; the popularity ranking key.
/// Total issues, pull requests and commits, the popularity ranking key.
pub fn score(self) -> u32 {
self.issues + self.pull_requests + self.commits
}
}
/// Store listing repository announcements (global discovery or per-author).
///
/// The all-repos store (`author: None`) is created at startup by
/// [`crate::init`] and installed as a global, so the explore panel renders
/// what's in the local database without waiting for relays.
/// 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>>,
/// Latest known activity timestamp per repository
/// (announcements, state updates, patches, PRs, issues, statuses).
/// Latest known activity timestamp per repository.
/// Covers announcements, state updates, patches, PRs, issues and statuses.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues + pull requests + commits per repository, for the Popular
/// ranking of the explore list.
/// Issues, pull requests and commits per repository.
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
author: Option<PublicKey>,
refreshing: bool,
refresh_dirty: bool,
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
debouncing: bool,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
impl RepoListStore {
/// Retrieve the global explore store (all announcements, created at
/// startup by [`crate::init`]).
/// Retrieve the global repository list store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRepoListStore>().0.clone()
}
@@ -75,35 +69,34 @@ 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| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
// Deletions may target anything we list; always refresh.
// Deletions may target anything we list, always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
// Activity (patches, issues, ...) is addressed to repos via
// `a` tags, so its author isn't the repo owner; always refresh.
// Activity events are addressed to repos via `a` tags.
// Their author is not the repo owner, always refresh.
true
} 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 announcement = event.kind == Kind::GitRepoAnnouncement
&& this.author.is_none_or(|a| a == event.pubkey);
// Locally published deletions (e.g. deleting a repo)
// are already in the local database; refresh so they
// take effect immediately, like relay deletions.
let announcement = event.kind == Kind::GitRepoAnnouncement;
// Locally published deletions are already in the local database.
// Refresh so they take effect immediately, like relay deletions.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
announcement || deletion
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
@@ -119,105 +112,90 @@ impl RepoListStore {
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
counts: Arc::new(HashMap::new()),
author,
refreshing: false,
refresh_dirty: false,
debouncing: false,
refresh: RefreshGate::default(),
_subscription: subscription,
tasks: Vec::new(),
};
store.subscribe_remote(cx);
// Query the local database right away; the list never waits for the
// relay syncs started above to finish.
// Query the local database right away.
// The list never waits for the relay syncs started above to finish.
store.refresh_initial(cx);
store
}
/// Scope the list to an author (or clear the scope with `None`).
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
self.author = author;
self.subscribe_remote(cx);
self.refresh(cx);
/// 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.
fn push_task(&mut self, task: Task<Result<(), Error>>) {
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
}
/// 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);
// Deletion requests (NIP-09/62) must be known before any
// announcement can be shown.
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);
});
}
/// One-shot initial load: query the local database immediately (no
/// debounce), so stored announcements appear as soon as the app opens.
/// Only called from [`Self::new`], before any refresh can be pending.
/// One-shot initial load.
///
/// Query the local database immediately, no debounce.
/// Stored announcements appear as soon as the app opens.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.debouncing);
if self.refreshing {
self.refresh_dirty = true;
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database. Latest announcement per repository wins.
///
/// Debounced: a short delay collapses bursts of requests (e.g. sync
/// progress ticks), and requests that arrive while a query is running
/// are folded into one follow-up query. The query and processing run on
/// a background thread; only the results are applied on the main thread.
/// Re-query the local database.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refreshing {
self.refresh_dirty = true;
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
if self.debouncing {
return;
}
self.debouncing = true;
let task = cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| {
this.debouncing = false;
this.run_refresh(cx);
})
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.tasks.push(task);
self.push_task(task);
}
/// One query + apply cycle (debounced entry point).
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refreshing = true;
self.refresh.begin();
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);
// Dedup and sort off the main thread; only the final list
// crosses back into the entity.
// Dedup and sort off the main thread.
// Only the final list crosses back into the entity.
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
for event in events {
@@ -242,8 +220,9 @@ impl RepoListStore {
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
// Last activity per repository: state updates plus all NIP-34
// activity events (patches, PRs, issues, statuses).
// Last activity per repository.
// State updates count, and all NIP-34 activity events.
// The activity events are patches, PRs, issues and statuses.
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
.iter()
.map(|a| (a.addr(), a.created_at))
@@ -264,8 +243,8 @@ impl RepoListStore {
*entry = (*entry).max(event.created_at);
}
// Bound the activity query to a recent window; older repos fall
// back to their announcement / state timestamps.
// Bound the activity query to a recent window.
// Older repos fall back to their announcement or state timestamps.
let activity_filter = Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.since(Timestamp::now() - ACTIVITY_WINDOW);
@@ -277,8 +256,8 @@ impl RepoListStore {
if addr.kind != Kind::GitRepoAnnouncement {
continue;
}
// Skip events for repos we don't list, so the map can't
// grow beyond the number of announcements.
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
let Some(entry) = last_activity.get_mut(&addr) else {
continue;
};
@@ -286,9 +265,8 @@ impl RepoListStore {
}
}
// Popularity counts per repository (issues, pull requests and
// patches). Unbounded, unlike the windowed activity query
// above, so totals are exact.
// Popularity counts per repository, issues, pull requests and patches.
// Unbounded, unlike the windowed activity query above, so totals are exact.
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
let count_filter =
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
@@ -297,8 +275,8 @@ impl RepoListStore {
continue;
}
for addr in event.tags.coordinates() {
// Skip events for repos we don't list, so the map can't
// grow beyond the number of announcements.
// Skip events for repos we do not list.
// The map cannot grow beyond the number of announcements.
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
{
continue;
@@ -316,13 +294,13 @@ impl RepoListStore {
Ok::<_, Error>((announcements, last_activity, counts))
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let (announcements, last_activity, counts) = match work.await {
Ok(results) => results,
// Database errors are transient; keep the last list.
// Database errors are transient, keep the last list.
Err(_) => {
return this.update(cx, |this, _cx| {
this.refreshing = false;
this.refresh.abort();
});
}
};
@@ -333,17 +311,11 @@ impl RepoListStore {
this.counts = Arc::new(counts);
cx.notify();
this.refreshing = false;
if this.refresh_dirty {
this.refresh_dirty = false;
true
} else {
false
}
this.refresh.finish()
})?;
// Requests that arrived while the refresh was running are
// coalesced into one follow-up refresh.
// Requests that arrived while the refresh was running.
// They are coalesced into one follow-up refresh.
if again {
this.update(cx, |this, cx| this.refresh(cx))?;
}