This commit is contained in:
2026-09-04 17:12:52 +07:00
parent 1d224218df
commit 1496b7afeb
24 changed files with 270 additions and 645 deletions
+22 -14
View File
@@ -160,6 +160,14 @@ impl Backend {
this
}
/// 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);
}
/// Bootstrap the client.
///
/// Restore the saved session, if any.
@@ -180,7 +188,7 @@ impl Backend {
Ok::<(), Error>(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.notify())?;
@@ -208,7 +216,7 @@ impl Backend {
let user = cx.read_credentials(USER_KEYRING);
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let content = match user.await {
Ok(Some((_username, secret))) => String::from_utf8(secret)?,
_ => {
@@ -906,7 +914,7 @@ impl Backend {
let pubkey = keys.public_key().to_hex();
let write = cx.write_credentials(USER_KEYRING, &pubkey, nsec.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
if let Err(e) = write.await {
this.update(cx, |_, cx| cx.emit(BackendEvent::error(e.to_string())))?;
return Ok(());
@@ -932,7 +940,7 @@ impl Backend {
let credential = with_master_key(&uri_string, &keys);
let write = cx.write_credentials(USER_KEYRING, "bunker", credential.as_bytes());
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let result = async {
let mut signer = NostrConnect::new(
connect_uri,
@@ -964,7 +972,7 @@ impl Backend {
pub fn logout(&mut self, cx: &mut Context<Self>) {
let delete = cx.delete_credentials(USER_KEYRING);
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
delete.await.ok();
this.update(cx, |this, cx| {
@@ -984,7 +992,7 @@ impl Backend {
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let result = async {
let events: Vec<Event> = client
.fetch_events(filters::grasp_list(public_key))
@@ -1068,7 +1076,7 @@ impl Backend {
Ok(())
});
self.tasks.push(task);
self.push_task(task);
}
/// Add relays and connect to them.
@@ -1083,7 +1091,7 @@ impl Backend {
Ok::<(), Error>(())
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
match task.await {
Ok(()) => {
this.update(cx, |_this, cx| cx.notify())?;
@@ -1125,7 +1133,7 @@ impl Backend {
let client = self.client.clone();
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}");
// Allow an immediate retry after a failure.
@@ -1145,7 +1153,7 @@ impl Backend {
let task =
cx.background_spawn(async move { subscribe_bootstrap_only(&client, filters).await });
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
}
@@ -1168,7 +1176,7 @@ impl Backend {
let (tx, mut rx) = SyncProgress::channel();
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let mut last_percent: u64 = 0;
while rx.changed().await.is_ok() {
@@ -1201,7 +1209,7 @@ impl Backend {
sync_bootstrap_only(&client, filter, opts).await
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
match task.await {
Ok(summary) => {
log::debug!(
@@ -1287,7 +1295,7 @@ impl Backend {
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
let task = self.send(builder, cx);
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |_this, cx| {
cx.emit(BackendEvent::error(e.to_string()));
@@ -1313,7 +1321,7 @@ impl Backend {
let task = self.send(EventBuilder::new(Kind::EventDeletion, "").tags(tags), cx);
self.tasks.push(cx.spawn(async move |_this, _cx| {
self.push_task(cx.spawn(async move |_this, _cx| {
if let Err(e) = task.await {
log::warn!("failed to retract repository events: {e}");
}
+22 -15
View File
@@ -1,7 +1,6 @@
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::Arc;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use anyhow::Error;
@@ -67,9 +66,9 @@ struct Remembered {
/// Global store of local-checkout associations and per-checkout statuses.
pub struct CheckoutsStore {
/// Checkout paths per announced repository.
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
/// Ready-to-contribute statuses of the requested repositories.
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Repositories whose statuses are recomputed on every input change.
///
/// Those are the repository detail panels currently open.
@@ -79,7 +78,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.
push_statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
push_statuses: HashMap<RepoAddr, Vec<CheckoutStatus>>,
/// Last announced head branch per requested repository.
///
/// A recompute defaults the base the same way.
@@ -129,19 +128,19 @@ impl CheckoutsStore {
this.status_requested.clear();
this.push_requested.clear();
this.requested_head.clear();
this.statuses = Arc::new(HashMap::new());
this.push_statuses = Arc::new(HashMap::new());
this.statuses = HashMap::new();
this.push_statuses = HashMap::new();
this.refresh(cx);
}
}));
}
let mut store = Self {
by_repo: Arc::new(HashMap::new()),
statuses: Arc::new(HashMap::new()),
by_repo: HashMap::new(),
statuses: HashMap::new(),
status_requested: HashSet::new(),
push_requested: HashSet::new(),
push_statuses: Arc::new(HashMap::new()),
push_statuses: HashMap::new(),
requested_head: HashMap::new(),
refresh: RefreshGate::default(),
_subscriptions: subscriptions,
@@ -155,6 +154,14 @@ impl CheckoutsStore {
store
}
/// 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);
}
/// Remember a successful local-checkout use.
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
@@ -243,7 +250,7 @@ impl CheckoutsStore {
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.tasks.push(task);
self.push_task(task);
}
/// One resolve and apply cycle, the debounced entry point.
@@ -345,7 +352,7 @@ impl CheckoutsStore {
Ok::<_, Error>((associations, statuses, push_statuses))
});
self.tasks.push(cx.spawn(async move |this, cx| {
self.push_task(cx.spawn(async move |this, cx| {
let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results,
Err(_) => {
@@ -357,9 +364,9 @@ impl CheckoutsStore {
};
let again = this.update(cx, |this, cx| {
this.by_repo = Arc::new(associations);
this.statuses = Arc::new(statuses);
this.push_statuses = Arc::new(push_statuses);
this.by_repo = associations;
this.statuses = statuses;
this.push_statuses = push_statuses;
cx.notify();
this.refresh.finish()
@@ -386,7 +393,7 @@ impl CheckoutsStore {
this.update(cx, |this, cx| this.run_refresh(cx))
});
this.tasks.push(task);
this.push_task(task);
}
})?;
-2
View File
@@ -13,8 +13,6 @@ pub struct GitStore(GitCache);
impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global.
///
/// Replaces any installed store, [`signed_state::init`] 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()));
+8 -2
View File
@@ -23,7 +23,12 @@ use signed_nostr::new_backend;
/// 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> {
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.
rustls::crypto::aws_lc_rs::default_provider()
.install_default()
@@ -41,7 +46,8 @@ pub fn init(db_path: impl AsRef<Path>, scan_paths: Vec<PathBuf>, cx: &mut App) -
ProfileStore::set_global(cx.new(ProfileStore::new), cx);
RepoListStore::set_global(cx.new(|cx| RepoListStore::new(None, cx)), cx);
GitStore::set_global(PathBuf::new(), cx);
// The local git clone cache, the grasp mirrors.
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);
+44 -36
View File
@@ -3,8 +3,11 @@ 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;
@@ -60,14 +63,6 @@ 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);
@@ -114,23 +109,15 @@ impl ProfileStore {
_ => {}
});
// Fetch requests are queued on a channel.
// 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 {
@@ -145,6 +132,14 @@ impl ProfileStore {
store
}
/// 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.
@@ -186,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| {
@@ -222,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| {
@@ -278,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| {
@@ -294,28 +289,43 @@ impl ProfileStore {
/// Sync metadata for requested authors in batches, debounced to collect requests.
///
/// Results are dispatched to the main thread, which re-reads the database.
/// 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()
@@ -327,9 +337,7 @@ impl ProfileStore {
// 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}"),
}
+10 -2
View File
@@ -129,6 +129,14 @@ impl RepoListStore {
store
}
/// 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);
@@ -170,7 +178,7 @@ impl RepoListStore {
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.tasks.push(task);
self.push_task(task);
}
/// One query and apply cycle, the debounced entry point.
@@ -291,7 +299,7 @@ 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.