update
This commit is contained in:
+233
-245
@@ -18,8 +18,9 @@ use signed_nostr::{SignedAuthUrlHandler, UniversalSigner, Update};
|
||||
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Keyring entry holding the user credential (`nsec1...` or `bunker://...`
|
||||
/// with an embedded `?master=<nsec>` NIP-46 session key).
|
||||
/// 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;
|
||||
@@ -32,34 +33,35 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
"wss://profiles.nostr1.com",
|
||||
];
|
||||
|
||||
/// Relays used for indexing user's relay list (NIP-65).
|
||||
/// Relays used to index the user's NIP-65 relay list.
|
||||
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||
|
||||
/// How long an identical fetch/sync request is suppressed after it started.
|
||||
/// A second panel for the same repository (or the global and per-author
|
||||
/// list stores at login) doesn't duplicate a sync that just ran; after the
|
||||
/// window, re-fetching is allowed again so data stays fresh.
|
||||
/// 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 (`ncryptsec1...`); a
|
||||
/// passphrase is required to decrypt it before the session can resume.
|
||||
/// The stored identity is NIP-49 encrypted, an `ncryptsec1...` key.
|
||||
/// A passphrase is required to decrypt it before the session can resume.
|
||||
PassphraseRequired,
|
||||
/// The signer has changed (login/logout/account switch).
|
||||
/// The signer changed on login, logout or account switch.
|
||||
SignerChanged,
|
||||
/// Relay bootstrap finished.
|
||||
Connected,
|
||||
/// 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).
|
||||
/// 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`/`total` progress.
|
||||
/// 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,
|
||||
@@ -81,28 +83,29 @@ impl BackendEvent {
|
||||
}
|
||||
}
|
||||
|
||||
/// Global backend entity: owns the nostr client, the signer and the
|
||||
/// notification pump. Stores subscribe to [`BackendEvent`] and re-query the
|
||||
/// local database when relevant updates arrive.
|
||||
/// 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,
|
||||
current_user: Option<PublicKey>,
|
||||
connected: bool,
|
||||
sync_progress: Option<(u64, u64)>,
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
/// 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/syncs (relay + filter set),
|
||||
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
|
||||
/// one. Entries are pruned lazily on the next request.
|
||||
/// 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 a push (mirror or checkout) is currently in flight
|
||||
/// for. Concurrent pushes of the same refs — two panels of the same
|
||||
/// repository, or the banner push racing the header's Republish — make
|
||||
/// the losing push fail server-side with a compare-and-swap rejection
|
||||
/// ("cannot lock ref … is at … but expected …"), so pushes are
|
||||
/// single-flight per repository.
|
||||
/// 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>>>,
|
||||
}
|
||||
@@ -111,9 +114,8 @@ struct GlobalBackend(Entity<Backend>);
|
||||
|
||||
impl Global for GlobalBackend {}
|
||||
|
||||
/// Removes its repository from the in-flight push set when dropped, so a
|
||||
/// push task that is cancelled (e.g. its panel closed mid-push) can never
|
||||
/// leave the repository locked for the rest of the session.
|
||||
/// 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>>>,
|
||||
addr: RepoAddr,
|
||||
@@ -179,8 +181,9 @@ impl Backend {
|
||||
this
|
||||
}
|
||||
|
||||
/// Bootstrap the client: connect to the default relays (indexers as
|
||||
/// discovery-only) and restore the saved session, if any.
|
||||
/// 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();
|
||||
|
||||
@@ -217,10 +220,9 @@ impl Backend {
|
||||
self.restore_session(cx);
|
||||
}
|
||||
|
||||
/// Restore the saved session from the keyring. Emits
|
||||
/// [`BackendEvent::SignerRequired`] if no credential is stored, or
|
||||
/// [`BackendEvent::PassphraseRequired`] if the stored identity is
|
||||
/// NIP-49 encrypted.
|
||||
/// 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") {
|
||||
cx.emit(BackendEvent::SignerRequired);
|
||||
@@ -254,8 +256,8 @@ impl Backend {
|
||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||
} else if content.starts_with("ncryptsec1") {
|
||||
// Encrypted identity: a passphrase is required to
|
||||
// decrypt it before the session can resume.
|
||||
// Encrypted identity.
|
||||
// A passphrase is required to decrypt it before the session can resume.
|
||||
log::warn!("stored identity is ncryptsec-encrypted; waiting for passphrase");
|
||||
this.update(cx, |this, cx| {
|
||||
this.passphrase_required = true;
|
||||
@@ -280,11 +282,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Decrypt the NIP-49 encrypted credential stored in the keyring with
|
||||
/// the given passphrase and resume the session.
|
||||
///
|
||||
/// The scrypt decryption runs off the UI thread. The task yields the
|
||||
/// public key, or the failure reason (e.g. wrong passphrase).
|
||||
/// 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,
|
||||
@@ -319,12 +320,12 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new identity: generate keys, encrypt the secret key with the
|
||||
/// passphrase (NIP-49) and persist it in the keyring, then publish the
|
||||
/// user's NIP-65 relay list, metadata and grasp list.
|
||||
///
|
||||
/// The encryption runs off the UI thread; the task yields the new
|
||||
/// public key.
|
||||
/// 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,
|
||||
@@ -360,8 +361,7 @@ impl Backend {
|
||||
write.await?;
|
||||
|
||||
this.update(cx, |this, cx| {
|
||||
// Become the new identity, so the publishes below are
|
||||
// signed with the new keys.
|
||||
// Become the new identity so later publishes are signed with the new keys.
|
||||
this.signer.swap_inner(keys);
|
||||
this.current_user = Some(public_key);
|
||||
this.bootstrap_user(public_key, cx);
|
||||
@@ -412,21 +412,20 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Create a new 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, then push the initial commit
|
||||
/// to each grasp server. A working copy of the repository is also
|
||||
/// created at `<folder>/<name>` (named like the repo header's Clone
|
||||
/// action), with `origin` pointing at the first grasp server, so the
|
||||
/// new project exists in the chosen folder right away.
|
||||
///
|
||||
/// The events must reach the grasp servers *before* the push: GRASP
|
||||
/// servers hold the signed state event in "purgatory" and only accept
|
||||
/// a push for a not-yet-existing repository while that authorization is
|
||||
/// pending (it expires after 30 minutes), like gitworkshop and ngit.
|
||||
///
|
||||
/// The git work runs on background threads; the task yields the
|
||||
/// published announcement and the path of the created working copy.
|
||||
/// 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,
|
||||
name: &str,
|
||||
@@ -454,9 +453,10 @@ 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 (except `/`) become hyphens, case is preserved.
|
||||
// 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.
|
||||
let repo_id = identifier_from_name(&name);
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
return Task::ready(Err(anyhow!(
|
||||
@@ -495,18 +495,16 @@ impl Backend {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
let commit = signed_git::init_repository(&path, &name, &description)?;
|
||||
|
||||
// Point `origin` at the first grasp server so later
|
||||
// fetches (and pushes) have a target, like ngit.
|
||||
// Point `origin` at the first grasp server.
|
||||
// Later fetches and pushes have a target, like ngit.
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
signed_git::ensure_origin(&path, &url).ok();
|
||||
}
|
||||
|
||||
// A working copy at `<folder>/<name>` (the same naming
|
||||
// as the header's Clone action), cloned from the mirror
|
||||
// above so it shares the announced history exactly;
|
||||
// `origin` is re-pointed at the first grasp server
|
||||
// instead of the mirror path.
|
||||
// A working copy at `<folder>/<name>`, like the header's Clone action.
|
||||
// Cloned from the mirror above so it shares the announced history.
|
||||
// `origin` is set to the first grasp server, not the mirror path.
|
||||
let destination = {
|
||||
let dir_name = signed_git::sanitize_path_component(&name);
|
||||
let dir_name = if dir_name.is_empty() {
|
||||
@@ -546,8 +544,8 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// 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()),
|
||||
@@ -593,8 +591,7 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// Push to every grasp server; creation only fails when no
|
||||
// server accepted it.
|
||||
// Push to every grasp server. Creation fails only when no server accepted it.
|
||||
let push = cx.background_spawn({
|
||||
let path = path.clone();
|
||||
let owner = owner.clone();
|
||||
@@ -604,8 +601,8 @@ impl Backend {
|
||||
});
|
||||
|
||||
if let Err(e) = push.await {
|
||||
// The events are already published; retract them so the
|
||||
// repository doesn't remain 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);
|
||||
})
|
||||
@@ -624,13 +621,12 @@ 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 points `origin` at the first grasp server.
|
||||
///
|
||||
/// Same ordering constraint as [`Self::create_repository`]: the state
|
||||
/// event ("purgatory") must be accepted before the push.
|
||||
/// 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,
|
||||
@@ -654,8 +650,7 @@ impl Backend {
|
||||
return Task::ready(Err(anyhow!("Sign in to publish a repository")));
|
||||
};
|
||||
|
||||
// The repository identifier is derived from the name as in
|
||||
// [`Self::create_repository`].
|
||||
// The identifier derives from the name, as in [`Self::create_repository`].
|
||||
let repo_id = identifier_from_name(&name);
|
||||
|
||||
if repo_id.is_empty() || repo_id.len() > 100 {
|
||||
@@ -690,8 +685,8 @@ impl Backend {
|
||||
this.add_relays(urls, cx);
|
||||
})?;
|
||||
|
||||
// The state event is the push authorization ("purgatory"), so
|
||||
// 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()),
|
||||
@@ -735,9 +730,9 @@ impl Backend {
|
||||
}
|
||||
};
|
||||
|
||||
// Push every branch and tag to each grasp server; the init
|
||||
// only fails when no server accepted it. An empty repository
|
||||
// has nothing to push.
|
||||
// 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({
|
||||
let path = path.clone();
|
||||
@@ -759,8 +754,7 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
// Point `origin` at the first grasp server so later pushes
|
||||
// have a target.
|
||||
// Point `origin` at the first grasp server so later pushes have a target.
|
||||
if let Some(base) = servers.first().and_then(grasp_base_url) {
|
||||
let url = format!("{base}/{owner}/{repo_id}.git");
|
||||
let path = path.clone();
|
||||
@@ -774,10 +768,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Re-push the repository's current refs to the grasp servers announced
|
||||
/// in its `relays` tag: publishes a fresh state event (the push
|
||||
/// authorization), then pushes every branch and tag, like the init
|
||||
/// flow. The repository must have a local clone in the cache.
|
||||
/// 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,
|
||||
@@ -788,12 +782,12 @@ impl Backend {
|
||||
self.push_repo_from(announcement, path, None, cx)
|
||||
}
|
||||
|
||||
/// Push the refs of a local checkout (the working copy of the user's
|
||||
/// own repository) to the grasp servers announced in the `relays` tag:
|
||||
/// publishes a fresh state event, then pushes every branch and tag of
|
||||
/// the checkout, like the init flow. `announced_head` keeps the state
|
||||
/// event's `HEAD` on the repository's announced default branch when the
|
||||
/// checkout is on a different branch.
|
||||
/// 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,
|
||||
@@ -804,12 +798,13 @@ impl Backend {
|
||||
self.push_repo_from(announcement, checkout, announced_head, cx)
|
||||
}
|
||||
|
||||
/// 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: two concurrent pushes of the same refs
|
||||
/// (e.g. two panels of the same repository) make the losing push fail
|
||||
/// server-side with a compare-and-swap rejection.
|
||||
/// 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,
|
||||
@@ -841,8 +836,8 @@ impl Backend {
|
||||
let relays = announcement.relays.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Held for the whole task; dropped (and the lock released) 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 = {
|
||||
@@ -853,10 +848,9 @@ impl Backend {
|
||||
work.await?
|
||||
};
|
||||
|
||||
// The state event announces the pushed refs. When the source is
|
||||
// a checkout on a side branch, keep the repository's announced
|
||||
// default branch (its `HEAD`) when that branch is among the
|
||||
// pushed refs; otherwise the checkout's current branch.
|
||||
// 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()
|
||||
@@ -895,10 +889,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Delete the repository from nostr: publish NIP-09 deletions for its
|
||||
/// announcement, state and activity events (issues, pull requests,
|
||||
/// patches, statuses, comments). Only the repository owner may delete
|
||||
/// it.
|
||||
/// 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,
|
||||
addr: RepoAddr,
|
||||
@@ -939,8 +933,8 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` key or a `bunker://...` URI, dispatching on
|
||||
/// the credential's prefix.
|
||||
/// 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();
|
||||
|
||||
@@ -955,8 +949,8 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a fresh identity and login with it. The generated key is
|
||||
/// persisted in the keyring like any other `nsec` credential.
|
||||
/// 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()
|
||||
@@ -965,8 +959,8 @@ impl Backend {
|
||||
self.login_with_nsec(&nsec, cx);
|
||||
}
|
||||
|
||||
/// Login with an `nsec1...` secret key. The credential is verified by
|
||||
/// the signer flow and persisted in the keyring.
|
||||
/// 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),
|
||||
@@ -990,11 +984,11 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Login with a `bunker://...` URI (NIP-46). A fresh session key is
|
||||
/// generated and embedded into the stored URI as `?master=<nsec>`, so
|
||||
/// 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.
|
||||
/// 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();
|
||||
|
||||
@@ -1058,8 +1052,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Fetch the user's grasp list (kind `10317`) and add the listed grasp
|
||||
/// servers as relays.
|
||||
/// Fetch the user's grasp list of kind `10317`.
|
||||
/// Add the listed grasp servers as relays.
|
||||
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||
let client = self.client.clone();
|
||||
|
||||
@@ -1111,8 +1105,8 @@ impl Backend {
|
||||
self.current_user
|
||||
}
|
||||
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
/// 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
|
||||
}
|
||||
@@ -1127,13 +1121,15 @@ impl Backend {
|
||||
self.connected
|
||||
}
|
||||
|
||||
/// Progress of the in-flight negentropy sync, if any: `(total, current)`.
|
||||
/// 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,
|
||||
/// e.g. `Keys`, `NostrConnect`, a browser extension proxy).
|
||||
/// 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,
|
||||
@@ -1194,8 +1190,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Add relays used only for discovery (e.g. NIP-65 indexers) and
|
||||
/// connect to them. No subscriptions or writes are routed through them.
|
||||
/// 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();
|
||||
|
||||
@@ -1218,8 +1214,9 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a persistent subscription. Matching events are stored in the
|
||||
/// database automatically and surface as [`BackendEvent::NostrUpdate`].
|
||||
/// 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();
|
||||
|
||||
@@ -1233,9 +1230,8 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
|
||||
/// and is still recent enough to suppress a duplicate. Records the
|
||||
/// fingerprint (after pruning expired entries) when returning `false`.
|
||||
/// 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
|
||||
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
|
||||
@@ -1246,17 +1242,13 @@ impl Backend {
|
||||
false
|
||||
}
|
||||
|
||||
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and
|
||||
/// fetch its events from them: a one-shot auto-closing subscription for
|
||||
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
|
||||
/// only on those relays are not missed.
|
||||
///
|
||||
/// Deduplicated: an identical request (same relays and filters) started
|
||||
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
|
||||
/// same repository doesn't re-run the fetch.
|
||||
///
|
||||
/// Best-effort: failures are logged, not surfaced. The relays stay in
|
||||
/// the pool, so later publishes for this repository also reach them.
|
||||
/// 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>,
|
||||
@@ -1285,10 +1277,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Start a one-shot subscription targeted only at the bootstrap relays,
|
||||
/// auto-closing after EOSE or a short timeout. Matching events are stored
|
||||
/// in the database and surface as [`BackendEvent::NostrUpdate`] while the
|
||||
/// subscription is open.
|
||||
/// 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();
|
||||
|
||||
@@ -1303,14 +1295,13 @@ 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) and [`BackendEvent::Synced`] on completion.
|
||||
///
|
||||
/// Deduplicated: an identical sync started within
|
||||
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
|
||||
/// sync's progress and completion events.
|
||||
/// 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) {
|
||||
@@ -1385,12 +1376,10 @@ 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/errors) and is owned by the caller; dropping it cancels
|
||||
/// the publish.
|
||||
/// 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,
|
||||
@@ -1400,8 +1389,8 @@ impl Backend {
|
||||
let signer = self.signer.clone();
|
||||
|
||||
cx.spawn(async move |this, cx| {
|
||||
// Sign with the current signer, broadcast, and save locally so
|
||||
// the event is immediately visible to database queries.
|
||||
// Sign with the current signer, broadcast and save locally.
|
||||
// The event is immediately visible to database queries.
|
||||
let work = cx.background_spawn(async move {
|
||||
let event = builder.finalize_async(&signer).await?;
|
||||
let output = client.send_event(&event).await?;
|
||||
@@ -1440,10 +1429,10 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Broadcast and locally store an already-signed event, like
|
||||
/// [`Self::send`] without the signing step. Callers that signed early
|
||||
/// (e.g. to learn the event id before pushing a commit to the grasp
|
||||
/// servers) publish through this.
|
||||
/// 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,
|
||||
@@ -1489,9 +1478,9 @@ impl Backend {
|
||||
})
|
||||
}
|
||||
|
||||
/// Publish a NIP-34 repository announcement (kind 30617) with the
|
||||
/// current signer. The returned task yields the published event, so
|
||||
/// callers can show inline progress/errors.
|
||||
/// 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,
|
||||
@@ -1500,9 +1489,9 @@ impl Backend {
|
||||
self.send(announcement.into_event_builder(), cx)
|
||||
}
|
||||
|
||||
/// Sign, broadcast and store an event without awaiting the result;
|
||||
/// failures surface through [`BackendEvent::Error`]. The spawned task is
|
||||
/// owned by the backend, so it is cancelled when the backend is dropped.
|
||||
/// 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);
|
||||
|
||||
@@ -1517,10 +1506,10 @@ impl Backend {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Publish a NIP-09 deletion event for `events` (best-effort), so a
|
||||
/// publish that fails midway can retract the events that were already
|
||||
/// broadcast to relays. Failures are logged, not surfaced: the caller's
|
||||
/// error already told the user what happened.
|
||||
/// 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;
|
||||
@@ -1544,8 +1533,8 @@ impl Backend {
|
||||
}
|
||||
}
|
||||
|
||||
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
|
||||
/// filters are sorted first so the fingerprint is order-independent.
|
||||
/// 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();
|
||||
relays.sort_unstable();
|
||||
@@ -1558,11 +1547,10 @@ fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
|
||||
hasher.finish()
|
||||
}
|
||||
|
||||
/// Add the given relays, connect to them, and fetch the filters: a one-shot
|
||||
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
|
||||
/// as a second pass, so events that race with the subscription or relays
|
||||
/// with flaky EOSE behavior can't be missed. Relays without NEG-XX support
|
||||
/// just fail the sync step; the subscription already covered them.
|
||||
/// 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>,
|
||||
@@ -1576,8 +1564,8 @@ async fn connect_repo_relays_only(
|
||||
for url in &relays {
|
||||
added |= client.add_relay(url).await?;
|
||||
}
|
||||
// Connecting is only needed when the pool grew; connected relays no-op,
|
||||
// but the call still iterates every relay in the pool.
|
||||
// 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;
|
||||
}
|
||||
@@ -1592,9 +1580,9 @@ async fn connect_repo_relays_only(
|
||||
.collect();
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
// Sync the filters concurrently: each reconciles against every relay
|
||||
// either way, and a relay without NEG-XX support otherwise serializes
|
||||
// its initial timeout behind every other filter.
|
||||
// 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;
|
||||
@@ -1616,9 +1604,10 @@ async fn connect_repo_relays_only(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Subscribe only on the bootstrap relays, auto-closing after EOSE or a
|
||||
/// short timeout. Use for one-shot data fetches (repo events, profiles)
|
||||
/// instead of persistent gossip-routed subscriptions.
|
||||
/// 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>,
|
||||
@@ -1658,17 +1647,17 @@ fn with_master_key(uri: &str, keys: &Keys) -> String {
|
||||
format!("{uri}{separator}master={nsec}")
|
||||
}
|
||||
|
||||
/// A `https://<host>` (or `http://<host>` for `ws://` grasp servers, like
|
||||
/// ngit) base URL for a grasp server. The repository then lives at
|
||||
/// `{base}/{npub}/{repo-id}.git`.
|
||||
/// 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 commonly run on a custom port).
|
||||
// `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.
|
||||
// `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 {
|
||||
@@ -1677,25 +1666,25 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
|
||||
Some(format!("{scheme}://{host}{port}"))
|
||||
}
|
||||
|
||||
/// The GRASP clone URL of a repository on a grasp server, matching the
|
||||
/// format ngit announces: `https://<host>/<npub>/<repo-id>.git`.
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// The GRASP-06 contributor namespace URL of a pull request tip on the
|
||||
/// author's grasp server: `{base}/prs/<author-npub>/<repo-id>.git` (npub in
|
||||
/// the URL; the server stores it under the hex form). Anyone may push there;
|
||||
/// no announcement or maintainer rights are involved.
|
||||
/// 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 first (author-controlled, most likely to accept the tip
|
||||
/// push), then the base announcement's clone URLs, deduplicated while
|
||||
/// preserving that order.
|
||||
/// 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();
|
||||
@@ -1708,7 +1697,7 @@ 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).
|
||||
/// Unparseable URLs are dropped, the UI only writes well-formed servers.
|
||||
fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
event
|
||||
.tags
|
||||
@@ -1719,10 +1708,9 @@ fn grasp_list_servers(event: &Event) -> Vec<RelayUrl> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The grasp servers of the newest kind-10317 grasp list among `events`
|
||||
/// (latest event wins, like every other latest-wins resolution in the app);
|
||||
/// empty when there is no list, so the caller falls back to the settings
|
||||
/// defaults.
|
||||
/// 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()
|
||||
@@ -1731,10 +1719,10 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Resolve the user's published grasp servers: the `g` tags (in order) of
|
||||
/// their latest kind-10317 grasp list in the local database. Returns an
|
||||
/// empty list when the user has no published list, so the caller can fall
|
||||
/// back to the settings defaults.
|
||||
/// 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,
|
||||
@@ -1748,11 +1736,11 @@ pub(crate) async fn user_grasp_list_servers(
|
||||
Ok(latest_grasp_list_servers(events))
|
||||
}
|
||||
|
||||
/// Push the repository at `path` to every grasp server: a server that
|
||||
/// rejects the push is logged, but the push only fails when no server
|
||||
/// accepted it. `push` performs the single-server push (e.g.
|
||||
/// [`signed_git::push_main`] for the create flow, [`signed_git::push_all`]
|
||||
/// for the init flow).
|
||||
/// 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,
|
||||
@@ -1789,7 +1777,7 @@ 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.
|
||||
/// 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)) => {
|
||||
@@ -1837,7 +1825,7 @@ mod tests {
|
||||
grasp06_prs_url("https://relay.ngit.dev", "npub1author", "my-repo"),
|
||||
"https://relay.ngit.dev/prs/npub1author/my-repo.git"
|
||||
);
|
||||
// `ws://` grasp servers (local dev) keep their plain-HTTP base.
|
||||
// `ws://` grasp servers, local dev, keep their plain-HTTP base.
|
||||
assert_eq!(
|
||||
grasp06_prs_url("http://localhost:8080", "npub1author", "my-repo"),
|
||||
"http://localhost:8080/prs/npub1author/my-repo.git"
|
||||
@@ -1913,7 +1901,7 @@ mod tests {
|
||||
vec!["wss://fresh.example", "wss://also.example"]
|
||||
);
|
||||
|
||||
// No list at all: empty, so the caller falls back to the defaults.
|
||||
// No list at all, empty, so the caller falls back to the defaults.
|
||||
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,28 +1,3 @@
|
||||
//! Local checkout associations ("remember" tier of the PR suggestions):
|
||||
//! which local folders are checkouts of which announced repositories.
|
||||
//!
|
||||
//! Two sources feed the resolution:
|
||||
//!
|
||||
//! - **Remembered records** (settings, [`settings::CheckoutRecord`]):
|
||||
//! recorded when the user clones a repository from the app or picks a
|
||||
//! folder in the New PR panel.
|
||||
//! - **Implicit matches** over the local scan ([`LocalReposStore`]): a
|
||||
//! scanned repository whose `origin` URL matches an announcement `clone`
|
||||
//! URL (scheme-insensitive), or whose root commit equals an announcement
|
||||
//! EUC, is a checkout of that announced repository.
|
||||
//!
|
||||
//! The store also computes per-checkout statuses for two surfaces:
|
||||
//!
|
||||
//! - **"Ready to contribute"** (pull-request banner of repositories the
|
||||
//! user does not own): branch, base and commits ahead of the base.
|
||||
//! - **"Ready to push"** (sidebar badge and banner of the user's own
|
||||
//! repositories): the checked-out branch has commits the grasp servers
|
||||
//! do not have yet (counted against the refreshed remote-tracking
|
||||
//! refs), so the user can push their local work from the app.
|
||||
//!
|
||||
//! Everything is resolved on background threads and swapped in as
|
||||
//! [`Arc`]s; the UI never waits for git.
|
||||
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
@@ -40,18 +15,17 @@ use crate::git_store::GitStore;
|
||||
use crate::local_repos::LocalReposStore;
|
||||
use crate::repo_list::RepoListStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-computation, so bursts
|
||||
/// of notifications (settings edits, rescan ticks) collapse into one pass.
|
||||
/// 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, so a
|
||||
/// checkout committed to or pulled in external git surfaces in the banner
|
||||
/// without reopening the panel.
|
||||
/// 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 when no repository panel is open (each cycle refreshes
|
||||
/// the remote view of the checkouts with a git fetch).
|
||||
/// 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.
|
||||
@@ -61,24 +35,25 @@ struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
|
||||
|
||||
impl Global for GlobalCheckoutsStore {}
|
||||
|
||||
/// One associated local checkout of a repository, with the git facts needed
|
||||
/// to suggest a pull request.
|
||||
/// 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 {
|
||||
/// The checkout folder.
|
||||
pub path: PathBuf,
|
||||
/// The branch checked out (`None`-less: detached checkouts are idle).
|
||||
/// The branch checked out. A detached checkout is idle and yields no status.
|
||||
pub branch: String,
|
||||
/// Commit the branch points at, for tip-based PR dedupe.
|
||||
pub head: String,
|
||||
/// What the branch is compared against. For ready-to-contribute
|
||||
/// statuses: the announced HEAD branch (else `main`, else the first
|
||||
/// local branch). For ready-to-push statuses: the remote-tracking ref
|
||||
/// the unpushed commits are counted against
|
||||
/// (`refs/remotes/origin/<branch>`, or `origin/HEAD` for branches the
|
||||
/// remote does not have yet).
|
||||
/// What the branch is compared against.
|
||||
/// For ready-to-contribute statuses, the announced HEAD branch.
|
||||
/// 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`; always > 0 (even checkouts are dropped).
|
||||
/// Commits in `base..branch`.
|
||||
/// Zero-ahead checkouts are dropped, so this is always above zero.
|
||||
pub ahead: u32,
|
||||
}
|
||||
|
||||
@@ -91,23 +66,23 @@ 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.
|
||||
/// 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 whenever the inputs
|
||||
/// change (the repository detail panels currently open).
|
||||
/// 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, plus
|
||||
/// the detail panels of those repositories).
|
||||
/// 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>>>,
|
||||
/// Announced head branch last provided per requested repository, so a
|
||||
/// recompute defaults the base the same way.
|
||||
/// Last announced head branch per requested repository.
|
||||
/// A recompute defaults the base the same way.
|
||||
requested_head: HashMap<RepoAddr, Option<String>>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
@@ -127,9 +102,10 @@ impl CheckoutsStore {
|
||||
cx.set_global(GlobalCheckoutsStore(entity));
|
||||
}
|
||||
|
||||
/// Create the store: observe the inputs (settings records, the local
|
||||
/// scan, the announcement list, signer changes) and resolve the
|
||||
/// associations right away.
|
||||
/// 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();
|
||||
|
||||
@@ -148,8 +124,8 @@ impl CheckoutsStore {
|
||||
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||
this.refresh(cx);
|
||||
}));
|
||||
// Another identity's repositories must not keep the previous
|
||||
// user's statuses (or polls) alive.
|
||||
// Another identity's repositories must not keep the old statuses alive.
|
||||
// Their polls stop too.
|
||||
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
if matches!(event, BackendEvent::SignerChanged) {
|
||||
this.status_requested.clear();
|
||||
@@ -182,8 +158,9 @@ impl CheckoutsStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Remember a successful local-checkout use: (re)insert the record with
|
||||
/// a fresh timestamp, so freshest-first ordering follows actual use.
|
||||
/// 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;
|
||||
@@ -212,16 +189,15 @@ impl CheckoutsStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// The associated checkouts of `addr`, freshest first. Empty when none
|
||||
/// are known (or the resolution has not run yet).
|
||||
/// 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()
|
||||
}
|
||||
|
||||
/// Ask for the "ready to contribute" statuses of `addr` to be kept
|
||||
/// current (called while the repository's detail panel is open).
|
||||
/// `announced_head` is the announced HEAD branch of the repository
|
||||
/// (from its state announcement), used to default the base.
|
||||
/// 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,
|
||||
addr: &RepoAddr,
|
||||
@@ -235,33 +211,32 @@ impl CheckoutsStore {
|
||||
self.refresh(cx);
|
||||
}
|
||||
|
||||
/// The ready-to-contribute statuses of `addr`; empty while none are
|
||||
/// known or nothing is ahead.
|
||||
/// 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 be kept current
|
||||
/// (called by the sidebar for the signed-in user's own repositories and
|
||||
/// by the detail panels of those repositories). Recomputed on every
|
||||
/// input change and on a background poll; each cycle refreshes the
|
||||
/// remote view of the checkouts first, so a commit made in external
|
||||
/// git surfaces within one poll interval.
|
||||
/// 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);
|
||||
}
|
||||
|
||||
/// 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.
|
||||
/// 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 associations (and the requested statuses). Debounced:
|
||||
/// bursts of notifications collapse into one pass; requests arriving
|
||||
/// while a pass runs are folded into a follow-up.
|
||||
/// 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 {
|
||||
self.refresh_dirty = true;
|
||||
@@ -284,7 +259,7 @@ impl CheckoutsStore {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One resolve + apply cycle (debounced entry point).
|
||||
/// One resolve and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
@@ -321,12 +296,12 @@ impl CheckoutsStore {
|
||||
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: origin URL and root commit (both CLI reads).
|
||||
// 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))
|
||||
@@ -339,7 +314,7 @@ impl CheckoutsStore {
|
||||
}
|
||||
|
||||
let associations = resolve_associations(&remembered, &facts, announcements.iter());
|
||||
// Missing directories are stale records; drop them.
|
||||
// Missing directories are stale records, drop them.
|
||||
let associations: HashMap<RepoAddr, Vec<PathBuf>> = associations
|
||||
.into_iter()
|
||||
.map(|(addr, paths)| (addr, paths.into_iter().filter(|p| p.is_dir()).collect()))
|
||||
@@ -382,7 +357,7 @@ impl CheckoutsStore {
|
||||
let (associations, statuses, push_statuses) = match work.await {
|
||||
Ok(results) => results,
|
||||
Err(_) => {
|
||||
// Git reads are best-effort; keep the last results.
|
||||
// Git reads are best-effort, keep the last results.
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
});
|
||||
@@ -408,16 +383,16 @@ impl CheckoutsStore {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
// While any repository panel is open (or any of the user's own
|
||||
// repositories is watched for the sidebar badge), keep the
|
||||
// statuses current: local commits, pulls and branch switches
|
||||
// happen outside the app and are not otherwise observable.
|
||||
// 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; the sidebar badges
|
||||
// alone poll less aggressively (each cycle fetches
|
||||
// every watched checkout's remote).
|
||||
// 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 {
|
||||
@@ -439,11 +414,11 @@ impl CheckoutsStore {
|
||||
}
|
||||
}
|
||||
|
||||
/// The identity of a repository URL: host, explicit port and path with a
|
||||
/// trailing `.git` (and slashes) stripped. Scheme-insensitive, so
|
||||
/// `ws`/`wss`/`http`/`https`/`grasp` are equivalent transports of the same
|
||||
/// grasp server. `None` for URLs that cannot be parsed (e.g. `git@`-style
|
||||
/// or plain paths), which then compare by raw string.
|
||||
/// 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();
|
||||
@@ -454,8 +429,8 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
|
||||
Some((host, parsed.port(), path))
|
||||
}
|
||||
|
||||
/// Whether two repository URLs point at the same repository, ignoring the
|
||||
/// transport scheme (see [`url_identity`]).
|
||||
/// 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,
|
||||
@@ -463,10 +438,10 @@ fn same_repo_url(a: &str, b: &str) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve the associations between local checkouts and announced
|
||||
/// repositories: remembered records (freshest first per repository),
|
||||
/// followed by scanned repositories matched by origin URL or EUC.
|
||||
/// Deduplicated by path, keeping the first (remembered) occurrence.
|
||||
/// 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>)],
|
||||
@@ -507,8 +482,9 @@ fn resolve_associations<'a>(
|
||||
out
|
||||
}
|
||||
|
||||
/// Whether the worktree of `path` has uncommitted changes (a dirty
|
||||
/// checkout is never suggested: the proposal should cover committed work).
|
||||
/// 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")
|
||||
@@ -522,8 +498,9 @@ fn worktree_dirty(path: &Path) -> bool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Commits in `base..branch` of the checkout at `path` (`git rev-list
|
||||
/// --count`); `0` when the range is empty or cannot be computed.
|
||||
/// 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")
|
||||
@@ -540,8 +517,8 @@ fn commits_ahead(path: &Path, base: &str, branch: &str) -> u32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// The branch checked out at `path` (`git branch --show-current`), `None`
|
||||
/// when detached.
|
||||
/// 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")
|
||||
@@ -554,10 +531,11 @@ fn current_branch_of(path: &Path) -> Option<String> {
|
||||
(!branch.is_empty()).then_some(branch)
|
||||
}
|
||||
|
||||
/// The ready-to-contribute status of one checkout, or `None` when it is
|
||||
/// idle: 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.
|
||||
/// 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) {
|
||||
@@ -583,8 +561,8 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the reference `name` (e.g. `refs/remotes/origin/main`) exists
|
||||
/// in the checkout at `path`.
|
||||
/// 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")
|
||||
.arg("-C")
|
||||
@@ -595,13 +573,12 @@ fn ref_exists(path: &Path, name: &str) -> bool {
|
||||
matches!(output, Ok(output) if output.status.success())
|
||||
}
|
||||
|
||||
/// 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 the commits made since). Detached
|
||||
/// checkouts, dirty worktrees and branches with no remote state at all
|
||||
/// (the remote HEAD is unknown) are never suggested; branches the remote
|
||||
/// does not have yet are counted against the remote HEAD.
|
||||
/// 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;
|
||||
@@ -610,13 +587,13 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
let head = signed_git::head_commit_id(path).ok().flatten()?;
|
||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||
|
||||
// Refresh the remote heads so a commit made elsewhere (or pushed from
|
||||
// another machine) does not show as "to push" forever.
|
||||
// Refresh the remote heads first.
|
||||
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||
|
||||
let remote = format!("refs/remotes/origin/{branch}");
|
||||
// A branch that has never been fetched/pushed yet is compared against
|
||||
// the remote HEAD (its fork point in practice).
|
||||
// A branch never fetched or pushed yet compares against the remote HEAD.
|
||||
// The remote HEAD is the fork point in practice.
|
||||
let base = if ref_exists(path, &remote) {
|
||||
remote
|
||||
} else if ref_exists(path, "refs/remotes/origin/HEAD") {
|
||||
@@ -634,10 +611,10 @@ fn checkout_push_status(path: &Path) -> Option<CheckoutStatus> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Whether the pull request `pr` (a kind-1618 root, resolved `open` by the
|
||||
/// caller) already proposes the same change as `checkout`: authored by
|
||||
/// `user`, with a matching `branch-name` tag, or — for renamed branches — a
|
||||
/// `c` tip tag matching the checkout's HEAD commit.
|
||||
/// 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,
|
||||
@@ -724,8 +701,8 @@ mod tests {
|
||||
repo_addr(owner(), id)
|
||||
}
|
||||
|
||||
/// Build one announcement by the fixed test owner with `clone` URLs and
|
||||
/// an EUC.
|
||||
/// Build one announcement by the fixed test owner.
|
||||
/// Takes `clone` URLs and an EUC.
|
||||
fn announcement(id: &str, clones: &[&str], euc: Option<&str>) -> Announcement {
|
||||
let keys = Keys::new(SecretKey::from_hex(KEY).expect("secret"));
|
||||
let mut tags = vec![Tag::parse(vec!["d", id]).expect("tag")];
|
||||
@@ -807,8 +784,8 @@ mod tests {
|
||||
)];
|
||||
let base = addr("repo");
|
||||
|
||||
// The same path is both remembered and scanned (its origin matches);
|
||||
// the remembered occurrence wins and it is listed once.
|
||||
// The same path is both remembered and scanned, its origin matches.
|
||||
// The remembered occurrence wins and the path is listed once.
|
||||
let resolved = resolve_associations(
|
||||
&[remembered("/shared", "repo", 100)],
|
||||
&[
|
||||
@@ -848,7 +825,7 @@ mod tests {
|
||||
run(&["commit", "-m", message]);
|
||||
};
|
||||
|
||||
// A feature branch ahead of main: ready to contribute.
|
||||
// A feature branch ahead of main, ready to contribute.
|
||||
run(&["checkout", "-b", "feature"]);
|
||||
std::fs::write(path.join("feature.txt"), "x\n").expect("write");
|
||||
commit("feature work");
|
||||
@@ -863,17 +840,17 @@ mod tests {
|
||||
assert!(checkout_status(&path, Some("main")).is_none());
|
||||
run(&["checkout", "--", "."]);
|
||||
|
||||
// Even with main: nothing to propose.
|
||||
// Even on main, nothing to propose.
|
||||
run(&["checkout", "main"]);
|
||||
assert_eq!(checkout_status(&path, Some("main")), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn checkout_push_status_counts_unpushed_commits_only() {
|
||||
// The "grasp remote": a plain repository the checkout clones from
|
||||
// (origin URL = local path, so the whole cycle runs offline). Git
|
||||
// refuses pushes to its checked-out branch by default; act like a
|
||||
// grasp server and allow them.
|
||||
// The `grasp remote` is a plain repository the checkout clones from.
|
||||
// Its origin URL is a local path, so the whole cycle runs offline.
|
||||
// Git refuses pushes to a checked-out branch by default.
|
||||
// Act like a grasp server and allow them.
|
||||
let dir = tempfile::tempdir().expect("tempdir");
|
||||
let remote = dir.path().join("remote");
|
||||
signed_git::init_repository(&remote, "My Repo", "").expect("init");
|
||||
@@ -913,7 +890,7 @@ mod tests {
|
||||
// A fresh clone has nothing to push.
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// One local commit: ready to push, counted against the remote.
|
||||
// One local commit, ready to push, counted against the remote.
|
||||
std::fs::write(checkout.join("work.txt"), "x\n").expect("write");
|
||||
run(&["add", "-A"]);
|
||||
run(&["commit", "-m", "local work"]);
|
||||
@@ -923,12 +900,12 @@ mod tests {
|
||||
assert_eq!(status.ahead, 1);
|
||||
assert_eq!(status.head.len(), 40);
|
||||
|
||||
// After the push the same commit is on the remote: idle again.
|
||||
// After the push the same commit is on the remote, idle again.
|
||||
run(&["push", "origin", "main"]);
|
||||
assert_eq!(checkout_push_status(&checkout), None);
|
||||
|
||||
// A commit made by someone else on the remote must not count as
|
||||
// local work (it is behind, not ahead).
|
||||
// A commit made by someone else on the remote must not count as local work.
|
||||
// It is behind, not ahead.
|
||||
let remote_run = |args: &[&str]| {
|
||||
let status = Command::new("git")
|
||||
.current_dir(&remote)
|
||||
@@ -979,15 +956,15 @@ mod tests {
|
||||
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Without the branch name (renamed), the `c` tip still matches.
|
||||
// Without a branch-name tag, the `c` tip still matches for a renamed branch.
|
||||
let pr = pr_event(
|
||||
author,
|
||||
&[&["c", "bb231c4c6a5777dc89b42207b499891a344add5c"]],
|
||||
);
|
||||
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
|
||||
|
||||
// Someone else's PR, a closed PR, a different branch and a missing
|
||||
// tip all leave the checkout uncovered.
|
||||
// Someone else's PR, a closed PR, a different branch and a missing tip.
|
||||
// They all leave the checkout uncovered.
|
||||
let pr = pr_event(author, &[&["branch-name", "feature"]]);
|
||||
assert!(!pr_proposes_checkout(&pr, false, pr.pubkey, &status));
|
||||
let other = pr_event(
|
||||
|
||||
@@ -7,17 +7,15 @@ struct GlobalGitStore(GitCache);
|
||||
|
||||
impl Global for GlobalGitStore {}
|
||||
|
||||
/// Global access to the on-disk git clone cache (grasp mirrors).
|
||||
///
|
||||
/// Installed at startup via [`GitStore::set_global`]; see also
|
||||
/// [`signed_state::init`].
|
||||
/// Global access to the on-disk git clone cache, the grasp mirrors.
|
||||
/// 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 previously installed store (see [`signed_state::init`], which
|
||||
/// installs an empty one).
|
||||
/// Replaces any installed store, [`signed_state::init`] installs an empty one.
|
||||
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
|
||||
let store = Self::new(root);
|
||||
cx.set_global(GlobalGitStore(store.0.clone()));
|
||||
@@ -25,9 +23,6 @@ impl GitStore {
|
||||
}
|
||||
|
||||
/// The app-wide clone cache.
|
||||
///
|
||||
/// # Panics
|
||||
///
|
||||
/// Panics if [`GitStore::set_global`] was never called.
|
||||
pub fn global(cx: &App) -> Self {
|
||||
Self(cx.global::<GlobalGitStore>().0.clone())
|
||||
|
||||
@@ -45,9 +45,10 @@ impl LocalReposStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Forget a repository that has just been published to NIP-34,
|
||||
/// so it leaves the local list immediately. A later rescan re-discovers it from disk,
|
||||
/// the sidebar additionally hides published repositories by identifier.
|
||||
/// Forget a repository that has just been published to NIP-34.
|
||||
/// 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
|
||||
@@ -95,8 +96,7 @@ impl LocalReposStore {
|
||||
dirty
|
||||
})?;
|
||||
|
||||
// Scans requested while this one was running are coalesced into
|
||||
// a single follow-up scan.
|
||||
// Scans requested while this one ran are coalesced into one follow-up scan.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.rescan(cx))?;
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ use utils::shorten_pubkey;
|
||||
|
||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||
|
||||
/// A user profile (kind `0` metadata), as plain data for the UI.
|
||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Profile {
|
||||
public_key: PublicKey,
|
||||
@@ -62,18 +62,20 @@ impl Profile {
|
||||
|
||||
/// Message from the fetch task to the main thread.
|
||||
enum Dispatch {
|
||||
/// A batched sync finished; re-read seen profiles from the database.
|
||||
/// A batched sync finished.
|
||||
/// Re-read seen profiles from the database.
|
||||
Synced,
|
||||
}
|
||||
|
||||
/// How long to wait for more requests before firing a batched sync.
|
||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||
|
||||
/// Global profile cache. Profiles are fetched in batches and kept as plain
|
||||
/// data; the whole store notifies on change.
|
||||
/// Global profile cache.
|
||||
/// Profiles are fetched in batches and kept as plain data.
|
||||
/// The whole store notifies on change.
|
||||
pub struct ProfileStore {
|
||||
profiles: HashMap<PublicKey, Profile>,
|
||||
/// Public keys we've already requested this session (main thread only).
|
||||
/// Public keys requested this session, main thread only.
|
||||
seen: RefCell<HashSet<PublicKey>>,
|
||||
/// Sender for queuing fetch requests, batched by a background task.
|
||||
sender: Sender<PublicKey>,
|
||||
@@ -111,8 +113,8 @@ impl ProfileStore {
|
||||
_ => {}
|
||||
});
|
||||
|
||||
// Fetch requests are queued on a channel and synced in batches by a
|
||||
// background task.
|
||||
// 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>();
|
||||
@@ -143,8 +145,9 @@ impl ProfileStore {
|
||||
store
|
||||
}
|
||||
|
||||
/// Get a profile. Returns a placeholder (default metadata) and queues a
|
||||
/// fetch if the profile isn't cached yet.
|
||||
/// Get a profile.
|
||||
/// Returns a placeholder with default metadata.
|
||||
/// Queues a fetch when the profile is not cached yet.
|
||||
pub fn get(&self, public_key: &PublicKey) -> Profile {
|
||||
if let Some(profile) = self.profiles.get(public_key) {
|
||||
return profile.clone();
|
||||
@@ -170,7 +173,8 @@ impl ProfileStore {
|
||||
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only plain profiles cross back.
|
||||
// Parse off the main thread.
|
||||
// Only plain profiles cross back.
|
||||
let profiles: Vec<Profile> = events
|
||||
.into_iter()
|
||||
.map(|event| {
|
||||
@@ -205,7 +209,8 @@ impl ProfileStore {
|
||||
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||
let events = client.database().query(filter).await?;
|
||||
|
||||
// Parse off the main thread; only the profile crosses back.
|
||||
// Parse off the main thread.
|
||||
// Only the profile crosses back.
|
||||
let profile = events
|
||||
.into_iter()
|
||||
.max_by_key(|e| e.created_at)
|
||||
@@ -231,8 +236,8 @@ impl ProfileStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Re-read the latest metadata of every requested author from the local
|
||||
/// database (used after a sync, which produces no NostrUpdate events).
|
||||
/// Re-read the latest metadata of every requested author from the local database.
|
||||
/// Used after a sync, which produces no NostrUpdate events.
|
||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||
|
||||
@@ -286,9 +291,9 @@ impl ProfileStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Sync metadata for requested authors in batches, debounced to collect
|
||||
/// requests. Runs on a background thread; results are dispatched to the
|
||||
/// main thread, which re-reads the database.
|
||||
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||
/// Runs on a background thread.
|
||||
/// Results are dispatched to the main thread, which re-reads the database.
|
||||
async fn handle_requests(
|
||||
client: &Client,
|
||||
dispatch: &Sender<Dispatch>,
|
||||
@@ -316,9 +321,9 @@ impl ProfileStore {
|
||||
.kind(Kind::Metadata)
|
||||
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||
|
||||
// Negentropy-sync with the bootstrap relays. Synced events are
|
||||
// written to the database directly (no NostrUpdate), so re-apply
|
||||
// from the database afterwards.
|
||||
// Negentropy-sync with the bootstrap relays.
|
||||
// Synced events are written to the database directly, no NostrUpdate.
|
||||
// Re-apply from the database afterwards.
|
||||
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||
Ok(_) => {
|
||||
if dispatch.send(Dispatch::Synced).is_err() {
|
||||
|
||||
+205
-203
@@ -19,17 +19,17 @@ use crate::backend::{
|
||||
};
|
||||
use crate::git_store::GitStore;
|
||||
|
||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||
/// events (e.g. per-event `NostrUpdate`s) collapse into one query.
|
||||
/// 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, following NIP-34's guidance that
|
||||
/// patches should be used when each event is under 60kb.
|
||||
/// 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: announcement, state, issues, patches, PRs,
|
||||
/// comments and their resolved statuses. Always derived from the local
|
||||
/// database.
|
||||
/// Per-repository store.
|
||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||
/// Always derived from the local database.
|
||||
pub struct RepoStore {
|
||||
addr: RepoAddr,
|
||||
pub announcement: Option<Announcement>,
|
||||
@@ -42,34 +42,33 @@ pub struct RepoStore {
|
||||
pub pull_requests: Vec<Event>,
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
pub comments: Vec<Event>,
|
||||
/// Resolved status per root event (issue / patch / PR), recomputed on
|
||||
/// every refresh so render paths are HashMap lookups instead of
|
||||
/// scanning all status events per root.
|
||||
/// 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 / root PR counts, computed with [`Self::status_by_root`]
|
||||
/// on every refresh.
|
||||
/// 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 referencing this
|
||||
/// repository's roots (ngit / GitWorkshop extensions).
|
||||
/// 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.
|
||||
/// 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 (e.g. a PR published without
|
||||
/// its commit reaching a grasp server), if any.
|
||||
/// 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 announced by this repository (NIP-34 `relays` tag) that we
|
||||
/// have already been asked to connect to and fetch from, to avoid
|
||||
/// re-subscribing on every refresh.
|
||||
/// 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, PRs) for which the per-root fetches
|
||||
/// (NIP-22 comments, statuses without an `a` tag, cover notes and
|
||||
/// labels) have already been requested, to avoid re-fetching on every
|
||||
/// refresh.
|
||||
/// 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>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
@@ -92,12 +91,12 @@ impl RepoStore {
|
||||
let coordinate = update.coordinate.as_ref() == Some(&this.addr);
|
||||
let author = update.author == this.addr.public_key;
|
||||
let kind = update.kind == Kind::GitRepoAnnouncement;
|
||||
// NIP-22 comments carry no `a` tag, so they can't be
|
||||
// matched by coordinate; any comment may reference this
|
||||
// repository's roots.
|
||||
// NIP-22 comments carry no `a` tag.
|
||||
// Coordinate matching fails for them.
|
||||
// Any comment may reference this repository's roots.
|
||||
let comment = update.kind == Kind::Comment;
|
||||
// Status events may omit their `a` tag (NIP-34), so any
|
||||
// status event may reference a root of this repository.
|
||||
// Status events may omit their `a` tag, NIP-34.
|
||||
// Any status event may reference a root of this repository.
|
||||
let status = RepoStatus::from_kind(update.kind).is_some();
|
||||
// Cover notes and labels carry no `a` tag either.
|
||||
let annotation = update.kind == COVER_NOTE_KIND || update.kind == Kind::Label;
|
||||
@@ -108,9 +107,8 @@ impl RepoStore {
|
||||
let kind = event.kind == Kind::GitRepoAnnouncement;
|
||||
let author = event.pubkey == this.addr.public_key;
|
||||
let coordinate = event.tags.coordinates().into_iter().any(|c| c == this.addr);
|
||||
// Locally published deletions may target any event of
|
||||
// this repository; refresh so they take effect
|
||||
// immediately, like relay deletions.
|
||||
// Locally published deletions may target any event of this repository.
|
||||
// Refresh so they take effect immediately, like relay deletions.
|
||||
let deletion =
|
||||
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||
|
||||
@@ -151,9 +149,9 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
store.subscribe_remote(cx);
|
||||
// The announcement we opened the repo from may already list its
|
||||
// relays; connect to them right away instead of waiting for the
|
||||
// bootstrap fetch to return the same event.
|
||||
// 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
|
||||
@@ -164,7 +162,7 @@ impl RepoStore {
|
||||
&self.addr
|
||||
}
|
||||
|
||||
/// Returns the repository's name, or "Unknown" if not known.
|
||||
/// Returns the repository's name, or `Unknown` when not known.
|
||||
pub fn name(&self) -> SharedString {
|
||||
self.announcement
|
||||
.as_ref()
|
||||
@@ -173,29 +171,26 @@ impl RepoStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Filters that make up a repository: announcement, state,
|
||||
/// activity and deletions targeting it.
|
||||
/// Filters that make up a repository.
|
||||
/// Announcement, state, activity and deletions targeting it.
|
||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||
let mut filters = vec![
|
||||
// Announcement and state share author and identifier, so they
|
||||
// combine into one filter: one fewer negentropy reconciliation
|
||||
// per relay when fetching from the repo's announced relays.
|
||||
// Announcement and state share author and identifier.
|
||||
// They combine into one filter, one fewer negentropy reconciliation per relay.
|
||||
Filter::new()
|
||||
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
|
||||
.author(addr.public_key)
|
||||
.identifier(addr.identifier.clone()),
|
||||
filters::activity(addr),
|
||||
];
|
||||
// Deletion requests (NIP-09/62) must be known before any event of
|
||||
// this repository can be shown.
|
||||
// Deletion requests, NIP-09/62, must be known before any event is shown.
|
||||
filters.extend(filters::deletions_for_repo(addr));
|
||||
filters
|
||||
}
|
||||
|
||||
/// Fetch this repository's events from the relays announced in its
|
||||
/// NIP-34 `relays` tag. Deduplicated: each relay is only contacted once
|
||||
/// per store, so refreshes after the first are no-ops unless the
|
||||
/// announcement lists new relays.
|
||||
/// 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()
|
||||
@@ -214,7 +209,7 @@ impl RepoStore {
|
||||
});
|
||||
}
|
||||
|
||||
/// Fetch this repository's events from the bootstrap relays
|
||||
/// Fetch this repository's events from the bootstrap relays.
|
||||
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
|
||||
let backend = Backend::global(cx);
|
||||
let addr = self.addr.clone();
|
||||
@@ -225,9 +220,8 @@ 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.
|
||||
/// 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;
|
||||
@@ -272,8 +266,8 @@ impl RepoStore {
|
||||
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Parse and sort off the main thread; only plain data
|
||||
// crosses back into the entity.
|
||||
// Parse and sort off the main thread.
|
||||
// Only plain data crosses back into the entity.
|
||||
let all_announcements = announcements
|
||||
.into_iter()
|
||||
.filter(|e| !deletions.is_deleted(e));
|
||||
@@ -302,9 +296,8 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// NIP-22 comments reference their root via an `E`/`e` tag rather
|
||||
// than the repository's `a` tag, so query them by the root events
|
||||
// of this repository.
|
||||
// NIP-22 comments reference their root via an `E` or `e` tag.
|
||||
// Not the repository's `a` tag, so query them by the root events.
|
||||
let mut seen_comments: HashSet<EventId> = comments.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
|
||||
@@ -322,8 +315,8 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Status events may omit their `a` tag,
|
||||
// so also query them by the root events they reference.
|
||||
// Status events may omit their `a` tag.
|
||||
// Query them by the root events they reference too.
|
||||
let mut seen_statuses: HashSet<EventId> = statuses.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
|
||||
@@ -341,8 +334,8 @@ impl RepoStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Cover notes (1624) and label events (1985) reference
|
||||
// so query them per root like comments and statuses.
|
||||
// Cover notes, 1624, and label events, 1985, carry no `a` tag.
|
||||
// Query them per root like comments and statuses.
|
||||
let mut seen_cover_notes: HashSet<EventId> = cover_notes.iter().map(|e| e.id).collect();
|
||||
let mut seen_labels: HashSet<EventId> = labels.iter().map(|e| e.id).collect();
|
||||
let db = client.database();
|
||||
@@ -373,9 +366,9 @@ impl RepoStore {
|
||||
sort_newest_first(&mut cover_notes);
|
||||
sort_newest_first(&mut labels);
|
||||
|
||||
// Resolve every root's status once here; render paths do
|
||||
// HashMap lookups instead of scanning all status events per
|
||||
// root (quadratic, with an allocation per pair).
|
||||
// Resolve every root's status once here.
|
||||
// Render paths do HashMap lookups instead of per-root status scans.
|
||||
// Those scans are quadratic, with an allocation per pair.
|
||||
let maintainers = announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
@@ -441,8 +434,8 @@ impl RepoStore {
|
||||
let again = this.update(cx, |this, cx| {
|
||||
this.announcement = announcement;
|
||||
|
||||
// The announcement may list relays for this repository's activity,
|
||||
// connect to any we haven't fetched from yet.
|
||||
// The announcement may list relays for this repository's activity.
|
||||
// Connect to any we have not fetched from yet.
|
||||
let relays = this
|
||||
.announcement
|
||||
.as_ref()
|
||||
@@ -466,10 +459,10 @@ impl RepoStore {
|
||||
this.labels = labels;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments, statuses without an `a` tag, cover notes and
|
||||
// labels are not addressed to the repository, so fetch them
|
||||
// by the root events they reference, on the bootstrap relays
|
||||
// and on the relays this repository announced.
|
||||
// Comments, statuses without an `a` tag, cover notes and labels.
|
||||
// None are addressed to the repository.
|
||||
// Fetch them by the root events they reference.
|
||||
// Use the bootstrap relays and the relays this repository announced.
|
||||
let roots = this
|
||||
.issues
|
||||
.iter()
|
||||
@@ -486,10 +479,9 @@ impl RepoStore {
|
||||
|
||||
if !new_roots.is_empty() {
|
||||
this.root_fetches.extend(new_roots.iter().copied());
|
||||
// Batch the per-root filters: one statuses filter and one
|
||||
// annotations filter covering all new roots, instead of
|
||||
// one filter per root (each filter is a separate
|
||||
// negentropy reconciliation per relay).
|
||||
// Batch the per-root filters.
|
||||
// One statuses filter and one annotations filter cover all new roots.
|
||||
// One filter per root costs a negentropy reconciliation per relay.
|
||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
||||
root_filters.push(filters::annotations_for(new_roots));
|
||||
@@ -513,7 +505,8 @@ impl RepoStore {
|
||||
}
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running are coalesced into one follow-up refresh.
|
||||
// Requests that arrived while the refresh was running.
|
||||
// They are coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
@@ -522,7 +515,7 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Resolve the status of a root event (issue / patch / PR) per NIP-34
|
||||
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
||||
pub fn status_of(&self, root: &Event) -> RepoStatus {
|
||||
status_of(&self.status_by_root, root)
|
||||
}
|
||||
@@ -533,8 +526,8 @@ impl RepoStore {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// The effective cover note of `root` (kind 1624), if any:
|
||||
/// the latest note authored by the root author or a maintainer.
|
||||
/// 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
|
||||
@@ -545,8 +538,8 @@ impl RepoStore {
|
||||
cover_note(root, &self.cover_notes, &maintainers)
|
||||
}
|
||||
|
||||
/// The effective hashtag labels of `root`: its own `t` tags plus labels
|
||||
/// from authorized NIP-32 kind-1985 events (`#t` namespace).
|
||||
/// 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
|
||||
@@ -558,8 +551,8 @@ impl RepoStore {
|
||||
labels
|
||||
}
|
||||
|
||||
/// The effective subject/title override of `root` from authorized
|
||||
/// kind-1985 events (`#subject` namespace), if any.
|
||||
/// 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
|
||||
@@ -570,22 +563,23 @@ impl RepoStore {
|
||||
subject_override(root, &self.labels, &maintainers)
|
||||
}
|
||||
|
||||
/// Number of open issues: issues whose resolved status is
|
||||
/// [`RepoStatus::Open`] (issues without status events default to open).
|
||||
/// Number of open issues.
|
||||
/// Issues whose resolved status is [`RepoStatus::Open`].
|
||||
/// Issues without status events default to open.
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.open_issue_count
|
||||
}
|
||||
|
||||
/// Number of open pull requests: root PR events (not PR updates, whose
|
||||
/// status is carried by the root) with a resolved status of
|
||||
/// [`RepoStatus::Open`].
|
||||
/// 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 {
|
||||
self.open_pr_count
|
||||
}
|
||||
|
||||
/// Whether `user` is the author (owner) of this repository: the public
|
||||
/// key of the repository address. Only the author may manage the
|
||||
/// repository's pull requests (close / reopen / merge).
|
||||
/// 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 {
|
||||
&self.addr.public_key == user
|
||||
}
|
||||
@@ -603,19 +597,19 @@ impl RepoStore {
|
||||
self.send(builder, cx);
|
||||
}
|
||||
|
||||
/// Comments on a root event (issue / PR), oldest first.
|
||||
/// Comments on a root event, an issue or PR, oldest first.
|
||||
pub fn comments_of(&self, root: &EventId) -> impl Iterator<Item = &Event> {
|
||||
self.comments
|
||||
.iter()
|
||||
.filter(move |e| signed_core::references_root(e, root))
|
||||
}
|
||||
|
||||
/// Comment on a root event (issue / PR) per NIP-34 (kind 1111)
|
||||
/// Comment on a root event, an issue or PR, per NIP-34, kind 1111.
|
||||
pub fn comment(&mut self, root: &Event, content: String, cx: &mut Context<Self>) {
|
||||
self.reply(root, None, content, cx);
|
||||
}
|
||||
|
||||
/// Reply to `parent` (a comment on `root`) with a NIP-22 threaded comment,
|
||||
/// 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,
|
||||
@@ -636,31 +630,32 @@ impl RepoStore {
|
||||
);
|
||||
}
|
||||
|
||||
/// Open a pull request on this repository: a root PR event (kind 1618)
|
||||
/// whose content is the markdown description, plus a root patch event
|
||||
/// (kind 1617) carrying the `git format-patch` output, which the PR
|
||||
/// references 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 under [`MAX_PATCH_EVENT_BYTES`])
|
||||
/// so the PR can reference the root patch's id. The proposed commit is
|
||||
/// parsed from the series' last `From <commit>` header (the tip); without
|
||||
/// one publishing is refused, because the PR's `c` tag must carry a real
|
||||
/// commit id for other NIP-34 clients to verify and apply the proposal.
|
||||
///
|
||||
/// The `clone` tag carries the author's GRASP-06 `/prs/` URLs first
|
||||
/// (resolved from their kind-10317 grasp list, falling back to the
|
||||
/// settings defaults) plus the announced mirror URLs, so the tip is
|
||||
/// downloadable on the author's own hosting even when the base project
|
||||
/// accepts nothing. When `push_from` is set, the tip is pushed to those
|
||||
/// servers under `refs/nostr/<event-id>` (best-effort, author servers
|
||||
/// first) 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, computed from a
|
||||
/// local checkout when the patch was generated there.
|
||||
/// 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,
|
||||
@@ -694,7 +689,8 @@ impl RepoStore {
|
||||
return;
|
||||
}
|
||||
|
||||
// The tip of the series is its last commit; `git format-patch` orders patches oldest first.
|
||||
// The tip of the series is its last commit.
|
||||
// `git format-patch` orders patches oldest first.
|
||||
let Some(current_commit) = series
|
||||
.last()
|
||||
.and_then(|part| patch_current_commit(part))
|
||||
@@ -715,7 +711,7 @@ impl RepoStore {
|
||||
cx.notify();
|
||||
return;
|
||||
};
|
||||
// The author's npub names their GRASP-06 namespace (`/prs/...`).
|
||||
// The author's npub names their GRASP-06 namespace, `/prs/...`.
|
||||
let author_npub = user.to_bech32().unwrap_or_else(|_| user.to_hex());
|
||||
|
||||
let addr = self.addr.clone();
|
||||
@@ -729,8 +725,8 @@ impl RepoStore {
|
||||
.map(|a| a.relays.clone())
|
||||
.unwrap_or_default();
|
||||
|
||||
// GRASP-06 hosting falls back to the settings defaults when
|
||||
// the author has no published grasp list
|
||||
// GRASP-06 hosting falls back to the settings defaults.
|
||||
// That happens when the author has no published grasp list.
|
||||
let defaults: Vec<RelayUrl> = {
|
||||
let settings = settings::SettingsStore::global(cx).read(cx).settings();
|
||||
let urls: Vec<String> = if settings.grasp_servers.default_servers.is_empty() {
|
||||
@@ -747,7 +743,8 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
// The PR references the root patch event so viewers can find the patch without carrying it inline.
|
||||
// The PR references the root patch event.
|
||||
// Viewers can then find the patch without carrying it inline.
|
||||
let root_patch = match publish_patch_series(
|
||||
&this,
|
||||
cx,
|
||||
@@ -769,12 +766,11 @@ impl RepoStore {
|
||||
}
|
||||
};
|
||||
|
||||
// GRASP-06: the tip is pushed to the author's own grasp servers
|
||||
// under `/prs/<author-npub>/<repo-id>.git`, so contributing to
|
||||
// someone else's project never depends on their servers
|
||||
// accepting the push. Resolve them from the author's latest
|
||||
// kind-10317 grasp list; the settings defaults stand in when no
|
||||
// list is published (or the query fails).
|
||||
// GRASP-06 pushes the tip to the author's own grasp servers.
|
||||
// The path is `/prs/<author-npub>/<repo-id>.git`.
|
||||
// Contributing to another project never depends on that project's servers.
|
||||
// Resolve the servers from the author's latest kind-10317 grasp list.
|
||||
// The settings defaults stand in when no list is published or the query fails.
|
||||
let author_servers = {
|
||||
let query = this.update(cx, |_this, cx| {
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
@@ -814,13 +810,15 @@ impl RepoStore {
|
||||
};
|
||||
|
||||
let builder = this.update(cx, |this, _cx| {
|
||||
// NIP-34: PRs carry at least one clone URL where the tip
|
||||
// commit can be downloaded. The author's `/prs/` URLs come
|
||||
// first (author-controlled, most likely alive), then the
|
||||
// announced mirrors. The list is fixed before signing: the
|
||||
// pushed ref name embeds the event id, so every candidate
|
||||
// URL is listed up front; dead URLs are inert, the linked
|
||||
// patch stays the source of truth.
|
||||
// NIP-34 PRs carry at least one clone URL.
|
||||
// The tip commit is downloadable from it.
|
||||
// The author's `/prs/` URLs come first.
|
||||
// They are author-controlled and most likely alive.
|
||||
// The announced mirrors follow.
|
||||
// The list is fixed before signing.
|
||||
// The pushed ref name embeds the event id.
|
||||
// Every candidate URL is listed up front.
|
||||
// Dead URLs are inert, the linked patch stays the source of truth.
|
||||
let prs_urls: Vec<Url> = author_targets
|
||||
.iter()
|
||||
.filter_map(|(url, _)| Url::parse(url).ok())
|
||||
@@ -846,16 +844,16 @@ impl RepoStore {
|
||||
}
|
||||
.into_event_builder();
|
||||
|
||||
// NIP-34: the `r` EUC tag lets clients subscribe to all PRs of this repository
|
||||
// The `r` EUC tag lets clients subscribe to all PRs of the repository.
|
||||
match this.announcement.as_ref().and_then(|a| a.euc.clone()) {
|
||||
Some(euc) => builder.tag(Tag::parse(["r", &euc]).expect("valid r tag")),
|
||||
None => builder,
|
||||
}
|
||||
})?;
|
||||
|
||||
// Sign before publishing so the tip can be pushed to the grasp
|
||||
// servers under `refs/nostr/<event-id>` (nak's convention):
|
||||
// readers fetch that ref to get the commit behind the `c` tag.
|
||||
// Sign before publishing.
|
||||
// The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
|
||||
// Nak's convention, readers fetch that ref for the commit behind the `c` tag.
|
||||
let event = cx
|
||||
.background_spawn({
|
||||
let signer = signer.clone();
|
||||
@@ -871,8 +869,8 @@ impl RepoStore {
|
||||
let path = path.clone();
|
||||
let tip = tip.clone();
|
||||
let reference = reference.clone();
|
||||
// Author servers first, then the base repository's
|
||||
// announced grasp servers (best-effort redundancy).
|
||||
// Author servers first, then the announced base grasp servers.
|
||||
// The extra targets are best-effort redundancy.
|
||||
let targets: Vec<(String, String)> = author_targets
|
||||
.into_iter()
|
||||
.chain(base_targets)
|
||||
@@ -919,8 +917,8 @@ impl RepoStore {
|
||||
}
|
||||
};
|
||||
|
||||
// NIP-34: a draft PR carries a kind-1633 status event,
|
||||
// publish it right after the PR event so viewers never show it open.
|
||||
// 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 {
|
||||
this.update(cx, |this, cx| {
|
||||
this.set_status(&pr_event, RepoStatus::Draft, cx);
|
||||
@@ -931,11 +929,12 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Update a pull request: publish revision patch events chained to the
|
||||
/// original root patch (`t root-revision` and a NIP-10 `e` reply on the
|
||||
/// first, per NIP-34), then a kind-1619 PR update event carrying the new tip.
|
||||
///
|
||||
/// Only the PR author may update it; other authors must open a new PR.
|
||||
/// 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;
|
||||
self.last_warning = None;
|
||||
@@ -984,9 +983,8 @@ impl RepoStore {
|
||||
return;
|
||||
};
|
||||
|
||||
// NIP-34: the first patch of a revision replies to the original
|
||||
// root patch (the PR's `e` tag; fall back to the oldest patch of
|
||||
// the linked set for PRs without one).
|
||||
// The first revision patch replies to the original root patch, NIP-34.
|
||||
// Use the PR's `e` tag, or the oldest patch of the linked set if the PR has none.
|
||||
let root_patch_id = root.tags.event_ids().next().or_else(|| {
|
||||
pull_request_patches(root, self.patches.iter())
|
||||
.first()
|
||||
@@ -1033,8 +1031,8 @@ impl RepoStore {
|
||||
}
|
||||
.into_event_builder();
|
||||
|
||||
// NIP-34: the `r` EUC tag lets clients subscribe to all PR
|
||||
// updates of this repository; the SDK builder omits it.
|
||||
// The `r` EUC tag lets clients subscribe to all PR updates.
|
||||
// The SDK builder omits it.
|
||||
let builder = match euc.as_deref() {
|
||||
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
||||
None => builder,
|
||||
@@ -1055,9 +1053,9 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Set the status of a root event. Per NIP-34 only the root author or a
|
||||
/// repository maintainer may set the status; status events from anyone
|
||||
/// else are ignored by clients, so refuse them up front.
|
||||
/// 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;
|
||||
|
||||
@@ -1094,9 +1092,10 @@ impl RepoStore {
|
||||
self.send(builder, cx);
|
||||
}
|
||||
|
||||
/// Publish a repository state announcement (kind 30618) with the refs of
|
||||
/// the local clone: branches, tags and HEAD. Only the repository owner
|
||||
/// may publish state, and a local clone must exist to read the refs from.
|
||||
/// 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.
|
||||
pub fn publish_state(&mut self, cx: &mut Context<Self>) {
|
||||
self.last_error = None;
|
||||
|
||||
@@ -1146,17 +1145,17 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Merge a pull request: apply its patch (the content of the linked
|
||||
/// root patch event) to the local clone of this repository, then publish
|
||||
/// a kind-1631 (Applied) status event with merge provenance: the commits
|
||||
/// `git am` created (`applied-as-commits` + `r` tags) and the applied
|
||||
/// patch events (`q` tags, plus `e` reply tags for every patch beyond
|
||||
/// the root, per NIP-34).
|
||||
///
|
||||
/// Only the repository author may merge. The clone is created on demand
|
||||
/// from the announcement's clone URLs when needed. Patch application
|
||||
/// (`git am`) runs on a background thread; failures (e.g. a patch that
|
||||
/// no longer applies) surface in [`Self::last_error`].
|
||||
/// 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;
|
||||
@@ -1198,8 +1197,8 @@ impl RepoStore {
|
||||
.workdir()
|
||||
.ok_or_else(|| anyhow::anyhow!("repository has no worktree"))?
|
||||
.to_path_buf();
|
||||
// The commits created by the apply: everything between the
|
||||
// previous HEAD and the new one, oldest first.
|
||||
// The commits the apply created.
|
||||
// Everything between the previous HEAD and the new one, oldest first.
|
||||
let previous = signed_git::head_commit_id(&workdir)?;
|
||||
signed_git::apply_patch(&workdir, &patch)?;
|
||||
let applied = signed_git::commits_since(&workdir, previous.as_deref())?;
|
||||
@@ -1231,10 +1230,10 @@ impl RepoStore {
|
||||
}));
|
||||
}
|
||||
|
||||
/// Publish a kind-1631 (Applied) status event for `root` after a merge:
|
||||
/// `applied-as-commits` + `r` tags for the commits `git am` created,
|
||||
/// `q` tags for the applied patch events, and `e` reply tags for every
|
||||
/// patch of the series beyond the root (NIP-34).
|
||||
/// 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,
|
||||
@@ -1255,9 +1254,9 @@ impl RepoStore {
|
||||
{
|
||||
tags.push(tag);
|
||||
}
|
||||
// The applied patch events: a `q` tag per event, plus an `e` reply
|
||||
// for every event beyond the root (chain parts and revisions), so
|
||||
// their statuses resolve to Applied too.
|
||||
// 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.
|
||||
for (ix, patch) in patches.iter().enumerate() {
|
||||
if let Ok(tag) =
|
||||
Tag::parse(["q", &patch.id.to_hex(), relay_hint, &patch.pubkey.to_hex()])
|
||||
@@ -1312,8 +1311,9 @@ where
|
||||
events.into_iter().max_by_key(|e| e.created_at)
|
||||
}
|
||||
|
||||
/// Status of `root` from the precomputed map; roots without status events
|
||||
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
|
||||
/// 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)
|
||||
@@ -1321,10 +1321,10 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
|
||||
.unwrap_or(RepoStatus::Open)
|
||||
}
|
||||
|
||||
/// Resolve the status of every root event in one pass: status events are
|
||||
/// indexed by the root they reference (`e`/`E` tag), then each root
|
||||
/// resolves against its own slice. O(roots + statuses) instead of the
|
||||
/// O(roots × statuses) of resolving per root on demand.
|
||||
/// 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],
|
||||
@@ -1364,20 +1364,21 @@ fn sort_oldest_first(events: &mut [Event]) {
|
||||
events.sort_by_key(|e| e.created_at);
|
||||
}
|
||||
|
||||
/// The proposed commit of a `git format-patch` output: the `From <commit>`
|
||||
/// header on its first line.
|
||||
/// The proposed commit of a `git format-patch` output.
|
||||
/// It is the `From <commit>` header on the first line.
|
||||
fn patch_current_commit(patch: &str) -> Option<&str> {
|
||||
let line = patch.lines().next()?;
|
||||
let hex = line.strip_prefix("From ")?;
|
||||
hex.split_whitespace().next().filter(|hex| hex.len() == 40)
|
||||
}
|
||||
|
||||
/// Publish a `git format-patch` series as chained kind-1617 events and
|
||||
/// return the root event (the one a PR references). The first part carries
|
||||
/// `first_marker` (`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, its own
|
||||
/// `commit`/`r` tags, and the repository EUC when known.
|
||||
/// 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>,
|
||||
@@ -1444,10 +1445,11 @@ async fn publish_patch_series(
|
||||
root.ok_or_else(|| anyhow::anyhow!("patch series is empty"))
|
||||
}
|
||||
|
||||
/// Build a NIP-22 kind-1111 comment: uppercase `E`/`K`/`P` tags scope the
|
||||
/// thread root, lowercase `e`/`k`/`p` the direct parent (or the root for a
|
||||
/// top-level comment). An `a` tag with the repository coordinate (not part
|
||||
/// of NIP-22) is added so Signed's own activity subscriptions also match.
|
||||
/// 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>,
|
||||
@@ -1514,15 +1516,15 @@ mod tests {
|
||||
assert!(kinds.contains(&expected), "missing {expected} tag");
|
||||
}
|
||||
|
||||
// The uppercase `E` tag scopes the root: id, relay hint and author.
|
||||
// The uppercase `E` tag scopes the root, with its id, relay hint and author.
|
||||
let e = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
|
||||
let slice = e.as_slice();
|
||||
assert_eq!(slice[1], root.id.to_hex());
|
||||
assert_eq!(slice[2], relay.as_str());
|
||||
assert_eq!(slice[3], root.pubkey.to_hex());
|
||||
|
||||
// The lowercase `e` tag references the parent, which for a top-level
|
||||
// comment is the root itself.
|
||||
// The lowercase `e` tag references the parent.
|
||||
// For a top-level comment the parent is the root itself.
|
||||
let e = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
|
||||
assert_eq!(e.as_slice()[1], root.id.to_hex());
|
||||
|
||||
@@ -1545,8 +1547,8 @@ mod tests {
|
||||
.finalize(&keys)
|
||||
.expect("signed event");
|
||||
|
||||
// The uppercase `E` tag still scopes the root event, while the
|
||||
// lowercase `e` tag references the parent comment.
|
||||
// The uppercase `E` tag still scopes the root event.
|
||||
// The lowercase `e` tag references the parent comment.
|
||||
let root_ref = event.tags.iter().find(|t| t.kind() == "E").expect("E tag");
|
||||
let parent_ref = event.tags.iter().find(|t| t.kind() == "e").expect("e tag");
|
||||
assert_eq!(root_ref.as_slice()[1], root.id.to_hex());
|
||||
|
||||
@@ -9,8 +9,8 @@ use signed_core::{Announcement, Deletions, RepoAddr, filters, repo_addr};
|
||||
|
||||
use crate::backend::{Backend, BackendEvent};
|
||||
|
||||
/// Delay between a refresh request and the actual re-query, so bursts of
|
||||
/// events (e.g. sync progress ticks) collapse into one query.
|
||||
/// Delay between a refresh request and the actual re-query.
|
||||
/// Bursts of events, e.g. sync progress ticks, collapse into one query.
|
||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||
|
||||
/// How far back activity events count toward a repository's last activity.
|
||||
@@ -20,40 +20,39 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
||||
|
||||
impl Global for GlobalRepoListStore {}
|
||||
|
||||
/// Counts of NIP-34 activity events per repository, used to rank the
|
||||
/// explore list by popularity. Each patch event is a pushed commit (or a
|
||||
/// small series), the closest proxy for commit count in the event data.
|
||||
/// NIP-34 activity event counts per repository, ranking the explore list by popularity.
|
||||
/// 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
|
||||
/// (updates to a PR are not new PRs and don't count).
|
||||
/// Root `3063` pull request events addressed to the repository.
|
||||
/// PR updates are not new PRs and do not count.
|
||||
pub pull_requests: u32,
|
||||
/// `1617` patch events addressed to the repository.
|
||||
pub commits: u32,
|
||||
}
|
||||
|
||||
impl RepoActivityCounts {
|
||||
/// Total issues + pull requests + commits; the popularity ranking key.
|
||||
/// Total issues, pull requests and commits, the popularity ranking key.
|
||||
pub fn score(self) -> u32 {
|
||||
self.issues + self.pull_requests + self.commits
|
||||
}
|
||||
}
|
||||
|
||||
/// Store listing repository announcements (global discovery or per-author).
|
||||
///
|
||||
/// The all-repos store (`author: None`) is created at startup by
|
||||
/// [`crate::init`] and installed as a global, so the explore panel renders
|
||||
/// what's in the local database without waiting for relays.
|
||||
/// Store listing 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>>,
|
||||
/// Latest known activity timestamp per repository
|
||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||
/// Latest known activity timestamp per repository.
|
||||
/// Covers announcements, state updates, patches, PRs, issues and statuses.
|
||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||
/// Issues + pull requests + commits per repository, for the Popular
|
||||
/// ranking of the explore list.
|
||||
/// Issues, pull requests and commits per repository.
|
||||
/// Used for the Popular ranking of the explore list.
|
||||
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
@@ -65,8 +64,8 @@ pub struct RepoListStore {
|
||||
}
|
||||
|
||||
impl RepoListStore {
|
||||
/// Retrieve the global explore store (all announcements, created at
|
||||
/// startup by [`crate::init`]).
|
||||
/// 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()
|
||||
}
|
||||
@@ -82,12 +81,12 @@ impl RepoListStore {
|
||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||
let relevant = match event {
|
||||
BackendEvent::NostrUpdate(update) => {
|
||||
// Deletions may target anything we list; always refresh.
|
||||
// Deletions may target anything we list, always refresh.
|
||||
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||
true
|
||||
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||
// Activity (patches, issues, ...) is addressed to repos via
|
||||
// `a` tags, so its author isn't the repo owner; always refresh.
|
||||
// Activity events are addressed to repos via `a` tags.
|
||||
// Their author is not the repo owner, always refresh.
|
||||
true
|
||||
} else {
|
||||
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||
@@ -99,9 +98,8 @@ impl RepoListStore {
|
||||
BackendEvent::Published(event) => {
|
||||
let announcement = event.kind == Kind::GitRepoAnnouncement
|
||||
&& this.author.is_none_or(|a| a == event.pubkey);
|
||||
// Locally published deletions (e.g. deleting a repo)
|
||||
// are already in the local database; refresh so they
|
||||
// take effect immediately, like relay deletions.
|
||||
// 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
|
||||
@@ -128,13 +126,13 @@ impl RepoListStore {
|
||||
};
|
||||
|
||||
store.subscribe_remote(cx);
|
||||
// Query the local database right away; the list never waits for the
|
||||
// relay syncs started above to finish.
|
||||
// Query the local database right away.
|
||||
// The list never waits for the relay syncs started above to finish.
|
||||
store.refresh_initial(cx);
|
||||
store
|
||||
}
|
||||
|
||||
/// Scope the list to an author (or clear the scope with `None`).
|
||||
/// Scope the list to an author, or clear the scope with `None`.
|
||||
pub fn set_author(&mut self, author: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.author = author;
|
||||
self.subscribe_remote(cx);
|
||||
@@ -152,14 +150,14 @@ impl RepoListStore {
|
||||
None => filters::all_announcements(),
|
||||
};
|
||||
backend.sync_bootstrap(filter, cx);
|
||||
// Deletion requests (NIP-09/62) must be known before any
|
||||
// announcement can be shown.
|
||||
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
|
||||
backend.sync_bootstrap(filters::deletions(), cx);
|
||||
});
|
||||
}
|
||||
|
||||
/// One-shot initial load: query the local database immediately (no
|
||||
/// debounce), so stored announcements appear as soon as the app opens.
|
||||
/// 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);
|
||||
@@ -170,12 +168,12 @@ impl RepoListStore {
|
||||
self.run_refresh(cx);
|
||||
}
|
||||
|
||||
/// Re-query the local database. Latest announcement per repository wins.
|
||||
///
|
||||
/// Debounced: a short delay collapses bursts of requests (e.g. sync
|
||||
/// progress ticks), and requests that arrive while a query is running
|
||||
/// are folded into one follow-up query. The query and processing run on
|
||||
/// a background thread; only the results are applied on the main thread.
|
||||
/// Re-query the local database.
|
||||
/// 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;
|
||||
@@ -198,7 +196,7 @@ impl RepoListStore {
|
||||
self.tasks.push(task);
|
||||
}
|
||||
|
||||
/// One query + apply cycle (debounced entry point).
|
||||
/// One query and apply cycle, the debounced entry point.
|
||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||
self.refreshing = true;
|
||||
|
||||
@@ -216,8 +214,8 @@ impl RepoListStore {
|
||||
let deletion_events = client.database().query(filters::deletions()).await?;
|
||||
let deletions = Deletions::from_events(deletion_events);
|
||||
|
||||
// Dedup and sort off the main thread; only the final list
|
||||
// crosses back into the entity.
|
||||
// Dedup and sort off the main thread.
|
||||
// Only the final list crosses back into the entity.
|
||||
let mut by_repo: HashMap<RepoAddr, Announcement> = HashMap::new();
|
||||
|
||||
for event in events {
|
||||
@@ -242,8 +240,9 @@ impl RepoListStore {
|
||||
let mut announcements: Vec<Announcement> = by_repo.into_values().collect();
|
||||
announcements.sort_by_key(|a| std::cmp::Reverse(a.created_at));
|
||||
|
||||
// Last activity per repository: state updates plus all NIP-34
|
||||
// activity events (patches, PRs, issues, statuses).
|
||||
// Last activity per repository.
|
||||
// State updates count, and all NIP-34 activity events.
|
||||
// The activity events are patches, PRs, issues and statuses.
|
||||
let mut last_activity: HashMap<RepoAddr, Timestamp> = announcements
|
||||
.iter()
|
||||
.map(|a| (a.addr(), a.created_at))
|
||||
@@ -264,8 +263,8 @@ impl RepoListStore {
|
||||
*entry = (*entry).max(event.created_at);
|
||||
}
|
||||
|
||||
// Bound the activity query to a recent window; older repos fall
|
||||
// back to their announcement / state timestamps.
|
||||
// Bound the activity query to a recent window.
|
||||
// Older repos fall back to their announcement or state timestamps.
|
||||
let activity_filter = Filter::new()
|
||||
.kinds(filters::ACTIVITY_KINDS)
|
||||
.since(Timestamp::now() - ACTIVITY_WINDOW);
|
||||
@@ -277,8 +276,8 @@ impl RepoListStore {
|
||||
if addr.kind != Kind::GitRepoAnnouncement {
|
||||
continue;
|
||||
}
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
let Some(entry) = last_activity.get_mut(&addr) else {
|
||||
continue;
|
||||
};
|
||||
@@ -286,9 +285,8 @@ impl RepoListStore {
|
||||
}
|
||||
}
|
||||
|
||||
// Popularity counts per repository (issues, pull requests and
|
||||
// patches). Unbounded, unlike the windowed activity query
|
||||
// above, so totals are exact.
|
||||
// Popularity counts per repository, issues, pull requests and patches.
|
||||
// Unbounded, unlike the windowed activity query above, so totals are exact.
|
||||
let mut counts: HashMap<RepoAddr, RepoActivityCounts> = HashMap::new();
|
||||
let count_filter =
|
||||
Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]);
|
||||
@@ -297,8 +295,8 @@ impl RepoListStore {
|
||||
continue;
|
||||
}
|
||||
for addr in event.tags.coordinates() {
|
||||
// Skip events for repos we don't list, so the map can't
|
||||
// grow beyond the number of announcements.
|
||||
// Skip events for repos we do not list.
|
||||
// The map cannot grow beyond the number of announcements.
|
||||
if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr)
|
||||
{
|
||||
continue;
|
||||
@@ -319,7 +317,7 @@ impl RepoListStore {
|
||||
self.tasks.push(cx.spawn(async move |this, cx| {
|
||||
let (announcements, last_activity, counts) = match work.await {
|
||||
Ok(results) => results,
|
||||
// Database errors are transient; keep the last list.
|
||||
// Database errors are transient, keep the last list.
|
||||
Err(_) => {
|
||||
return this.update(cx, |this, _cx| {
|
||||
this.refreshing = false;
|
||||
@@ -342,8 +340,8 @@ impl RepoListStore {
|
||||
}
|
||||
})?;
|
||||
|
||||
// Requests that arrived while the refresh was running are
|
||||
// coalesced into one follow-up refresh.
|
||||
// Requests that arrived while the refresh was running.
|
||||
// They are coalesced into one follow-up refresh.
|
||||
if again {
|
||||
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user