chore: refactor the backend (#17)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
2026-09-10 09:43:35 +00:00
parent a8e19e5fcd
commit 38e8b2b933
28 changed files with 6987 additions and 5488 deletions
File diff suppressed because it is too large Load Diff
+24 -32
View File
@@ -3,16 +3,15 @@ use std::path::{Path, PathBuf};
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use gpui::{App, AppContext, Context, Entity, Global, Subscription};
use nostr::prelude::*;
use settings::{CheckoutRecord, SettingsStore};
use signed_core::{Announcement, RepoAddr};
use crate::backend::{Backend, BackendEvent};
use crate::git_store::GitStore;
use crate::local_repos::LocalReposStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repo_list::RepoListStore;
use crate::repos::{LocalReposStore, RepoListStore};
/// Delay between a refresh request and the actual re-computation.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -105,7 +104,6 @@ pub struct CheckoutsStore {
/// The local pass runs a full pass again once this is older than the
/// reconciliation cadence, so remote moves still land.
last_full_sync: Option<Instant>,
tasks: Vec<Task<Result<(), Error>>>,
_subscriptions: Vec<Subscription>,
}
@@ -155,7 +153,16 @@ impl CheckoutsStore {
}));
}
let mut store = Self {
if !cfg!(target_arch = "wasm32") {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.refresh(cx)) {
log::warn!("checkouts store dropped before initial refresh could run: {error}");
}
});
}
Self {
by_repo: HashMap::new(),
statuses: HashMap::new(),
status_requested: HashSet::new(),
@@ -165,23 +172,8 @@ impl CheckoutsStore {
refresh: RefreshGate::default(),
local_pending: false,
last_full_sync: None,
tasks: Vec::new(),
_subscriptions: subscriptions,
};
if !cfg!(target_arch = "wasm32") {
store.refresh(cx);
}
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.
@@ -302,12 +294,11 @@ impl CheckoutsStore {
return;
}
let task = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.push_task(task);
})
.detach();
}
/// One full resolve and apply cycle, the debounced entry point.
@@ -388,7 +379,7 @@ impl CheckoutsStore {
Ok::<_, Error>((associations, statuses, push_statuses))
});
self.push_task(cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
let (associations, statuses, push_statuses) = match work.await {
Ok(results) => results,
Err(_) => {
@@ -435,7 +426,8 @@ impl CheckoutsStore {
})?;
Ok(())
}));
})
.detach();
}
/// Schedule the fast local status pass, unless one is already pending.
@@ -449,15 +441,14 @@ impl CheckoutsStore {
}
self.local_pending = true;
let task = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(LOCAL_POLL).await;
this.update(cx, |this, cx| {
this.local_pending = false;
this.local_tick(cx);
})
});
self.push_task(task);
})
.detach();
}
/// The fast local status pass.
@@ -525,7 +516,7 @@ impl CheckoutsStore {
Ok::<_, Error>((statuses, push_statuses))
});
self.push_task(cx.spawn(async move |this, cx| {
let task: gpui::Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let Ok((statuses, push_statuses)) = work.await else {
// Git reads are best-effort, keep the last results.
return Ok(());
@@ -550,7 +541,8 @@ impl CheckoutsStore {
})?;
Ok(())
}));
});
task.detach();
}
}
+2 -4
View File
@@ -1,11 +1,10 @@
mod backend;
mod checkouts;
mod git_store;
mod local_repos;
mod profile;
mod refresh;
mod repo;
mod repo_list;
mod repos;
use std::path::{Path, PathBuf};
@@ -13,11 +12,10 @@ 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;
pub use nostr_sdk::prelude::Timestamp;
pub use profile::{Profile, ProfileStore};
pub use repo::RepoStore;
pub use repo_list::{RepoActivityCounts, RepoListStore};
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
/// Initialize the backend and stores, and install them as globals.
-104
View File
@@ -1,104 +0,0 @@
use std::path::{Path, PathBuf};
use std::sync::Arc;
use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Task};
use signed_git::find_git_repos;
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
tasks: Vec<Task<Result<(), Error>>>,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let mut store = Self {
roots: Arc::new(roots),
repos: Arc::new(Vec::new()),
scanning: false,
scan_dirty: false,
tasks: Vec::new(),
};
store.rescan(cx);
store
}
/// 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
.iter()
.filter(|repo| repo.as_path() != path)
.cloned()
.collect(),
);
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
return;
}
if self.roots.is_empty() {
return;
}
self.scanning = true;
cx.notify();
let roots = self.roots.clone();
let work = cx.background_spawn(async move {
let mut repos = Vec::new();
for root in roots.iter() {
repos.extend(find_git_repos(root));
}
repos.sort();
repos.dedup();
repos
});
self.tasks.push(cx.spawn(async move |this, cx| {
let repos = work.await;
let again = this.update(cx, |this, cx| {
this.repos = Arc::new(repos);
this.scanning = false;
cx.notify();
let dirty = this.scan_dirty;
this.scan_dirty = false;
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
Ok(())
}));
}
}
+29 -28
View File
@@ -75,7 +75,6 @@ pub struct ProfileStore {
seen: RefCell<HashSet<PublicKey>>,
/// Sender for queuing fetch requests, batched by a background task.
sender: Sender<PublicKey>,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
@@ -97,8 +96,13 @@ impl ProfileStore {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
BackendEvent::NostrUpdate(update) if update.kind == Kind::Metadata => {
this.apply_author(update.author, cx);
BackendEvent::NostrUpdate(updates) => {
for update in updates
.iter()
.filter(|update| update.kind == Kind::Metadata)
{
this.apply_author(update.author, cx);
}
}
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
@@ -114,30 +118,24 @@ impl ProfileStore {
let (sender, receiver) = flume::unbounded::<PublicKey>();
let entity = cx.entity().downgrade();
let mut tasks = Vec::new();
tasks.push(cx.spawn(async move |_this, cx| {
cx.spawn(async move |_this, cx| {
Self::handle_requests(entity, &client, &receiver, cx).await
}));
})
.detach();
let mut store = Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
log::warn!("profile store dropped before initial load could run: {error}");
}
});
Self {
profiles: HashMap::new(),
seen: RefCell::new(HashSet::new()),
sender,
tasks,
_subscription: subscription,
};
store.load(cx);
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.
@@ -181,7 +179,7 @@ impl ProfileStore {
Ok::<_, Error>(profiles)
});
self.push_task(cx.spawn(async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
@@ -192,7 +190,8 @@ impl ProfileStore {
})?;
Ok(())
}));
});
task.detach();
}
/// Re-read the latest metadata of an author from the local database.
@@ -217,7 +216,7 @@ impl ProfileStore {
Ok::<_, Error>(profile)
});
self.push_task(cx.spawn(async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let profile = work.await?;
this.update(cx, |this, cx| {
@@ -228,7 +227,8 @@ impl ProfileStore {
})?;
Ok(())
}));
});
task.detach();
}
/// Re-read the latest metadata of every requested author from the local database.
@@ -273,7 +273,7 @@ impl ProfileStore {
Ok::<_, Error>(profiles)
});
self.push_task(cx.spawn(async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let profiles = work.await?;
this.update(cx, |this, cx| {
@@ -284,7 +284,8 @@ impl ProfileStore {
})?;
Ok(())
}));
});
task.detach();
}
/// Sync metadata for requested authors in batches, debounced to collect requests.
@@ -337,7 +338,7 @@ impl ProfileStore {
// Re-apply from the database afterwards.
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
Ok(_) => {
let _ = this.update(cx, |this, cx| this.apply_seen(cx));
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
}
Err(e) => log::warn!("profile sync failed: {e}"),
}
+187 -68
View File
@@ -3,7 +3,7 @@ use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::Error;
use anyhow::{Error, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
use nostr::event::IntoEventBuilder;
@@ -14,12 +14,13 @@ use signed_core::{
};
use crate::backend::{
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, user_grasp_list_servers,
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
user_grasp_list_servers,
};
use crate::checkouts::CheckoutsStore;
use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repo_list::RepoListStore;
use crate::repos::RepoListStore;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
@@ -81,7 +82,6 @@ pub struct RepoStore {
root_fetches: HashSet<EventId>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
@@ -91,7 +91,7 @@ impl RepoStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
@@ -107,7 +107,7 @@ impl RepoStore {
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
@@ -127,7 +127,20 @@ impl RepoStore {
}
});
let mut store = Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
this.connect_announced_relays(&announced_relays, cx);
this.refresh(cx);
});
if let Err(error) = result {
log::warn!("repo store dropped before bootstrap could run: {error}");
}
});
Self {
addr,
announcement: None,
head: None,
@@ -148,16 +161,7 @@ impl RepoStore {
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
tasks: Vec::new(),
};
store.subscribe_remote(cx);
// The announcement we opened the repo from may already list its relays.
// Connect to them right away.
// Do not wait for the bootstrap fetch to return the same event.
store.connect_announced_relays(&announced_relays, cx);
store.refresh(cx);
store
}
}
/// Returns the repository's address.
@@ -229,14 +233,12 @@ impl RepoStore {
return;
}
let task = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(task);
})
.detach();
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
@@ -372,9 +374,7 @@ impl RepoStore {
))
});
self.tasks.retain(|task| !task.is_ready());
self.tasks.push(cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
let (
announcement,
state,
@@ -466,7 +466,8 @@ impl RepoStore {
}
Ok(())
}));
})
.detach();
}
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
@@ -514,7 +515,7 @@ impl RepoStore {
}
.into_event_builder();
self.send(builder, cx);
self.publish(builder, cx);
}
/// Comments on a root event, an issue or PR, oldest first.
@@ -545,7 +546,7 @@ impl RepoStore {
.and_then(|a| a.relays.first())
.cloned();
self.send(
self.publish(
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
cx,
);
@@ -638,7 +639,7 @@ impl RepoStore {
.collect()
};
self.tasks.push(cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
// The PR references the root patch event.
// Viewers can then find the patch without carrying it inline.
let root_patch = match publish_patch_series(
@@ -798,12 +799,15 @@ impl RepoStore {
}
}
let publish_task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.publish_event(event, cx))
})?;
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
let pr_event = match publish_task.await {
let publish_result: Result<Event, Error> = async {
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
let pr_event = match publish_result {
Ok(event) => event,
Err(e) => {
return this.update(cx, |this, cx| {
@@ -813,6 +817,11 @@ impl RepoStore {
}
};
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
})?;
// A draft PR carries a kind-1633 status event, NIP-34.
// Publish it right after the PR event so viewers never show it open.
if draft {
@@ -822,7 +831,61 @@ impl RepoStore {
}
Ok(())
}));
})
.detach();
}
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
/// then open a pull request from it.
///
/// Fails descriptively when there are no commits to propose or the patch
/// could not be generated; otherwise publishes exactly like
/// [`Self::open_pull_request`].
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request_from_refs(
&mut self,
repo_path: PathBuf,
merge_base: String,
compare_ref: String,
subject: Option<String>,
description: String,
branch_name: Option<String>,
draft: bool,
cx: &mut Context<Self>,
) -> Task<Result<(), Error>> {
cx.spawn(async move |this, cx| {
// Regenerate the series at submit time.
// The published patch covers the current tip of the compare branch.
let patch = cx
.background_spawn({
let repo_path = repo_path.clone();
let merge_base = merge_base.clone();
let compare_ref = compare_ref.clone();
async move {
signed_git::format_patch_between(&repo_path, &merge_base, &compare_ref)
}
})
.await;
let patch = match patch {
Ok(patch) if !patch.is_empty() => patch,
Ok(_) => bail!("No commits between the branches to propose"),
Err(error) => bail!("Failed to generate the patch: {error}"),
};
this.update(cx, |this, cx| {
this.open_pull_request(
subject,
description,
branch_name,
patch,
draft,
Some(merge_base),
Some(repo_path),
cx,
);
})
})
}
/// Update a pull request.
@@ -894,7 +957,7 @@ impl RepoStore {
.map(|a| a.clone.clone())
.unwrap_or_default();
self.tasks.push(cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
if let Err(e) = publish_patch_series(
&this,
cx,
@@ -913,7 +976,7 @@ impl RepoStore {
});
}
let update_task = this.update(cx, |this, cx| {
let builder = this.update(cx, |this, _cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
pull_request_event: root.id,
@@ -926,24 +989,44 @@ impl RepoStore {
// The `r` EUC tag lets clients subscribe to all PR updates.
// The SDK builder omits it.
let builder = match euc.as_deref() {
match euc.as_deref() {
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
None => builder,
};
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
}
})?;
if let Err(e) = update_task.await {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
let (client, signer) = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
let backend = backend.read(cx);
(backend.client(), backend.signer())
})?;
let publish_result: Result<Event, Error> = async {
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
}
Err(e) => {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
});
}
}
Ok(())
}));
})
.detach();
}
/// Set the status of a root event.
@@ -982,7 +1065,7 @@ impl RepoStore {
Tag::coordinate(self.addr.clone(), None),
]);
self.send(builder, cx);
self.publish(builder, cx);
}
/// Merge a pull request.
@@ -1002,10 +1085,10 @@ impl RepoStore {
let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone();
let clone_urls: Vec<String> = self
let clone_urls: Vec<Url> = self
.announcement
.as_ref()
.map(|a| a.clone.iter().map(ToString::to_string).collect())
.map(|a| a.clone.clone())
.unwrap_or_default();
let patch = pull_request_patch(root, self.patches.iter());
@@ -1040,7 +1123,7 @@ impl RepoStore {
Ok::<_, Error>(applied)
});
self.tasks.push(cx.spawn(async move |this, cx| {
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
match apply.await {
Ok(applied) => {
this.update(cx, |this, cx| {
@@ -1062,7 +1145,8 @@ impl RepoStore {
}
}
Ok(())
}));
});
task.detach();
}
/// The latest announcement of this repository,
@@ -1224,7 +1308,7 @@ impl RepoStore {
return self.action_error("Repository announcement is not loaded yet", cx);
};
let clone_urls: Vec<String> = announcement.clone.iter().map(ToString::to_string).collect();
let clone_urls = announcement.clone.clone();
let addr = self.addr.clone();
self.cloning = true;
@@ -1328,24 +1412,50 @@ impl RepoStore {
}
}
self.send(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
self.publish(EventBuilder::new(Kind::GitStatusApplied, "").tags(tags), cx);
}
fn send(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
/// Sign `builder`, broadcast it and track the outcome in [`Self::last_error`].
///
/// Every one-shot repository event (issue, comment, status) goes through
/// this. Multi-step flows (opening or updating a pull request, a patch
/// series) call the SDK directly instead, since their error handling and
/// post-conditions differ per step.
fn publish(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
self.last_error = None;
let backend = Backend::global(cx);
let task = backend.update(cx, |backend, cx| backend.send(builder, cx));
let (client, signer) = {
let backend = backend.read(cx);
(backend.client(), backend.signer())
};
self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = task.await {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let publish_result: Result<Event, Error> = async {
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
require_relay_accepted(output, event)
}
.await;
match publish_result {
Ok(event) => {
this.update(cx, |_this, cx| {
Backend::global(cx)
.update(cx, |backend, cx| backend.announce_published(event, cx))
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
}
Ok(())
}));
});
task.detach();
}
}
@@ -1426,6 +1536,12 @@ async fn publish_patch_series(
first_marker: &str,
reply_to: Option<EventId>,
) -> Result<Event, Error> {
let (client, signer) = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
let backend = backend.read(cx);
(backend.client(), backend.signer())
})?;
let mut root: Option<Event> = None;
let mut previous = reply_to;
@@ -1466,11 +1582,14 @@ async fn publish_patch_series(
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
let task = this.update(cx, |_this, cx| {
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| backend.send(builder, cx))
let event = builder.finalize_async(&signer).await?;
let output = client.send_event(&event).broadcast().await?;
let event = require_relay_accepted(output, event)?;
this.update(cx, |_this, cx| {
Backend::global(cx).update(cx, |backend, cx| {
backend.announce_published(event.clone(), cx)
})
})?;
let event = task.await?;
if root.is_none() {
root = Some(event.clone());
@@ -1,4 +1,5 @@
use std::collections::HashMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
@@ -6,10 +7,116 @@ use anyhow::Error;
use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task};
use nostr_sdk::prelude::*;
use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
use signed_git::find_git_repos;
use crate::backend::{Backend, BackendEvent};
use crate::refresh::{RefreshGate, RefreshRequest};
struct GlobalLocalReposStore(Entity<LocalReposStore>);
impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
pub(crate) fn set_global(entity: Entity<Self>, cx: &mut App) {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.rescan(cx)) {
log::warn!("local repos store dropped before initial scan could run: {error}");
}
});
Self {
roots: Arc::new(roots),
repos: Arc::new(Vec::new()),
scanning: false,
scan_dirty: false,
}
}
/// 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
.iter()
.filter(|repo| repo.as_path() != path)
.cloned()
.collect(),
);
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
return;
}
if self.roots.is_empty() {
return;
}
self.scanning = true;
cx.notify();
let roots = self.roots.clone();
let work = cx.background_spawn(async move {
let mut repos = Vec::new();
for root in roots.iter() {
repos.extend(find_git_repos(root));
}
repos.sort();
repos.dedup();
repos
});
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
let repos = work.await;
let again = this.update(cx, |this, cx| {
this.repos = Arc::new(repos);
this.scanning = false;
cx.notify();
let dirty = this.scan_dirty;
this.scan_dirty = false;
dirty
})?;
// Scans requested while this one ran are coalesced into one follow-up scan.
if again {
this.update(cx, |this, cx| this.rescan(cx))?;
}
Ok(())
});
task.detach();
}
}
/// Delay between a refresh request and the actual re-query.
///
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
@@ -55,7 +162,6 @@ pub struct RepoListStore {
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
tasks: Vec<Task<Result<(), Error>>>,
_subscription: Subscription,
}
@@ -75,7 +181,7 @@ impl RepoListStore {
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(update) => {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target anything we list, always refresh.
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
true
@@ -88,7 +194,7 @@ impl RepoListStore {
let is_repo_state = update.kind == Kind::RepoState;
is_announcement || is_repo_state
}
}
}),
BackendEvent::Published(event) => {
let announcement = event.kind == Kind::GitRepoAnnouncement;
@@ -99,7 +205,10 @@ impl RepoListStore {
announcement || deletion
}
BackendEvent::Synced | BackendEvent::SyncProgress { .. } => true,
// Only a completed sync refreshes the list.
// Progress ticks would re-scan the whole database several times
// per sync to reveal entries incrementally.
BackendEvent::Synced => true,
_ => false,
};
@@ -108,20 +217,26 @@ impl RepoListStore {
}
});
let mut store = Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
// Query the local database right away.
// The list never waits for the relay syncs started above to finish.
this.refresh_initial(cx);
});
if let Err(error) = result {
log::warn!("repo list store dropped before bootstrap could run: {error}");
}
});
Self {
announcements: Arc::new(Vec::new()),
last_activity: Arc::new(HashMap::new()),
counts: Arc::new(HashMap::new()),
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.
store.refresh_initial(cx);
store
}
}
/// The announcements of `user`, newest first.
@@ -133,14 +248,6 @@ impl RepoListStore {
.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);
@@ -171,13 +278,11 @@ impl RepoListStore {
return;
}
let task = cx.spawn(async move |this, cx| {
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
});
self.push_task(task);
})
.detach();
}
/// One query and apply cycle, the debounced entry point.
@@ -294,7 +399,7 @@ impl RepoListStore {
Ok::<_, Error>((announcements, last_activity, counts))
});
self.push_task(cx.spawn(async move |this, cx| {
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.
@@ -321,6 +426,7 @@ impl RepoListStore {
}
Ok(())
}));
})
.detach();
}
}