review
This commit is contained in:
@@ -19,8 +19,6 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Keyring entry for the user credential.
|
||||
/// It is an `nsec1...` key or a `bunker://...` URI.
|
||||
/// The URI embeds a `?master=<nsec>` NIP-46 session key.
|
||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||
/// Timeout for NIP-46 signer responses.
|
||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||
@@ -37,17 +35,13 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// How long an identical fetch or sync request is suppressed after it started.
|
||||
/// A second panel for the same repository does not duplicate a live sync.
|
||||
/// The global and per-author list stores at login share this dedup.
|
||||
/// After the window, re-fetching is allowed again so data stays fresh.
|
||||
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum BackendEvent {
|
||||
/// User has no signer configured.
|
||||
SignerRequired,
|
||||
/// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
|
||||
/// A passphrase is required to decrypt it before the session can resume.
|
||||
/// The stored identity is NIP-49 encrypted key.
|
||||
PassphraseRequired,
|
||||
/// The signer changed on login, logout or account switch.
|
||||
SignerChanged,
|
||||
@@ -56,12 +50,8 @@ pub enum BackendEvent {
|
||||
/// A new event was received from a relay and stored in the database.
|
||||
NostrUpdate(Update),
|
||||
/// A negentropy sync completed.
|
||||
/// The database was updated directly, so stores should re-query.
|
||||
/// No [`BackendEvent::NostrUpdate`] is fired for synced events.
|
||||
Synced,
|
||||
/// A negentropy sync is in flight.
|
||||
/// Stores may re-query to render incrementally.
|
||||
/// UI can show `current` and `total` progress.
|
||||
SyncProgress {
|
||||
/// Total events to process.
|
||||
total: u64,
|
||||
@@ -84,9 +74,8 @@ impl BackendEvent {
|
||||
}
|
||||
|
||||
/// The global backend entity.
|
||||
///
|
||||
/// Owns the nostr client, the signer and the notification pump.
|
||||
/// Stores subscribe to [`BackendEvent`].
|
||||
/// They re-query the local database when relevant updates arrive.
|
||||
pub struct Backend {
|
||||
client: Client,
|
||||
signer: UniversalSigner,
|
||||
@@ -94,18 +83,10 @@ pub struct Backend {
|
||||
connected: bool,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
/// A passphrase is still needed to resume the session.
|
||||
passphrase_required: bool,
|
||||
/// Fingerprints of recently started fetches and syncs, a relay plus filter set.
|
||||
/// Duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into one.
|
||||
/// Entries are pruned lazily on the next request.
|
||||
recent_fetches: HashMap<u64, Instant>,
|
||||
/// Repositories with a push in flight, mirror or checkout based.
|
||||
/// Concurrent pushes of the same refs make the losing push fail server-side.
|
||||
/// The rejection is a compare-and-swap error from the server.
|
||||
/// Two panels of the same repository can race.
|
||||
/// The banner push can also race the header's Republish.
|
||||
/// Pushes are single-flight per repository.
|
||||
pushing_repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
}
|
||||
@@ -115,6 +96,7 @@ struct GlobalBackend(Entity<Backend>);
|
||||
impl Global for GlobalBackend {}
|
||||
|
||||
/// Removes its repository from the in-flight push set when dropped.
|
||||
///
|
||||
/// A push task cancelled by its panel closing cannot leave the repository locked.
|
||||
struct PushGuard {
|
||||
repos: Arc<Mutex<HashSet<RepoAddr>>>,
|
||||
@@ -182,7 +164,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Bootstrap the client.
|
||||
/// Connect to the default relays, with the indexers as discovery-only.
|
||||
///
|
||||
/// Restore the saved session, if any.
|
||||
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
@@ -221,7 +203,9 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Restore the saved session from the keyring.
|
||||
///
|
||||
/// Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||
///
|
||||
/// Emits [`BackendEvent::PassphraseRequired`] for a NIP-49 encrypted identity.
|
||||
pub fn restore_session(&mut self, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
@@ -283,9 +267,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Decrypt the NIP-49 keyring credential with the given passphrase.
|
||||
/// Resume the session on success.
|
||||
/// The scrypt decryption runs off the UI thread.
|
||||
/// The task yields the public key or the failure reason, e.g. a wrong passphrase.
|
||||
pub fn restore_with_passphrase(
|
||||
&mut self,
|
||||
password: &str,
|
||||
@@ -321,11 +302,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Create a new identity.
|
||||
/// Generate keys and encrypt the secret key with the passphrase, NIP-49.
|
||||
/// Persist it in the keyring.
|
||||
/// Then publish the NIP-65 relay list, metadata and grasp list.
|
||||
/// The encryption runs off the UI thread.
|
||||
/// The task yields the new public key.
|
||||
pub fn create_identity(
|
||||
&mut self,
|
||||
name: &str,
|
||||
@@ -412,19 +388,8 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a repository.
|
||||
/// Initialize a local clone with a `main` branch and a `README.md`.
|
||||
/// Publish the NIP-34 announcement and the repository state to the grasp relays.
|
||||
/// Push the initial commit to each grasp server.
|
||||
/// Also create a working copy at `<folder>/<name>`, like the header's Clone action.
|
||||
/// Its `origin` points at the first grasp server.
|
||||
/// The new project exists in the chosen folder right away.
|
||||
/// The events must reach the grasp relays before the push.
|
||||
/// GRASP servers hold the signed state event in purgatory.
|
||||
/// They accept the push only while the authorization is pending.
|
||||
/// The pushed repository must not exist yet.
|
||||
/// The authorization expires after 30 minutes, like gitworkshop and ngit.
|
||||
/// The git work runs on background threads.
|
||||
///
|
||||
/// The task yields the announcement and the path of the working copy.
|
||||
pub fn create_repository(
|
||||
&mut self,
|
||||
@@ -453,16 +418,15 @@ impl Backend {
|
||||
return Task::ready(Err(anyhow!("Sign in to create a repository")));
|
||||
};
|
||||
|
||||
// The repository identifier is derived from the name, like ngit and gitworkshop.
|
||||
// Spaces become hyphens.
|
||||
// Other non-alphanumeric characters become hyphens, except `/`.
|
||||
// Case is preserved.
|
||||
// The repository identifier is derived from the name.
|
||||
let repo_id = identifier_from_name(&name);
|
||||
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
return Task::ready(Err(anyhow!(
|
||||
"Repository name must produce an identifier of 1-100 characters"
|
||||
)));
|
||||
}
|
||||
|
||||
if !repo_id.chars().any(|c| c.is_ascii_alphanumeric()) {
|
||||
return Task::ready(Err(anyhow!(
|
||||
"Repository name must contain at least one alphanumeric character"
|
||||
@@ -472,9 +436,7 @@ impl Backend {
|
||||
let addr = repo_addr(public_key, repo_id.clone());
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let path = cache.repo_path(&addr);
|
||||
let owner = public_key
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| public_key.to_hex());
|
||||
let owner = public_key.to_bech32().unwrap();
|
||||
let servers = grasp_servers.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
@@ -544,8 +506,7 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization.
|
||||
// It must be accepted before the push below.
|
||||
// The state event is the push authorization. It must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -601,8 +562,7 @@ impl Backend {
|
||||
});
|
||||
|
||||
if let Err(e) = push.await {
|
||||
// The events are already published.
|
||||
// Retract them so the repository is not left announced without content.
|
||||
// The events are already published. Retract them so the repository is not left announced without content.
|
||||
this.update(cx, |this, cx| {
|
||||
this.retract_events(&[event.clone(), state_event.clone()], cx);
|
||||
})
|
||||
@@ -622,11 +582,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Publish an existing local repository to NIP-34.
|
||||
/// Read its current branches, tags and HEAD.
|
||||
/// Publish the announcement and the repository state to the grasp relays.
|
||||
/// Then push every branch and tag to each grasp server.
|
||||
/// Also point `origin` at the first grasp server.
|
||||
/// The state event must be accepted before the push, like [`Self::create_repository`].
|
||||
pub fn publish_local_repo(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
@@ -685,8 +640,7 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization.
|
||||
// It must be accepted before the push below.
|
||||
// The state event is the push authorization. It must be accepted before the push below.
|
||||
let announcement = GitRepositoryAnnouncement {
|
||||
id: repo_id.clone(),
|
||||
name: Some(name.clone()),
|
||||
@@ -730,8 +684,8 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// Push every branch and tag to each grasp server.
|
||||
// The push fails only when no server accepted it.
|
||||
// Push every branch and tag to each grasp server. The push fails only when no server accepted it.
|
||||
//
|
||||
// An empty repository has nothing to push.
|
||||
if !refs.is_empty() {
|
||||
let push = cx.background_spawn({
|
||||
@@ -769,9 +723,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Re-push the repository's current refs to the grasp servers in its `relays` tag.
|
||||
/// Publish a fresh state event, the push authorization.
|
||||
/// Then push every branch and tag, like the init flow.
|
||||
/// The repository must have a local clone in the cache.
|
||||
pub fn push_repository(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -784,10 +735,8 @@ impl Backend {
|
||||
|
||||
/// Push the refs of a local checkout to the grasp servers in its `relays` tag.
|
||||
/// The checkout is the working copy of the user's own repository.
|
||||
///
|
||||
/// Publish a fresh state event, then push every branch and tag of the checkout.
|
||||
/// That mirrors the init flow.
|
||||
/// `announced_head` keeps the state event's `HEAD` on the announced default branch.
|
||||
/// That matters when the checkout is on a different branch.
|
||||
pub fn push_checkout(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -799,12 +748,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Shared body of the mirror-based and checkout-based pushes.
|
||||
/// Publish the repository state, the push authorization.
|
||||
/// Then push every branch and tag of `path` to each announced grasp server.
|
||||
/// Pushes are single-flight per repository.
|
||||
/// Concurrent pushes of the same refs fail server-side.
|
||||
/// The rejection is a compare-and-swap error from the server.
|
||||
/// Two panels of the same repository can produce the race.
|
||||
fn push_repo_from(
|
||||
&mut self,
|
||||
announcement: Announcement,
|
||||
@@ -828,16 +771,13 @@ impl Backend {
|
||||
addr: addr.clone(),
|
||||
}
|
||||
};
|
||||
let owner = announcement
|
||||
.owner
|
||||
.to_bech32()
|
||||
.unwrap_or_else(|_| announcement.owner.to_hex());
|
||||
|
||||
let owner = announcement.owner.to_bech32().unwrap();
|
||||
let repo_id = announcement.id.clone();
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Held for the whole task.
|
||||
// Dropped on completion, on error and on cancellation alike.
|
||||
// Held for the whole task. Dropped on completion, on error and on cancellation alike.
|
||||
let _guard = guard;
|
||||
|
||||
let mut state = {
|
||||
@@ -850,12 +790,14 @@ impl Backend {
|
||||
|
||||
// The state event announces the pushed refs.
|
||||
// Keep the announced default branch in `HEAD` when it is among the pushed refs.
|
||||
//
|
||||
// Otherwise `HEAD` stays the checkout's current branch.
|
||||
let heads: Vec<&str> = state
|
||||
.refs
|
||||
.iter()
|
||||
.filter_map(|(name, _)| name.strip_prefix("refs/heads/"))
|
||||
.collect();
|
||||
|
||||
if let Some(head) = announced_head
|
||||
&& heads.iter().any(|branch| *branch == head)
|
||||
{
|
||||
@@ -865,6 +807,7 @@ impl Backend {
|
||||
// Grasp servers authorize a push by the state they have seen.
|
||||
let refs = state.refs.clone();
|
||||
let head = state.head.clone();
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
let builder = build_state(&repo_id, &refs, head.as_deref());
|
||||
this.send(builder, cx)
|
||||
@@ -890,8 +833,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr.
|
||||
/// Publish NIP-09 deletions for its announcement, state and activity events.
|
||||
/// Those are issues, pull requests, patches, statuses and comments.
|
||||
///
|
||||
/// Only the repository owner may delete it.
|
||||
pub fn delete_repository(
|
||||
&mut self,
|
||||
@@ -934,7 +876,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` key or a `bunker://...` URI.
|
||||
/// Dispatch on the credential's prefix.
|
||||
pub fn login(&mut self, credential: &str, cx: &mut Context<Self>) {
|
||||
let credential = credential.trim();
|
||||
|
||||
@@ -950,7 +891,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Create a fresh identity and login with it.
|
||||
/// The generated key is persisted in the keyring like any other `nsec` credential.
|
||||
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
|
||||
let nsec = Keys::generate()
|
||||
.secret_key()
|
||||
@@ -960,7 +900,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` secret key.
|
||||
/// The credential is verified by the signer flow and persisted in the keyring.
|
||||
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
|
||||
let keys = match SecretKey::parse(nsec) {
|
||||
Ok(secret) => Keys::new(secret),
|
||||
@@ -985,10 +924,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Login with a `bunker://...` URI, NIP-46.
|
||||
/// A fresh session key is embedded into the stored URI as `?master=<nsec>`.
|
||||
/// No separate keyring entry is needed.
|
||||
/// The auth URL, if any, is opened in the default browser.
|
||||
/// The credential is persisted in the keyring after the signer proves reachable.
|
||||
pub fn login_with_bunker(&mut self, uri: &str, cx: &mut Context<Self>) {
|
||||
let uri_string = uri.trim().to_owned();
|
||||
|
||||
@@ -1052,8 +987,7 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Fetch the user's grasp list of kind `10317`.
|
||||
/// Add the listed grasp servers as relays.
|
||||
/// Fetch the user's grasp list and add the listed grasp servers as relays.
|
||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1106,7 +1040,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// True when the stored credential is NIP-49 encrypted.
|
||||
/// A passphrase is still needed to resume the session.
|
||||
pub fn passphrase_required(&self) -> bool {
|
||||
self.passphrase_required
|
||||
}
|
||||
@@ -1122,14 +1055,11 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any.
|
||||
/// Reported as `total` and `current`.
|
||||
pub fn sync_progress(&self) -> Option<(u64, u64)> {
|
||||
self.sync_progress
|
||||
}
|
||||
|
||||
/// Update the signer.
|
||||
/// Any type implementing the async signer traits works.
|
||||
/// Examples are `Keys`, `NostrConnect` and a browser extension proxy.
|
||||
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
|
||||
where
|
||||
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
|
||||
@@ -1191,7 +1121,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Add discovery-only relays, e.g. NIP-65 indexers, and connect to them.
|
||||
/// No subscriptions or writes are routed through them.
|
||||
pub fn add_discovery_relays(&mut self, urls: Vec<String>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1215,8 +1144,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Start a persistent subscription.
|
||||
/// Matching events are stored in the database automatically.
|
||||
/// They surface as [`BackendEvent::NostrUpdate`].
|
||||
pub fn subscribe(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1231,6 +1158,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Whether an identical fetch started within [`FETCH_DEDUP_WINDOW`] is still recent.
|
||||
///
|
||||
/// Records the fingerprint when returning `false`, pruning expired entries first.
|
||||
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
|
||||
self.recent_fetches
|
||||
@@ -1243,12 +1171,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Connect to a repository's announced relays, its NIP-34 `relays` tag.
|
||||
/// Fetch the repository's events from them.
|
||||
/// Run a one-shot auto-closing subscription for `filters`.
|
||||
/// Then a negentropy sync covers issues, patches and PRs stored only on those relays.
|
||||
/// An identical request within [`FETCH_DEDUP_WINDOW`] is skipped.
|
||||
/// The relays stay in the pool, so later publishes for this repository reach them too.
|
||||
/// Failures are logged, not surfaced.
|
||||
pub fn connect_repo_relays(
|
||||
&mut self,
|
||||
relays: Vec<RelayUrl>,
|
||||
@@ -1278,9 +1200,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// One-shot subscription on the bootstrap relays only.
|
||||
/// Auto-closes after EOSE or a short timeout.
|
||||
/// Matching events are stored in the database.
|
||||
/// They surface as [`BackendEvent::NostrUpdate`] while the subscription is open.
|
||||
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1296,12 +1215,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Negentropy-sync the given filter against the bootstrap relays.
|
||||
/// Reconciles the local database with the relays in both directions.
|
||||
/// Emits [`BackendEvent::SyncProgress`] while running.
|
||||
/// Throttled to whole-percent changes.
|
||||
/// Emits [`BackendEvent::Synced`] on completion.
|
||||
/// An identical sync started within [`FETCH_DEDUP_WINDOW`] is skipped.
|
||||
/// Observers still see the original sync's progress and completion events.
|
||||
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
|
||||
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
|
||||
if self.fetch_recently_started(fingerprint) {
|
||||
@@ -1377,9 +1290,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Sign, broadcast and locally store an event.
|
||||
/// Emits [`BackendEvent::Published`] on success so stores can refresh.
|
||||
/// The task yields the outcome of this specific action for inline progress or errors.
|
||||
/// The caller owns the task, dropping it cancels the publish.
|
||||
pub fn send(
|
||||
&mut self,
|
||||
builder: EventBuilder,
|
||||
@@ -1430,9 +1340,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Broadcast and locally store an already-signed event.
|
||||
/// Like [`Self::send`] without the signing step.
|
||||
/// Callers that signed early use this.
|
||||
/// They may need the event id before pushing a commit to the grasp servers.
|
||||
pub fn publish_event(
|
||||
&mut self,
|
||||
event: Event,
|
||||
@@ -1479,8 +1386,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Publish a NIP-34 repository announcement, kind 30617, with the current signer.
|
||||
/// The returned task yields the published event.
|
||||
/// Callers can show inline progress or errors.
|
||||
pub fn publish_announcement(
|
||||
&mut self,
|
||||
announcement: GitRepositoryAnnouncement,
|
||||
@@ -1490,8 +1395,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Sign, broadcast and store an event without awaiting the result.
|
||||
/// Failures surface through [`BackendEvent::Error`].
|
||||
/// The backend owns the spawned task, so dropping it cancels the task.
|
||||
fn send_fire_and_forget(&mut self, builder: EventBuilder, cx: &mut Context<Self>) {
|
||||
let task = self.send(builder, cx);
|
||||
|
||||
@@ -1507,9 +1410,6 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Publish NIP-09 deletions for `events`, best-effort.
|
||||
/// A publish that fails midway retracts the events already broadcast to relays.
|
||||
/// Failures are logged, not surfaced.
|
||||
/// The caller's error already told the user what happened.
|
||||
fn retract_events(&mut self, events: &[Event], cx: &mut Context<Self>) {
|
||||
if events.is_empty() {
|
||||
return;
|
||||
@@ -1534,6 +1434,7 @@ impl Backend {
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay and filter set, for fetch dedup.
|
||||
///
|
||||
/// Relays and filters are sorted first, so the fingerprint is order-independent.
|
||||
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
let mut relays: Vec<&str> = relays.to_vec();
|
||||
@@ -1548,9 +1449,6 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
}
|
||||
|
||||
/// Add the given relays, connect and fetch the filters.
|
||||
/// Run a one-shot subscription, auto-closing after EOSE, then a negentropy sync per filter.
|
||||
/// The second pass catches events that race the subscription or flaky EOSE behavior.
|
||||
/// Relays without NEG-XX support fail the sync step, the subscription already covered them.
|
||||
async fn connect_repo_relays_only(
|
||||
client: &Client,
|
||||
relays: Vec<RelayUrl>,
|
||||
@@ -1564,8 +1462,8 @@ async fn connect_repo_relays_only(
|
||||
for url in &relays {
|
||||
added |= client.add_relay(url).await?;
|
||||
}
|
||||
|
||||
// Connect only when the pool grew.
|
||||
// Connected relays no-op, but the call still iterates every relay in the pool.
|
||||
if added {
|
||||
client.connect().await;
|
||||
}
|
||||
@@ -1581,8 +1479,6 @@ async fn connect_repo_relays_only(
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
// Sync the filters concurrently.
|
||||
// Each reconciles against every relay either way.
|
||||
// Without NEG-XX a relay would serialize its initial timeout behind every other filter.
|
||||
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||
let syncs = filters.into_iter().map(|filter| {
|
||||
let client = &client;
|
||||
@@ -1599,15 +1495,13 @@ async fn connect_repo_relays_only(
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
futures::future::join_all(syncs).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe only on the bootstrap relays.
|
||||
/// Auto-closes after EOSE or a short timeout.
|
||||
/// Use for one-shot data fetches, repo events and profiles.
|
||||
/// Not for persistent gossip-routed subscriptions.
|
||||
pub(crate) async fn subscribe_bootstrap_only(
|
||||
client: &Client,
|
||||
filters: Vec<Filter>,
|
||||
@@ -1648,16 +1542,14 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
}
|
||||
|
||||
/// Base URL of a grasp server, `https://<host>`.
|
||||
///
|
||||
/// `ws://` grasp servers use `http://<host>`, like ngit.
|
||||
/// The repository then lives at `{base}/{npub}/{repo-id}.git`.
|
||||
pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
// `domain()` drops the port.
|
||||
// Parse the full URL to keep it, local dev grasp servers often run on a custom port.
|
||||
let parsed = Url::parse(relay.as_str()).ok()?;
|
||||
let host = parsed.host_str()?;
|
||||
let port = parsed.port().map(|p| format!(":{p}")).unwrap_or_default();
|
||||
// `ws://` grasp servers, e.g. local dev relays, speak plain HTTP.
|
||||
// Everything else is HTTPS, matching ngit.
|
||||
let scheme = if relay.scheme().is_secure() {
|
||||
"https"
|
||||
} else {
|
||||
@@ -1667,24 +1559,19 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
}
|
||||
|
||||
/// GRASP clone URL of a repository on a grasp server.
|
||||
/// Matches the format ngit announces, `https://<host>/<npub>/<repo-id>.git`.
|
||||
fn grasp_clone_url(relay: &RelayUrl, owner: &str, repo_id: &str) -> Option<Url> {
|
||||
let base = grasp_base_url(relay)?;
|
||||
Url::parse(&format!("{base}/{owner}/{repo_id}.git")).ok()
|
||||
}
|
||||
|
||||
/// GRASP-06 contributor namespace URL of a pull request tip.
|
||||
/// The pattern is `{base}/prs/<author-npub>/<repo-id>.git`.
|
||||
/// The npub sits in the URL, the server stores it under the hex form.
|
||||
/// Anyone may push there, no announcement or maintainer rights are involved.
|
||||
pub(crate) fn grasp06_prs_url(base_url: &str, npub: &str, repo_id: &str) -> String {
|
||||
format!("{base_url}/prs/{npub}/{repo_id}.git")
|
||||
}
|
||||
|
||||
/// Assemble the `clone` URLs of a pull request.
|
||||
///
|
||||
/// The author's GRASP-06 `/prs/` URLs come first.
|
||||
/// They are author-controlled and most likely to accept the tip push.
|
||||
/// The base announcement's clone URLs follow, deduplicated while preserving order.
|
||||
pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Vec<Url> {
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
let mut urls = Vec::new();
|
||||
@@ -1697,7 +1584,6 @@ pub(crate) fn pr_clone_urls(prs_urls: Vec<Url>, base_clone_urls: Vec<Url>) -> Ve
|
||||
}
|
||||
|
||||
/// The `g` tag servers of one kind-10317 grasp list event, in tag order.
|
||||
/// Unparseable URLs are dropped, the UI only writes well-formed servers.
|
||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
event
|
||||
.tags
|
||||
@@ -1709,8 +1595,6 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
}
|
||||
|
||||
/// Grasp servers of the newest kind-10317 grasp list among `events`.
|
||||
/// The latest event wins, like other latest-wins resolutions in the app.
|
||||
/// Empty when there is no list, so the caller falls back to the settings defaults.
|
||||
fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
events
|
||||
.into_iter()
|
||||
@@ -1720,9 +1604,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
}
|
||||
|
||||
/// Resolve the user's published grasp servers.
|
||||
/// Read the `g` tags of their latest kind-10317 grasp list in the local database.
|
||||
/// Returns an empty list when the user has no published list.
|
||||
/// The caller can then fall back to the settings defaults.
|
||||
pub(crate) async fn user_grasp_list_servers(
|
||||
client: Client,
|
||||
user: PublicKey,
|
||||
@@ -1737,10 +1618,6 @@ pub(crate) async fn user_grasp_list_servers(
|
||||
}
|
||||
|
||||
/// Push the repository at `path` to every grasp server.
|
||||
/// Rejecting servers are logged, the push only fails when no server accepted it.
|
||||
/// `push` performs the single-server push.
|
||||
/// [`signed_git::push_main`] serves the create flow.
|
||||
/// [`signed_git::push_all`] serves the init flow.
|
||||
async fn push_to_grasp_servers(
|
||||
path: PathBuf,
|
||||
owner: String,
|
||||
@@ -1777,7 +1654,6 @@ async fn push_to_grasp_servers(
|
||||
}
|
||||
|
||||
/// Split a stored bunker credential into the plain URI and the session key.
|
||||
/// Credentials without an embedded key, legacy, get a fresh one.
|
||||
fn extract_master_key(credential: &str) -> (&str, Keys) {
|
||||
match credential.split_once("master=") {
|
||||
Some((base, nsec)) => {
|
||||
|
||||
@@ -16,16 +16,12 @@ use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation.
|
||||
/// Bursts of notifications, settings edits and rescan ticks, collapse into one pass.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How often the statuses of open repository panels are refreshed.
|
||||
/// A commit or pull in external git surfaces in the banner without reopening the panel.
|
||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||
|
||||
/// Background poll interval for the `ready to push` badges of the user's own repositories.
|
||||
/// Used when no repository panel is open.
|
||||
/// Each cycle refreshes the remote view of the checkouts with a git fetch.
|
||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||
|
||||
/// Maximum checkouts considered per repository when computing statuses.
|
||||
@@ -36,6 +32,7 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
impl Global for GlobalCheckoutsStore {}
|
||||
|
||||
/// One associated local checkout of a repository.
|
||||
///
|
||||
/// Carries the git facts needed to suggest a pull request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CheckoutStatus {
|
||||
@@ -50,9 +47,11 @@ pub struct CheckoutStatus {
|
||||
/// The fallbacks are `main`, then the first local branch.
|
||||
/// For ready-to-push statuses, the remote-tracking ref.
|
||||
/// Unpushed commits are counted against it.
|
||||
///
|
||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||
pub base: String,
|
||||
/// Commits in `base..branch`.
|
||||
///
|
||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
||||
pub ahead: u32,
|
||||
}
|
||||
@@ -67,21 +66,21 @@ struct Remembered {
|
||||
/// Global store of local-checkout associations and per-checkout statuses.
|
||||
pub struct CheckoutsStore {
|
||||
/// Checkout paths per announced repository.
|
||||
/// Remembered records, freshest first, plus scanned repos matched implicitly.
|
||||
/// Deduplicated by path.
|
||||
/// Missing directories are dropped before publishing.
|
||||
by_repo: Arc<HashMap<RepoAddr, Vec<PathBuf>>>,
|
||||
/// Ready-to-contribute statuses of the requested repositories.
|
||||
statuses: Arc<HashMap<RepoAddr, Vec<CheckoutStatus>>>,
|
||||
/// Repositories whose statuses are recomputed on every input change.
|
||||
///
|
||||
/// Those are the repository detail panels currently open.
|
||||
status_requested: HashSet<RepoAddr>,
|
||||
/// Repositories whose `ready to push` statuses are recomputed on the same cycle.
|
||||
///
|
||||
/// 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>>>,
|
||||
/// Last announced head branch per requested repository.
|
||||
///
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
@@ -103,9 +102,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Create the store.
|
||||
/// Observe the inputs, settings records, the local scan and the announcement list.
|
||||
/// Signer changes also trigger a refresh.
|
||||
/// Associations are resolved right away.
|
||||
pub fn new(cx: &mut Context<Self>) -> Self {
|
||||
let mut subscriptions = Vec::new();
|
||||
|
||||
@@ -118,12 +114,15 @@ impl CheckoutsStore {
|
||||
subscriptions.push(cx.observe(&settings, |this, _settings, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
|
||||
// Another identity's repositories must not keep the old statuses alive.
|
||||
// Their polls stop too.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
@@ -155,12 +154,11 @@ impl CheckoutsStore {
|
||||
if !cfg!(target_arch = "wasm32") {
|
||||
store.refresh(cx);
|
||||
}
|
||||
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use.
|
||||
/// Re-insert the record with a fresh timestamp.
|
||||
/// Freshest-first ordering then follows actual use.
|
||||
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
return;
|
||||
@@ -190,6 +188,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The associated checkouts of `addr`, freshest first.
|
||||
///
|
||||
/// Empty when none are known or the resolution has not run yet.
|
||||
pub fn associations_of(&self, addr: &RepoAddr) -> Vec<PathBuf> {
|
||||
self.by_repo.get(addr).cloned().unwrap_or_default()
|
||||
@@ -197,6 +196,7 @@ impl CheckoutsStore {
|
||||
|
||||
/// Ask for the `ready to contribute` statuses of `addr` to stay current.
|
||||
/// Called while the repository's detail panel is open.
|
||||
///
|
||||
/// `announced_head` is the announced HEAD branch, used to default the base.
|
||||
pub fn request_statuses(
|
||||
&mut self,
|
||||
@@ -212,16 +212,13 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// The ready-to-contribute statuses of `addr`.
|
||||
///
|
||||
/// Empty while none are known or nothing is ahead.
|
||||
pub fn statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Ask for the `ready to push` statuses of `addr` to stay current.
|
||||
/// The sidebar and the detail panels call this for the user's own repositories.
|
||||
/// Recomputed on every input change and on a background poll.
|
||||
/// Each cycle refreshes the remote view first.
|
||||
/// A commit made in external git surfaces within one poll interval.
|
||||
pub fn request_push_statuses(&mut self, addr: &RepoAddr, cx: &mut Context<Self>) {
|
||||
self.push_requested.insert(addr.clone());
|
||||
self.refresh(cx);
|
||||
@@ -229,13 +226,14 @@ impl CheckoutsStore {
|
||||
|
||||
/// The ready-to-push statuses of `addr`.
|
||||
/// Only meaningful for repositories announced by the signed-in user.
|
||||
///
|
||||
/// Empty while none are known or nothing is unpushed.
|
||||
pub fn push_statuses_of(&self, addr: &RepoAddr) -> Vec<CheckoutStatus> {
|
||||
self.push_statuses.get(addr).cloned().unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Re-resolve the associations and the requested statuses.
|
||||
/// Debounced, bursts of notifications collapse into one pass.
|
||||
///
|
||||
/// Requests arriving while a pass runs fold into a follow-up.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
@@ -268,6 +266,7 @@ impl CheckoutsStore {
|
||||
let settings = SettingsStore::global(cx);
|
||||
settings.read(cx).settings().checkouts.records.clone()
|
||||
};
|
||||
|
||||
let remembered: Vec<Remembered> = records
|
||||
.into_iter()
|
||||
.filter_map(|record| {
|
||||
@@ -279,9 +278,11 @@ impl CheckoutsStore {
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
let announcements = RepoListStore::global(cx).read(cx).announcements.clone();
|
||||
let scanned = LocalReposStore::global(cx).read(cx).repos.clone();
|
||||
let cache_root = GitStore::global(cx).cache().root().canonicalize().ok();
|
||||
|
||||
let requested: Vec<(RepoAddr, Option<String>)> = self
|
||||
.status_requested
|
||||
.iter()
|
||||
@@ -292,16 +293,17 @@ impl CheckoutsStore {
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let push_requested: Vec<RepoAddr> = self.push_requested.iter().cloned().collect();
|
||||
let poll = !self.status_requested.is_empty() || !self.push_requested.is_empty();
|
||||
|
||||
let work = cx.background_spawn(async move {
|
||||
// Read the git facts of every scanned repository off the main thread.
|
||||
//
|
||||
// The facts are the origin URL and the root commit, both CLI reads.
|
||||
let mut facts: Vec<(PathBuf, Option<String>, Option<String>)> = Vec::new();
|
||||
for path in scanned.iter() {
|
||||
// The browser's mirror clones share the announce URLs and EUCs.
|
||||
// They are not user checkouts.
|
||||
// The browser's mirror clones share the announce URLs and EUCs. They are not user checkouts.
|
||||
if cache_root
|
||||
.as_ref()
|
||||
.is_some_and(|root| path.starts_with(root))
|
||||
@@ -314,6 +316,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||
|
||||
// Missing directories are stale records, drop them.
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
@@ -384,20 +387,17 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
// Keep the statuses current while any repository panel is open.
|
||||
// The user's own repositories also count when watched for the sidebar badge.
|
||||
// Local commits, pulls and branch switches happen outside the app.
|
||||
// They are not otherwise observable.
|
||||
this.update(cx, |this, cx| {
|
||||
if poll && !this.debouncing && !this.refreshing {
|
||||
this.debouncing = true;
|
||||
// Open panels get the fast cadence.
|
||||
// Sidebar-only badges poll less aggressively.
|
||||
// Each cycle fetches every watched checkout's remote.
|
||||
let delay = if this.status_requested.is_empty() {
|
||||
PUSH_POLL
|
||||
} else {
|
||||
STATUS_POLL
|
||||
};
|
||||
|
||||
let task = cx.spawn(async move |this, cx| {
|
||||
cx.background_executor().timer(delay).await;
|
||||
this.update(cx, |this, cx| {
|
||||
@@ -405,6 +405,7 @@ impl CheckoutsStore {
|
||||
this.run_refresh(cx);
|
||||
})
|
||||
});
|
||||
|
||||
this.tasks.push(task);
|
||||
}
|
||||
})?;
|
||||
@@ -415,10 +416,6 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
/// Identity of a repository URL.
|
||||
/// Host, explicit port and path count, with a trailing `.git` and slashes stripped.
|
||||
/// Scheme-insensitive, so `ws`, `wss`, `http`, `https` and `grasp` are one transport.
|
||||
/// `None` for unparseable URLs, e.g. `git@`-style or plain paths.
|
||||
/// Those then compare by raw string.
|
||||
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
let parsed = Url::parse(url).ok()?;
|
||||
let host = parsed.host_str()?.to_ascii_lowercase();
|
||||
@@ -430,7 +427,6 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository.
|
||||
/// Ignores the transport scheme, see [`url_identity`].
|
||||
fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
match (url_identity(a), url_identity(b)) {
|
||||
(Some(a), Some(b)) => a == b,
|
||||
@@ -439,9 +435,6 @@ fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced repositories.
|
||||
/// Remembered records come first, freshest first per repository.
|
||||
/// Scanned repositories matched by origin URL or EUC follow.
|
||||
/// Deduplicated by path, remembered entries win.
|
||||
fn resolve_associations<'a>(
|
||||
remembered: &[Remembered],
|
||||
scanned: &[(PathBuf, Option<String>, Option<String>)],
|
||||
@@ -467,9 +460,11 @@ fn resolve_associations<'a>(
|
||||
.iter()
|
||||
.any(|url| same_repo_url(origin, url.as_str()))
|
||||
});
|
||||
|
||||
let euc_match = root
|
||||
.as_deref()
|
||||
.is_some_and(|root| announcement.euc.as_deref() == Some(root));
|
||||
|
||||
if url_match || euc_match {
|
||||
let paths = out.entry(announcement.addr()).or_default();
|
||||
if !paths.contains(path) {
|
||||
@@ -483,8 +478,6 @@ fn resolve_associations<'a>(
|
||||
}
|
||||
|
||||
/// Whether the worktree of `path` has uncommitted changes.
|
||||
/// A dirty checkout is never suggested.
|
||||
/// The proposal should cover committed work.
|
||||
fn worktree_dirty(path: &Path) -> bool {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -499,8 +492,6 @@ fn worktree_dirty(path: &Path) -> bool {
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `path`.
|
||||
/// Reads `git rev-list --count`.
|
||||
/// `0` when the range is empty or cannot be computed.
|
||||
fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -518,7 +509,6 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
}
|
||||
|
||||
/// The branch checked out at `path`, read via `git branch --show-current`.
|
||||
/// `None` when detached.
|
||||
fn current_branch_of(path: &Path) -> Option<String> {
|
||||
let output = Command::new("git")
|
||||
.arg("-C")
|
||||
@@ -532,10 +522,6 @@ fn current_branch_of(path: &Path) -> Option<String> {
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout.
|
||||
/// `None` when idle.
|
||||
/// Idle means detached HEAD, no branches, a dirty worktree or nothing ahead of its base.
|
||||
/// The base defaults like the New PR panel.
|
||||
/// The announced HEAD branch when the checkout has it, else `main`, else the first branch.
|
||||
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
|
||||
let branches = signed_git::worktree_branches(path).ok()?;
|
||||
if branches.is_empty() || worktree_dirty(path) {
|
||||
@@ -562,6 +548,7 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
}
|
||||
|
||||
/// Whether the reference `name` exists in the checkout at `path`.
|
||||
///
|
||||
/// Example, `refs/remotes/origin/main`.
|
||||
fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
let output = Command::new("git")
|
||||
@@ -574,11 +561,6 @@ fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
}
|
||||
|
||||
/// The `ready to push` status of one checkout of the user's own repository.
|
||||
/// The checked-out branch has commits the grasp servers do not have yet.
|
||||
/// The remote view is refreshed first, best-effort.
|
||||
/// Offline, the last known remote state still counts commits made since.
|
||||
/// Detached checkouts, dirty worktrees and an unknown remote state yield no status.
|
||||
/// Branches the remote does not have yet are counted against the remote HEAD.
|
||||
fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
if worktree_dirty(path) {
|
||||
return None;
|
||||
@@ -612,9 +594,6 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` already proposes the same change as `checkout`.
|
||||
/// `pr` is a kind-1618 root, resolved `open` by the caller.
|
||||
/// Matches when authored by `user` with a matching `branch-name` tag.
|
||||
/// For renamed branches, a `c` tip tag matching the checkout's HEAD commit counts.
|
||||
pub fn pr_proposes_checkout(
|
||||
pr: &Event,
|
||||
open: bool,
|
||||
|
||||
@@ -8,13 +8,12 @@ struct GlobalGitStore(GitCache);
|
||||
impl Global for GlobalGitStore {}
|
||||
|
||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||
/// Installed at startup via [`GitStore::set_global`].
|
||||
/// See also [`signed_state::init`].
|
||||
#[derive(Debug, Clone)]
|
||||
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);
|
||||
@@ -23,7 +22,6 @@ impl GitStore {
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
/// Panics if [`GitStore::set_global`] was never called.
|
||||
pub fn global(cx: &App) -> Self {
|
||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||
}
|
||||
|
||||
@@ -46,9 +46,6 @@ impl LocalReposStore {
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
/// It leaves the local list immediately.
|
||||
/// A later rescan re-discovers it from disk.
|
||||
/// The sidebar also hides published repositories by identifier.
|
||||
pub fn remove(&mut self, path: &Path, cx: &mut Context<Self>) {
|
||||
self.repos = Arc::new(
|
||||
self.repos
|
||||
|
||||
@@ -63,6 +63,7 @@ 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,
|
||||
}
|
||||
@@ -71,8 +72,8 @@ enum Dispatch {
|
||||
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.
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
/// Public keys requested this session, main thread only.
|
||||
@@ -114,7 +115,6 @@ impl ProfileStore {
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel.
|
||||
// A background task syncs them in batches.
|
||||
let client = backend.read(cx).client();
|
||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||
let (dispatch_tx, dispatch_rx) = flume::unbounded::<Dispatch>();
|
||||
@@ -146,8 +146,8 @@ impl ProfileStore {
|
||||
}
|
||||
|
||||
/// Get a profile.
|
||||
/// Returns a placeholder with default metadata.
|
||||
/// Queues a fetch when the profile is not cached yet.
|
||||
///
|
||||
/// 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();
|
||||
@@ -237,6 +237,7 @@ impl ProfileStore {
|
||||
}
|
||||
|
||||
/// 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();
|
||||
@@ -292,7 +293,7 @@ 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.
|
||||
async fn handle_requests(
|
||||
client: &Client,
|
||||
|
||||
@@ -20,14 +20,15 @@ use crate::backend::{
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
/// Bursts of events, e.g. per-event `NostrUpdate`s, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// Maximum size of one patch event.
|
||||
///
|
||||
/// NIP-34 suggests patches when each event is under 60kb.
|
||||
const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
||||
|
||||
/// Per-repository store.
|
||||
///
|
||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||
/// Always derived from the local database.
|
||||
pub struct RepoStore {
|
||||
@@ -43,30 +44,32 @@ pub struct RepoStore {
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
pub comments: Vec<Event>,
|
||||
/// Resolved status per root event, issue, patch or PR.
|
||||
/// Recomputed on every refresh.
|
||||
/// Render paths are HashMap lookups instead of per-root status scans.
|
||||
/// Those scans are quadratic, with an allocation per pair.
|
||||
status_by_root: HashMap<EventId, RepoStatus>,
|
||||
/// Open issue and root PR counts.
|
||||
/// Computed with [`Self::status_by_root`] on every refresh.
|
||||
open_issue_count: usize,
|
||||
open_pr_count: usize,
|
||||
/// Kind-1624 cover notes and kind-1985 label events.
|
||||
///
|
||||
/// They reference this repository's roots, used by ngit and GitWorkshop.
|
||||
cover_notes: Vec<Event>,
|
||||
labels: Vec<Event>,
|
||||
/// Incremented on every applied refresh.
|
||||
///
|
||||
/// Views key their derived-data caches to it instead of recomputing on every render.
|
||||
version: u64,
|
||||
/// Error of the last action initiated from this store, if any.
|
||||
pub last_error: Option<String>,
|
||||
/// Non-fatal warning of the last action, if any.
|
||||
///
|
||||
/// Example, a PR published without its commit reaching a grasp server.
|
||||
pub last_warning: Option<String>,
|
||||
/// Relays already asked to connect to, from this repository's NIP-34 `relays` tag.
|
||||
///
|
||||
/// Avoids re-subscribing and re-fetching on every refresh.
|
||||
repo_relays: HashSet<RelayUrl>,
|
||||
/// Root events, issues, patches and PRs, already fetched per root.
|
||||
///
|
||||
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
|
||||
/// Also kind-1624 cover notes and kind-1985 labels.
|
||||
root_fetches: HashSet<EventId>,
|
||||
@@ -172,6 +175,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Filters that make up a repository.
|
||||
///
|
||||
/// Announcement, state, activity and deletions targeting it.
|
||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||
let mut filters = vec![
|
||||
@@ -189,14 +193,13 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
|
||||
/// Deduplicated, each relay is contacted once per store.
|
||||
/// Refreshes after the first are no-ops unless the announcement lists new relays.
|
||||
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
|
||||
let new: Vec<RelayUrl> = relays
|
||||
.iter()
|
||||
.filter(|url| !self.repo_relays.contains(*url))
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
@@ -204,6 +207,7 @@ impl RepoStore {
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
let addr = self.addr.clone();
|
||||
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
|
||||
});
|
||||
@@ -220,8 +224,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Re-query the local database and update all fields.
|
||||
/// The query and processing run on a background thread.
|
||||
/// Only the results are applied on the main thread.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
@@ -527,7 +529,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// The effective cover note of `root`, kind 1624, if any.
|
||||
/// The latest note authored by the root author or a maintainer.
|
||||
pub fn cover_note_of(&self, root: &Event) -> Option<&Event> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -539,7 +540,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// The effective hashtag labels of `root`.
|
||||
/// Its own `t` tags plus labels from NIP-32 kind-1985 events in the `#t` namespace.
|
||||
pub fn labels_of(&self, root: &Event) -> Vec<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -552,7 +552,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// The effective subject or title override of `root`, if any.
|
||||
/// Comes from authorized kind-1985 events in the `#subject` namespace.
|
||||
pub fn subject_of(&self, root: &Event) -> Option<String> {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
@@ -564,6 +563,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Number of open issues.
|
||||
///
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
/// Issues without status events default to open.
|
||||
pub fn issue_count(&self) -> usize {
|
||||
@@ -571,6 +571,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Number of open pull requests.
|
||||
///
|
||||
/// Only root PR events count, PR updates do not.
|
||||
/// They must resolve to [`RepoStatus::Open`].
|
||||
pub fn pull_request_count(&self) -> usize {
|
||||
@@ -578,6 +579,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Whether `user` is the author or owner of this repository.
|
||||
///
|
||||
/// The author is the public key of the repository address.
|
||||
/// Only the author may manage pull requests, close, reopen or merge.
|
||||
pub fn is_author(&self, user: &PublicKey) -> bool {
|
||||
@@ -610,6 +612,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Reply to `parent`, a comment on `root`, with a NIP-22 threaded comment.
|
||||
///
|
||||
/// `None` publishes a top-level comment on the root itself.
|
||||
pub fn reply(
|
||||
&mut self,
|
||||
@@ -631,31 +634,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Open a pull request on this repository.
|
||||
/// A root PR event, kind 1618, carries the markdown description.
|
||||
/// A root patch event, kind 1617, carries the `git format-patch` output.
|
||||
/// The PR references the patch via an `e` tag, NIP-34.
|
||||
/// The patch series is published first.
|
||||
/// One kind-1617 event per commit, chained with NIP-10 `e` replies.
|
||||
/// Each event stays under [`MAX_PATCH_EVENT_BYTES`].
|
||||
/// The PR then references the root patch's id.
|
||||
/// The proposed commit is the series tip.
|
||||
/// It comes from the last `From <commit>` header.
|
||||
/// Publishing is refused without one.
|
||||
/// The PR's `c` tag must carry a real commit id.
|
||||
/// Other NIP-34 clients verify and apply the proposal from it.
|
||||
/// The `clone` tag lists the author's GRASP-06 `/prs/` URLs first.
|
||||
/// Taken from the author's kind-10317 grasp list, else the settings defaults.
|
||||
/// The announced mirror URLs follow.
|
||||
/// The tip stays downloadable on the author's hosting.
|
||||
/// This holds even when the base project accepts nothing.
|
||||
/// With `push_from` set, the tip is pushed to those servers.
|
||||
/// The ref is `refs/nostr/<event-id>`, author servers first, best-effort.
|
||||
/// The push happens before the PR is published.
|
||||
/// The linked patch stays the source of truth either way.
|
||||
/// `branch_name` lands in the PR's `branch-name` tag, NIP-34.
|
||||
/// `draft` publishes a kind-1633 status right after the PR event.
|
||||
/// `merge_base` is the hex commit the proposed branch forked from.
|
||||
/// It is computed from a local checkout when the patch was generated there.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn open_pull_request(
|
||||
&mut self,
|
||||
@@ -712,13 +690,13 @@ impl RepoStore {
|
||||
return;
|
||||
};
|
||||
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
||||
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
|
||||
let author_npub = user.to_bech32().unwrap();
|
||||
|
||||
let addr = self.addr.clone();
|
||||
let owner = self.addr.public_key;
|
||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
let repo_id = addr.identifier.clone();
|
||||
let base_npub = owner.to_bech32().unwrap_or_else(|_| owner.to_hex());
|
||||
let base_npub = owner.to_bech32().unwrap();
|
||||
let push_relays = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -930,10 +908,7 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Update a pull request.
|
||||
/// Publish revision patch events chained to the original root patch.
|
||||
/// The first event carries `t root-revision` and a NIP-10 `e` reply, per NIP-34.
|
||||
/// Then a kind-1619 PR update event carries the new tip.
|
||||
/// Only the PR author may update it.
|
||||
///
|
||||
/// Other authors must open a new PR.
|
||||
pub fn update_pull_request(&mut self, root: &Event, patch: String, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
@@ -1054,8 +1029,8 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Set the status of a root event.
|
||||
///
|
||||
/// Only the root author or a maintainer may set it, per NIP-34.
|
||||
/// Status events from anyone else are ignored by clients, so refuse them up front.
|
||||
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -1092,19 +1067,18 @@ impl RepoStore {
|
||||
self.send(builder, cx);
|
||||
}
|
||||
|
||||
/// Publish a repository state announcement, kind 30618.
|
||||
/// It carries the local clone's branches, tags and HEAD.
|
||||
/// Only the repository owner may publish state.
|
||||
/// A local clone must exist to read the refs from.
|
||||
/// Publish a repository state announcement
|
||||
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
let backend = Backend::global(cx);
|
||||
|
||||
let Some(user) = backend.read(cx).current_user() else {
|
||||
self.last_error = Some("Sign in to publish repository state".into());
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
|
||||
if !self.is_author(&user) {
|
||||
self.last_error = Some("Only the repository owner can publish state".into());
|
||||
cx.notify();
|
||||
@@ -1146,16 +1120,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Merge a pull request.
|
||||
/// Apply its patch, the linked root patch event's content, to the local clone.
|
||||
/// Then publish a kind-1631 Applied status event with merge provenance.
|
||||
/// The provenance covers the commits `git am` created.
|
||||
/// They appear as `applied-as-commits` and `r` tags.
|
||||
/// It also tags the applied patch events.
|
||||
/// `q` tags per event and `e` replies for every patch beyond the root, NIP-34.
|
||||
/// Only the repository author may merge.
|
||||
/// The clone is created on demand from the announcement's clone URLs.
|
||||
/// Patch application, `git am`, runs on a background thread.
|
||||
/// Failures, e.g. a patch that no longer applies, surface in [`Self::last_error`].
|
||||
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
self.last_warning = None;
|
||||
@@ -1171,23 +1135,28 @@ impl RepoStore {
|
||||
|
||||
let cache = GitStore::global(cx).cache().clone();
|
||||
let addr = self.addr.clone();
|
||||
|
||||
let clone_urls: Vec<String> = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(|a| a.clone.iter().map(ToString::to_string).collect())
|
||||
.unwrap_or_default();
|
||||
|
||||
let patch = pull_request_patch(root, self.patches.iter());
|
||||
|
||||
// The applied patch events, for the status tags below.
|
||||
let patches: Vec<Event> = pull_request_patches(root, self.patches.iter())
|
||||
.into_iter()
|
||||
.cloned()
|
||||
.collect();
|
||||
|
||||
let relay_hint = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.and_then(|a| a.relays.first())
|
||||
.map(ToString::to_string)
|
||||
.unwrap_or_default();
|
||||
|
||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||
let root = root.clone();
|
||||
|
||||
@@ -1231,9 +1200,6 @@ impl RepoStore {
|
||||
}
|
||||
|
||||
/// Publish a kind-1631 Applied status event for `root` after a merge.
|
||||
/// `applied-as-commits` and `r` tags name the commits `git am` created.
|
||||
/// `q` tags name the applied patch events.
|
||||
/// `e` reply tags cover every patch of the series beyond the root, NIP-34.
|
||||
fn publish_applied_status(
|
||||
&mut self,
|
||||
root: &Event,
|
||||
@@ -1249,11 +1215,13 @@ impl RepoStore {
|
||||
Tag::public_key(root.pubkey),
|
||||
Tag::coordinate(self.addr.clone(), None),
|
||||
];
|
||||
|
||||
if let Some(euc) = euc
|
||||
&& let Ok(tag) = Tag::parse(["r", euc])
|
||||
{
|
||||
tags.push(tag);
|
||||
}
|
||||
|
||||
// Tag each applied patch event.
|
||||
// `q` per event, `e` reply for events beyond the root, chain parts and revisions.
|
||||
// Their statuses then resolve to Applied too.
|
||||
@@ -1269,6 +1237,7 @@ impl RepoStore {
|
||||
tags.push(tag);
|
||||
}
|
||||
}
|
||||
|
||||
// The commits `git am` created on top of the previous HEAD.
|
||||
if !applied.is_empty() {
|
||||
let mut applied_tag = vec!["applied-as-commits".to_string()];
|
||||
@@ -1312,8 +1281,6 @@ where
|
||||
}
|
||||
|
||||
/// Status of `root` from the precomputed map.
|
||||
/// Roots without status events default to [`RepoStatus::Open`].
|
||||
/// Matches [`signed_core::resolve_status`].
|
||||
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
|
||||
status_by_root
|
||||
.get(&root.id)
|
||||
@@ -1322,9 +1289,6 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
|
||||
}
|
||||
|
||||
/// Resolve every root event's status in one pass.
|
||||
/// Status events are indexed by the root they reference, the `e` or `E` tag.
|
||||
/// Each root resolves against its own slice.
|
||||
/// Linear in roots and statuses, per-root resolution is their product.
|
||||
fn resolve_statuses(
|
||||
issues: &[Event],
|
||||
patches: &[Event],
|
||||
@@ -1373,12 +1337,8 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
}
|
||||
|
||||
/// Publish a `git format-patch` series as chained kind-1617 events.
|
||||
///
|
||||
/// Returns the root event, the one a PR references.
|
||||
/// The first part carries `first_marker`.
|
||||
/// That is `t root`, or `t root-revision` with an `e` reply to `reply_to` for revisions.
|
||||
/// Every later part replies to the previous one, NIP-34.
|
||||
/// Every part gets the repository coordinate, the owner and its `commit` and `r` tags.
|
||||
/// The repository EUC is added when known.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn publish_patch_series(
|
||||
this: &WeakEntity<RepoStore>,
|
||||
@@ -1446,10 +1406,6 @@ async fn publish_patch_series(
|
||||
}
|
||||
|
||||
/// Build a NIP-22 kind-1111 comment.
|
||||
/// Uppercase `E`, `K` and `P` tags scope the thread root.
|
||||
/// Lowercase `e`, `k` and `p` tag the direct parent, or the root for a top-level comment.
|
||||
/// An `a` tag with the repository coordinate is added, not part of NIP-22.
|
||||
/// Signed's own activity subscriptions then match it too.
|
||||
fn comment_builder(
|
||||
root: &Event,
|
||||
parent: Option<&Event>,
|
||||
@@ -1465,6 +1421,7 @@ fn comment_builder(
|
||||
relay_hint.cloned().map(Cow::Owned),
|
||||
)
|
||||
};
|
||||
|
||||
let root_target = target(root);
|
||||
let parent_target = parent.map(target).unwrap_or_else(|| root_target.clone());
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
|
||||
/// 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);
|
||||
|
||||
@@ -21,13 +22,12 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||
/// Each patch event is a pushed commit or a small series.
|
||||
/// That is the closest proxy for commit count in the event data.
|
||||
#[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.
|
||||
///
|
||||
/// PR updates are not new PRs and do not count.
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
@@ -42,9 +42,6 @@ impl RepoActivityCounts {
|
||||
}
|
||||
|
||||
/// Store listing repository announcements, global discovery or per-author.
|
||||
/// The all-repos store, `author: None`, is created at startup by [`crate::init`].
|
||||
/// Installed as a global.
|
||||
/// The explore panel renders from the local database without waiting for relays.
|
||||
pub struct RepoListStore {
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
@@ -52,6 +49,7 @@ pub struct RepoListStore {
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// 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>,
|
||||
@@ -65,7 +63,6 @@ pub struct RepoListStore {
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store.
|
||||
/// It lists all announcements and is created at startup by [`crate::init`].
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalRepoListStore>().0.clone()
|
||||
}
|
||||
@@ -96,12 +93,15 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == event.pubkey);
|
||||
let kind_match = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author_match = this.author.is_none_or(|a| a == event.pubkey);
|
||||
let announcement = kind_match && author_match;
|
||||
|
||||
// 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,
|
||||
@@ -156,9 +156,9 @@ impl RepoListStore {
|
||||
}
|
||||
|
||||
/// One-shot initial load.
|
||||
///
|
||||
/// Query the local database immediately, no debounce.
|
||||
/// Stored announcements appear as soon as the app opens.
|
||||
/// Only called from [`Self::new`], before any refresh can be pending.
|
||||
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
|
||||
debug_assert!(!self.debouncing);
|
||||
if self.refreshing {
|
||||
@@ -169,11 +169,6 @@ impl RepoListStore {
|
||||
}
|
||||
|
||||
/// Re-query the local database.
|
||||
/// The latest announcement per repository wins.
|
||||
/// A short debounce collapses bursts of requests, e.g. sync progress ticks.
|
||||
/// Requests that arrive while a query runs fold into one follow-up query.
|
||||
/// The query and processing run on a background thread.
|
||||
/// Only the results are applied on the main thread.
|
||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||
if self.refreshing {
|
||||
self.refresh_dirty = true;
|
||||
|
||||
Reference in New Issue
Block a user