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
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))?;
}