chore: refactor the backend (#17)
Reviewed-on: #17
This commit was merged in pull request #17.
This commit is contained in:
+301
-398
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(())
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user