chore: clean up codebase (#19)
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run

Reviewed-on: #19
This commit was merged in pull request #19.
This commit is contained in:
2026-09-13 09:42:08 +00:00
parent 40deb9db66
commit f6b8a5e133
82 changed files with 3559 additions and 7862 deletions
-152
View File
@@ -17,7 +17,6 @@ use crate::git_store::GitStore;
use crate::inbox::Inbox;
use crate::repos::RepoListStore;
/// Keyring entry for the user credential.
pub const USER_KEYRING: &str = "Signed Safe Storage";
/// Timeout for NIP-46 signer responses.
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
@@ -33,12 +32,10 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// 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"];
/// Delay the notification pump waits for more events before emitting a batch.
const PUMP_DEBOUNCE: Duration = Duration::from_millis(200);
#[derive(Debug, Clone)]
pub enum BackendEvent {
/// User has no signer configured.
SignerRequired,
/// The stored identity is NIP-49 encrypted key.
PassphraseRequired,
@@ -51,18 +48,13 @@ pub enum BackendEvent {
/// instead of emitting per-event and making every subscriber debounce
/// the same burst independently.
NostrUpdate(Vec<Update>),
/// A negentropy sync completed.
Synced,
/// A negentropy sync is in flight.
SyncProgress {
/// Total events to process.
total: u64,
/// Events processed so far.
current: u64,
},
/// An event built locally was signed, broadcast and stored.
Published(Box<Event>),
/// An error occurred.
Error(String),
}
@@ -79,13 +71,10 @@ pub struct Backend {
client: Client,
signer: UniversalSigner,
current_user: Option<PublicKey>,
/// User's inbox, including notifications and recent activity.
inbox: Entity<Inbox>,
/// The progress of the current sync operation, if any.
sync_progress: Option<(u64, u64)>,
/// True when the stored credential is NIP-49 encrypted.
passphrase_required: bool,
/// Repositories with a push in flight, mirror or checkout based.
pushing_repos: Entity<HashSet<RepoAddr>>,
}
@@ -96,7 +85,6 @@ impl Global for GlobalBackend {}
impl EventEmitter<BackendEvent> for Backend {}
impl Backend {
/// Retrieve the global backend.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalBackend>().0.clone()
}
@@ -114,7 +102,6 @@ impl Backend {
let mut pending: Vec<Update> = Vec::new();
'outer: loop {
// Wait for the first event of a batch.
match notifications.next().await {
Some(ClientNotification::Event { event, .. }) => {
pending.push(Update::from_event(&event));
@@ -123,7 +110,6 @@ impl Backend {
None => break,
}
// Collect everything else that arrives within the debounce window.
let deadline = Instant::now() + PUMP_DEBOUNCE;
loop {
@@ -152,7 +138,6 @@ impl Backend {
}
}
// Collect and emit the collected events.
let batch = std::mem::take(&mut pending);
if let Err(e) =
@@ -167,7 +152,6 @@ impl Backend {
pump.detach();
// Bootstrap the client.
cx.defer(move |cx| {
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
log::warn!("backend dropped before bootstrap could run: {error}");
@@ -185,7 +169,6 @@ impl Backend {
}
}
/// Bootstrap the client and restore the saved session, if any.
fn bootstrap(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -259,7 +242,6 @@ impl Backend {
signer.auth_url_handler(SignedAuthUrlHandler);
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
} else if content.starts_with("ncryptsec1") {
// A passphrase is required to decrypt it before the session can resume.
this.update(cx, |this, cx| {
this.passphrase_required = true;
cx.emit(BackendEvent::PassphraseRequired);
@@ -319,7 +301,6 @@ impl Backend {
})
}
/// Create a new identity.
pub fn create_identity(
&mut self,
name: &str,
@@ -348,7 +329,6 @@ impl Backend {
let (keys, ncryptsec) = job.await?;
let public_key = keys.public_key();
// Persist the encrypted credential.
let write = cx.update(|cx| {
cx.write_credentials(USER_KEYRING, &public_key.to_hex(), ncryptsec.as_bytes())
});
@@ -446,7 +426,6 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to create a repository")));
};
// The repository identifier is derived from the name.
let repo_id = identifier_from_name(&name);
if repo_id.is_empty() || repo_id.len() > 100 {
@@ -629,7 +608,6 @@ impl Backend {
return Task::ready(Err(anyhow!("Sign in to publish a 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 {
@@ -937,7 +915,6 @@ impl Backend {
let addr = addr.clone();
cx.spawn(async move |this, cx| {
// Collect every event of the repository from the local database.
let events = cx.background_spawn(async move {
let db = client.database();
let mut events = Vec::new();
@@ -974,7 +951,6 @@ impl Backend {
}
}
/// Create a fresh identity and login with it.
pub fn login_with_new_identity(&mut self, cx: &mut Context<Self>) {
let nsec = Keys::generate()
.secret_key()
@@ -983,7 +959,6 @@ impl Backend {
self.login_with_nsec(&nsec, cx);
}
/// Login with an `nsec1...` secret key.
pub fn login_with_nsec(&mut self, nsec: &str, cx: &mut Context<Self>) {
let keys = match SecretKey::parse(nsec) {
Ok(secret) => Keys::new(secret),
@@ -1053,7 +1028,6 @@ impl Backend {
task.detach();
}
/// Remove the saved credential and reset to an anonymous session.
pub fn logout(&mut self, cx: &mut Context<Self>) {
let delete = cx.delete_credentials(USER_KEYRING);
@@ -1075,7 +1049,6 @@ impl Backend {
task.detach();
}
/// Sync the user's grasp list and add the listed grasp servers as relays.
fn bootstrap_user(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1105,46 +1078,34 @@ impl Backend {
task.detach();
}
/// Get the nostr client.
pub fn client(&self) -> Client {
self.client.clone()
}
/// Get the current signer.
pub fn signer(&self) -> UniversalSigner {
self.signer.clone()
}
/// Repositories with a push in flight, mirror or checkout based.
///
/// A child entity: `cx.observe` it to react only to push-state changes.
pub fn pushing_repos(&self) -> Entity<HashSet<RepoAddr>> {
self.pushing_repos.clone()
}
/// The inbox child entity backing the home screen.
///
/// A child entity: `cx.observe` it to react only to inbox changes.
pub fn inbox(&self) -> Entity<Inbox> {
self.inbox.clone()
}
/// Get the current user's public key.
pub fn current_user(&self) -> Option<PublicKey> {
self.current_user
}
/// True when the stored credential is NIP-49 encrypted.
pub fn passphrase_required(&self) -> bool {
self.passphrase_required
}
/// Surface an error message through [`BackendEvent::Error`].
pub fn emit_error(&mut self, message: impl Into<String>, cx: &mut Context<Self>) {
cx.emit(BackendEvent::error(message));
}
/// Attach the inbox to the current signer and activate or clear it.
fn sync_inbox(&mut self, cx: &mut Context<Self>) {
let client = self.client.clone();
let me = self.current_user;
@@ -1173,12 +1134,10 @@ impl Backend {
});
}
/// Progress of the in-flight negentropy sync, if any.
pub fn sync_progress(&self) -> Option<(u64, u64)> {
self.sync_progress
}
/// Update the signer.
pub fn set_signer<T>(&mut self, new_signer: T, cx: &mut Context<Self>)
where
T: AsyncGetPublicKey + AsyncSignEvent + AsyncNip44 + 'static,
@@ -1229,7 +1188,6 @@ impl Backend {
.detach();
}
/// One-shot subscription on the bootstrap relays only.
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
@@ -1247,7 +1205,6 @@ impl Backend {
.detach();
}
/// Negentropy-sync the given filter against the bootstrap relays.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel();
@@ -1399,7 +1356,6 @@ async fn publish_best_effort(client: &Client, signer: &UniversalSigner, builder:
}
}
/// Add the given relays, connect and fetch the filters.
async fn connect_repo_relays(
client: &Client,
relays: Vec<RelayUrl>,
@@ -1409,12 +1365,10 @@ async fn connect_repo_relays(
return Ok(());
}
// Ensure relay connections
for url in relays.iter() {
client.add_relay(url).and_connect().await?;
}
// Run neg sync for each filter
for filter in filters.into_iter() {
if let Err(e) = client.sync(filter).with(relays.iter()).await {
log::warn!("repo relay negentropy sync failed: {e}");
@@ -1424,7 +1378,6 @@ async fn connect_repo_relays(
Ok(())
}
/// Subscribe only on the bootstrap relays.
pub(crate) async fn subscribe_bootstrap_only(
client: &Client,
filters: Vec<Filter>,
@@ -1443,7 +1396,6 @@ pub(crate) async fn subscribe_bootstrap_only(
Ok(())
}
/// Negentropy-sync the filter against the bootstrap relays only.
pub(crate) async fn sync_bootstrap_only(
client: &Client,
filter: Filter,
@@ -1481,7 +1433,6 @@ pub(crate) fn grasp_base_url(relay: &RelayUrl) -> Option<String> {
Some(format!("{scheme}://{host}{port}"))
}
/// GRASP clone URL of a repository on a grasp server.
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()
@@ -1526,7 +1477,6 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
.unwrap_or_default()
}
/// Resolve the user's published grasp servers from the local database.
pub async fn user_grasp_list_servers(
client: Client,
user: PublicKey,
@@ -1540,18 +1490,14 @@ pub async fn user_grasp_list_servers(
Ok(latest_grasp_list_servers(events))
}
/// Attempts per grasp server when a git push is denied transiently.
const GRASP_PUSH_ATTEMPTS: usize = 3;
/// Pause before re-staging a state event after a transient denial.
const GRASP_RETRY_DELAY: Duration = Duration::from_secs(1);
/// The outcome of pushing to one grasp server.
#[derive(Debug, Clone)]
pub struct GraspServerResult {
/// The grasp server's relay URL, e.g. `wss://relay.ngit.dev`.
pub relay: RelayUrl,
/// The git URL the data was pushed to.
pub git_url: String,
/// `None` when the server accepted the data, the reason otherwise.
pub reason: Option<String>,
@@ -1575,7 +1521,6 @@ impl GraspServerResult {
}
}
/// The outcome of a staged push across every grasp server of a repository.
#[derive(Debug, Clone, Default)]
pub struct PushOutcome {
/// Per-server results, in the order the servers were listed.
@@ -1587,7 +1532,6 @@ pub struct PushOutcome {
}
impl PushOutcome {
/// The number of grasp servers that accepted the git data.
pub fn accepted(&self) -> usize {
self.servers
.iter()
@@ -1595,12 +1539,10 @@ impl PushOutcome {
.count()
}
/// Servers that did not accept the push.
fn failing(&self) -> impl Iterator<Item = &GraspServerResult> {
self.servers.iter().filter(|server| server.reason.is_some())
}
/// One-line summary of every server failure, for error messages.
pub fn failure_summary(&self) -> String {
self.failing()
.map(|server| {
@@ -1628,7 +1570,6 @@ impl PushOutcome {
}
}
/// Collapse a multi-line relay or git error into one display line.
fn flatten_whitespace(text: &str) -> String {
const MAX_CHARS: usize = 200;
let flat: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
@@ -1760,7 +1701,6 @@ async fn stage_event_on_relay(
}
}
/// Push the repository at `path` to every grasp server in `servers`.
#[allow(clippy::too_many_arguments)]
async fn push_staged_to_grasps(
client: &Client,
@@ -1955,56 +1895,6 @@ mod tests {
);
}
fn grasp_list_event(servers: &[&str], created_at: u64) -> Event {
let keys = Keys::generate();
let tags: Vec<Tag> = servers
.iter()
.map(|url| Tag::parse(vec!["g", *url]).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitUserGraspList, "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at))
.finalize(&keys)
.expect("signed event")
}
#[test]
fn grasp_list_servers_reads_g_tags_in_order() {
let event = grasp_list_event(
&["wss://first.example", "wss://second.example", "not a url"],
1000,
);
let servers = grasp_list_servers(&event);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://first.example", "wss://second.example"]
);
}
#[test]
fn latest_grasp_list_servers_takes_the_newest_list_and_falls_back_empty() {
let old = grasp_list_event(&["wss://old.example"], 1000);
let fresh = grasp_list_event(&["wss://fresh.example", "wss://also.example"], 2000);
// The newest list wins, its `g` order preserved.
let servers = latest_grasp_list_servers(vec![old.clone(), fresh.clone()]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// The order of the input events does not matter.
let servers = latest_grasp_list_servers(vec![fresh, old]);
assert_eq!(
servers.iter().map(ToString::to_string).collect::<Vec<_>>(),
vec!["wss://fresh.example", "wss://also.example"]
);
// No list at all, empty, so the caller falls back to the defaults.
assert!(latest_grasp_list_servers(Vec::new()).is_empty());
}
#[test]
fn transient_grasp_denials_are_classified() {
// The exact server rejection that started this work: the state event
@@ -2074,13 +1964,6 @@ mod tests {
));
}
#[test]
fn transient_denial_markers_match_case_insensitively() {
assert!(is_transient_grasp_denial(
"ERR NO STATE EVENTS IN PURGATORY"
));
}
#[test]
fn push_outcome_reports_partial_failures() {
let outcome = PushOutcome {
@@ -2110,39 +1993,4 @@ mod tests {
// The multi-line server reason is a single display line.
assert_eq!(warning.lines().count(), 1);
}
#[test]
fn push_outcome_with_every_server_ok_has_no_warning() {
let outcome = PushOutcome {
servers: vec![
GraspServerResult::ok(
RelayUrl::parse("wss://gitnostr.com").expect("url"),
"https://gitnostr.com/npub1owner/repo.git".to_owned(),
),
GraspServerResult::ok(
RelayUrl::parse("wss://relay.ngit.dev").expect("url"),
"https://relay.ngit.dev/npub1owner/repo.git".to_owned(),
),
],
state_event: None,
};
assert_eq!(outcome.accepted(), 2);
assert!(outcome.partial_warning().is_none());
assert_eq!(outcome.failure_summary(), "");
}
#[test]
fn push_outcome_without_servers_or_pushes_has_no_warning() {
assert!(PushOutcome::default().partial_warning().is_none());
}
#[test]
fn flatten_whitespace_collapses_and_clips_long_errors() {
assert_eq!(flatten_whitespace("a\n\n b \t c"), "a b c");
let long = "word ".repeat(100);
let flat = flatten_whitespace(&long);
assert!(flat.ends_with('…'));
assert_eq!(flat.chars().count(), 201);
}
}
+1 -107
View File
@@ -13,7 +13,6 @@ use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::{LocalReposStore, RepoListStore};
/// Delay between a refresh request and the actual re-computation.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// How often the statuses are recomputed against the local refs.
@@ -29,7 +28,6 @@ const STATUS_POLL: Duration = Duration::from_secs(15);
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
const PUSH_POLL: Duration = Duration::from_secs(60);
/// Maximum checkouts considered per repository when computing statuses.
const MAX_STATUS_CHECKOUTS: usize = 8;
struct GlobalCheckoutsStore(Entity<CheckoutsStore>);
@@ -41,7 +39,6 @@ impl Global for GlobalCheckoutsStore {}
/// 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. A detached checkout is idle and yields no status.
pub branch: String,
@@ -95,9 +92,7 @@ pub struct CheckoutsStore {
///
/// A recompute defaults the base the same way.
requested_head: HashMap<RepoAddr, Option<String>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
/// A local status pass timer is pending.
local_pending: bool,
/// When the last full pass (with a remote refresh) completed.
///
@@ -108,7 +103,6 @@ pub struct CheckoutsStore {
}
impl CheckoutsStore {
/// Retrieve the global checkouts store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalCheckoutsStore>().0.clone()
}
@@ -117,7 +111,6 @@ impl CheckoutsStore {
cx.set_global(GlobalCheckoutsStore(entity));
}
/// Create the store.
pub fn new(cx: &mut Context<Self>) -> Self {
let mut subscriptions = Vec::new();
@@ -176,7 +169,6 @@ impl CheckoutsStore {
}
}
/// Remember a successful local-checkout use.
pub fn record(&mut self, path: PathBuf, addr: RepoAddr, cx: &mut Context<Self>) {
if cfg!(target_arch = "wasm32") {
return;
@@ -278,7 +270,6 @@ impl CheckoutsStore {
self.push_statuses.get(addr).cloned().unwrap_or_default()
}
/// The number of unpushed commits for a repository.
pub fn unpushed(&self, addr: &RepoAddr) -> usize {
self.push_statuses
.get(addr)
@@ -311,7 +302,6 @@ impl CheckoutsStore {
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();
// Inputs snapshot, all cheap shared reads.
let records = {
let settings = SettingsStore::global(cx);
settings.read(cx).settings().checkouts.records.clone()
@@ -433,7 +423,7 @@ impl CheckoutsStore {
/// Schedule the fast local status pass, unless one is already pending.
///
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
/// the local refs no network so a new commit in a checkout surfaces in
/// the local refs, with no network, so a new commit in a checkout surfaces in
/// a second or two instead of at the next remote reconciliation.
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
if self.local_pending {
@@ -546,7 +536,6 @@ impl CheckoutsStore {
}
}
/// Identity of a repository URL.
fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
let parsed = Url::parse(url).ok()?;
let host = parsed.host_str()?.to_ascii_lowercase();
@@ -557,7 +546,6 @@ fn url_identity(url: &str) -> Option<(String, Option<u16>, String)> {
Some((host, parsed.port(), path))
}
/// Whether two repository URLs point at the same repository.
fn same_repo_url(a: &str, b: &str) -> bool {
match (url_identity(a), url_identity(b)) {
(Some(a), Some(b)) => a == b,
@@ -565,7 +553,6 @@ fn same_repo_url(a: &str, b: &str) -> bool {
}
}
/// Resolve the associations between local checkouts and announced repositories.
fn resolve_associations<'a>(
remembered: &[Remembered],
scanned: &[(PathBuf, Option<String>, Option<String>)],
@@ -608,7 +595,6 @@ fn resolve_associations<'a>(
out
}
/// The ready-to-contribute status of one checkout.
fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<CheckoutStatus> {
let branches = signed_git::worktree_branches(path).ok()?;
@@ -732,7 +718,6 @@ fn compute_statuses(
(statuses, push_statuses)
}
/// Whether the pull request `pr` already proposes the same change as `checkout`.
pub fn pr_proposes_checkout(
pr: &Event,
open: bool,
@@ -860,40 +845,6 @@ mod tests {
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_matches_scanned_repos_by_origin_and_euc() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
let announcements = vec![
announcement("repo", &["grasp://host/npub1x/repo"], None),
announcement("family", &[], Some(euc)),
];
let repo = addr("repo");
let family = addr("family");
let resolved = resolve_associations(
&[],
&[
// Origin matches modulo scheme and the `.git` suffix.
scanned("/clone", Some("https://host/npub1x/repo.git"), None),
// Root commit matches the family EUC.
scanned("/family-checkout", None, Some(euc)),
// Neither matches anything.
scanned("/unrelated", Some("https://elsewhere/x.git"), None),
],
&announcements,
);
assert_eq!(
resolved.get(&repo).expect("repo matches"),
&vec![PathBuf::from("/clone")]
);
assert_eq!(
resolved.get(&family).expect("family matches"),
&vec![PathBuf::from("/family-checkout")]
);
assert_eq!(resolved.len(), 2);
}
#[test]
fn resolve_deduplicates_paths_remembering_first() {
let euc = "aa231c4c6a5777dc89b42207b499891a344add5c";
@@ -1048,61 +999,4 @@ mod tests {
remote_run(&["commit", "-m", "remote work"]);
assert_eq!(checkout_push_status(&checkout, true), None);
}
fn pr_event(author: &str, tags: &[&[&str]]) -> Event {
let keys = Keys::new(SecretKey::from_hex(author).expect("secret"));
let tags: Vec<Tag> = tags
.iter()
.map(|t| Tag::parse(t.to_vec()).expect("valid tag"))
.collect();
EventBuilder::new(Kind::GitPullRequest, "")
.tags(tags)
.finalize(&keys)
.expect("signed event")
}
fn status(branch: &str, head: &str) -> CheckoutStatus {
CheckoutStatus {
path: PathBuf::from("/checkout"),
branch: branch.to_owned(),
head: head.to_owned(),
base: "main".to_owned(),
ahead: 1,
}
}
#[test]
fn pr_proposes_checkout_matches_branch_or_tip() {
let author = "0000000000000000000000000000000000000000000000000000000000000002";
let tip = "aa231c4c6a5777dc89b42207b499891a344add5c";
// A matching `branch-name` covers the proposal.
let pr = pr_event(author, &[&["branch-name", "feature"], &["c", tip]]);
let status = status("feature", "bb231c4c6a5777dc89b42207b499891a344add5c");
assert!(pr_proposes_checkout(&pr, true, pr.pubkey, &status));
// 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.
// 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(
"0000000000000000000000000000000000000000000000000000000000000003",
&[&["branch-name", "feature"]],
);
assert!(!pr_proposes_checkout(&pr, true, other.pubkey, &status));
let other_branch = pr_event(author, &[&["branch-name", "other"]]);
assert!(!pr_proposes_checkout(
&other_branch,
true,
other_branch.pubkey,
&status
));
}
}
-3
View File
@@ -12,14 +12,12 @@ impl Global for GlobalGitStore {}
pub struct GitStore(GitCache);
impl GitStore {
/// Register the clone cache rooted at `root` as an app-wide global.
pub fn set_global(root: impl Into<PathBuf>, cx: &mut App) -> Self {
let store = Self::new(root);
cx.set_global(GlobalGitStore(store.0.clone()));
store
}
/// The app-wide clone cache.
pub fn global(cx: &App) -> Self {
Self(cx.global::<GlobalGitStore>().0.clone())
}
@@ -28,7 +26,6 @@ impl GitStore {
Self(GitCache::new(root.into()))
}
/// Underlying clone cache.
pub fn cache(&self) -> &GitCache {
&self.0
}
+1 -10
View File
@@ -11,7 +11,6 @@ use crate::backend::Backend;
#[derive(Default)]
pub struct Inbox {
state: InboxReadState,
/// Set once the stored state has been read for the current user.
loaded: bool,
}
@@ -21,12 +20,10 @@ impl Inbox {
&self.state
}
/// Whether the stored state has been read for the current user.
pub fn is_loaded(&self) -> bool {
self.loaded
}
/// Mark the events of one notification group read, then bound the id sets.
pub fn mark_read(
&mut self,
group: &[Event],
@@ -42,7 +39,7 @@ impl Inbox {
cx.notify();
}
/// Archive one notification group. Archived events are always read too.
/// Archived events are always read too.
pub fn mark_archived(
&mut self,
group: &[Event],
@@ -62,14 +59,12 @@ impl Inbox {
cx.notify();
}
/// Mark every known notification read.
pub fn mark_all_read(&mut self, all: &[Event], me: PublicKey, cx: &mut Context<Self>) {
self.state.mark_all_read(all, me, Timestamp::now());
self.persist(cx);
cx.notify();
}
/// Load the stored state for current user.
pub(crate) fn activate(&mut self, me: PublicKey, client: Client, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
@@ -101,7 +96,6 @@ impl Inbox {
.detach();
}
/// Clear the state of the signed-out user.
pub(crate) fn reset(&mut self, cx: &mut Context<Self>) {
self.state = InboxReadState::default();
self.loaded = false;
@@ -128,7 +122,6 @@ impl Inbox {
}
}
/// Derive the inbox home screen's threads for `me` from the local database.
pub async fn query_inbox(
client: &Client,
me: PublicKey,
@@ -166,7 +159,6 @@ fn inbox_state_d_tag(me: PublicKey) -> String {
format!("signed-inbox-state:{}", me.to_hex())
}
/// Newest stored state for `me`.
async fn load_state(client: &Client, me: PublicKey) -> Result<Option<InboxReadState>, Error> {
let filter = Filter::new()
.kind(Kind::ApplicationSpecificData)
@@ -223,7 +215,6 @@ async fn fetch_notifications(
let mut seen: HashSet<EventId> = by_id.keys().copied().collect();
loop {
// Keep only ids not walked yet, and remember them.
pending.retain(|id| seen.insert(*id));
if pending.is_empty() {
-1
View File
@@ -21,7 +21,6 @@ pub use repo::RepoStore;
pub use repos::{LocalReposStore, RepoActivityCounts, RepoListStore};
use signed_nostr::new_backend;
/// Initialize the backend and stores, and install them as globals.
#[cfg(not(target_arch = "wasm32"))]
pub fn init(
db_path: impl AsRef<Path>,
-6
View File
@@ -53,7 +53,6 @@ impl Profile {
SharedString::from(shorten_pubkey(self.public_key, 4))
}
/// Avatar URL, if set.
pub fn picture(&self) -> Option<SharedString> {
self.metadata
.picture
@@ -83,7 +82,6 @@ struct GlobalProfileStore(Entity<ProfileStore>);
impl Global for GlobalProfileStore {}
impl ProfileStore {
/// Retrieve the global profile store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalProfileStore>().0.clone()
}
@@ -157,7 +155,6 @@ impl ProfileStore {
Profile::new(public_key, Metadata::default())
}
/// Load recently seen profiles from the local database.
fn load(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
@@ -194,7 +191,6 @@ impl ProfileStore {
task.detach();
}
/// Re-read the latest metadata of an author from the local database.
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
let client = backend.read(cx).client();
@@ -300,7 +296,6 @@ impl ProfileStore {
let mut batch: HashSet<PublicKey> = HashSet::new();
loop {
// Wait for the first request of a batch.
match receiver.recv_async().await {
Ok(public_key) => {
batch.insert(public_key);
@@ -308,7 +303,6 @@ impl ProfileStore {
Err(_) => return Ok(()),
}
// Collect everything that arrives within the debounce window.
// The channel has no async timeout, race the receive against a timer.
let deadline = Instant::now() + BATCH_TIMEOUT;
loop {
-7
View File
@@ -1,15 +1,11 @@
/// Refresh coalescing shared by the event stores.
#[derive(Debug, Default)]
pub struct RefreshGate {
/// A run is in flight.
running: bool,
/// A request arrived while a run was in flight.
dirty: bool,
/// The debounce timer is pending.
debouncing: bool,
}
/// What a refresh request decided.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RefreshRequest {
/// No run or timer covers the request, start the debounce timer.
@@ -19,12 +15,10 @@ pub enum RefreshRequest {
}
impl RefreshGate {
/// Whether a run is in flight.
pub fn running(&self) -> bool {
self.running
}
/// Whether the debounce timer is pending.
pub fn debouncing(&self) -> bool {
self.debouncing
}
@@ -45,7 +39,6 @@ impl RefreshGate {
}
}
/// The debounce timer fired and the run starts now.
pub fn begin(&mut self) {
self.debouncing = false;
self.running = true;
+238 -131
View File
@@ -1,7 +1,6 @@
use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::time::Duration;
use anyhow::{Error, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -22,9 +21,6 @@ use crate::git_store::GitStore;
use crate::refresh::{RefreshGate, RefreshRequest};
use crate::repos::RepoListStore;
/// Delay between a refresh request and the actual re-query.
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
/// Maximum size of one patch event.
///
/// NIP-34 suggests patches when each event is under 60kb.
@@ -35,8 +31,18 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
/// Always derived from the local database.
pub struct RepoStore {
addr: RepoAddr,
/// NIP-34 address. `None` while the repository is local-only.
addr: Option<RepoAddr>,
/// Latest announcement. Seeded from the open-time hint, replaced by the
/// database's latest on the first pass. `None` while local-only.
pub announcement: Option<Announcement>,
/// Local working copy. The scan path for a local repository, kept when it is
/// later announced so the panel keeps its worktree.
pub path: Option<PathBuf>,
/// The first local pass has been applied.
///
/// Views distinguish "no data yet" from a genuinely empty repository with it.
pub loaded: bool,
/// Branch pointed to by `HEAD` in the latest state announcement.
pub head: Option<String>,
pub issues: Vec<Event>,
@@ -54,7 +60,6 @@ pub struct RepoStore {
///
/// Views key their derived-data caches to it instead of recomputing on every render.
version: u64,
/// Error of the last action initiated from this store, if any.
pub last_error: Option<String>,
/// Non-fatal warning of the last action, if any.
///
@@ -80,54 +85,21 @@ pub struct RepoStore {
///
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
root_fetches: HashSet<EventId>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscription: Subscription,
/// Backend subscription of an announced repository. `None` while local-only.
_subscription: Option<Subscription>,
}
impl RepoStore {
pub fn new(addr: RepoAddr, announced_relays: Vec<RelayUrl>, cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
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.
// 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.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == this.addr.public_key;
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.
let deletion =
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
});
pub fn new(addr: RepoAddr, hint: Option<Announcement>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
let subscription = Self::subscribe_backend(cx);
let announced_relays = hint
.as_ref()
.map(|announcement| announcement.relays.clone())
.unwrap_or_default();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
@@ -141,8 +113,10 @@ impl RepoStore {
});
Self {
addr,
announcement: None,
addr: Some(addr),
announcement: hint,
path: None,
loaded: false,
head: None,
issues: Vec::new(),
patches: Vec::new(),
@@ -160,13 +134,101 @@ impl RepoStore {
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: subscription,
_subscription: Some(subscription),
}
}
/// Returns the repository's address.
pub fn addr(&self) -> &RepoAddr {
&self.addr
/// Local repository discovered by the scan, not announced to NIP-34 yet.
pub fn new_local(path: PathBuf) -> Self {
Self {
addr: None,
announcement: None,
path: Some(path),
loaded: true,
head: None,
issues: Vec::new(),
patches: Vec::new(),
pull_requests: Vec::new(),
comments: Vec::new(),
status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
version: 0,
last_error: None,
last_warning: None,
last_push_warning: None,
pushing: false,
cloning: false,
repo_relays: HashSet::new(),
root_fetches: HashSet::new(),
refresh: RefreshGate::default(),
_subscription: None,
}
}
/// Switch a local repository to its NIP-34 mode, keeping its path.
pub fn announce(&mut self, announcement: Announcement, cx: &mut Context<Self>) {
self.addr = Some(announcement.addr());
self.announcement = Some(announcement.clone());
self.loaded = false;
if self._subscription.is_none() {
self._subscription = Some(Self::subscribe_backend(cx));
}
self.subscribe_remote(cx);
self.connect_announced_relays(&announcement.relays, cx);
self.refresh(cx);
}
fn subscribe_backend(cx: &mut Context<Self>) -> Subscription {
let backend = Backend::global(cx);
cx.subscribe(&backend, |this, _backend, event, cx| {
let Some(addr) = this.addr.as_ref() else {
return;
};
let relevant = match event {
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
// Deletions may target any event of this repository.
let deletion =
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
let coordinate = update.coordinate.as_ref() == Some(addr);
let author = update.author == addr.public_key;
let kind = update.kind == Kind::GitRepoAnnouncement;
// 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.
// Any status event may reference a root of this repository.
let status = RepoStatus::from_kind(update.kind).is_some();
deletion || coordinate || (author && kind) || comment || status
}),
BackendEvent::Published(event) => {
let kind = event.kind == Kind::GitRepoAnnouncement;
let author = event.pubkey == addr.public_key;
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
// 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;
coordinate || (kind && author) || deletion
}
_ => false,
};
if relevant {
this.refresh(cx);
}
})
}
pub fn addr(&self) -> Option<&RepoAddr> {
self.addr.as_ref()
}
/// Returns the repository's name, or `Unknown` when not known.
@@ -198,6 +260,10 @@ impl RepoStore {
/// Fetch this repository's events from the relays in its NIP-34 `relays` tag.
fn connect_announced_relays(&mut self, relays: &[RelayUrl], cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let new: Vec<RelayUrl> = relays
.iter()
.filter(|url| !self.repo_relays.contains(*url))
@@ -210,17 +276,18 @@ impl RepoStore {
self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
});
}
/// Fetch this repository's events from the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
let backend = Backend::global(cx);
let addr = self.addr.clone();
backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(Self::repo_filters(&addr), cx);
@@ -228,25 +295,30 @@ impl RepoStore {
}
/// Re-query the local database and update all fields.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.addr.is_none() {
return;
}
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
self.run_refresh(cx);
}
fn run_refresh(&mut self, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
return;
};
self.refresh.begin();
let backend = Backend::global(cx);
let client = backend.read(cx).client();
let addr = self.addr.clone();
let work = cx.background_spawn(async move {
let (announcements, states, activity, deletion_events) = async {
@@ -397,7 +469,37 @@ impl RepoStore {
};
let again = this.update(cx, |this, cx| {
this.announcement = announcement;
// Compare before moving the freshly queried data in, so a pass
// that found nothing new does not notify observers. The store
// is polled in bursts while a sync is in flight; notifying on
// every identical pass would re-render the repository panel
// several times for no visible change.
//
// The first pass is the exception: it must notify even when it
// found nothing, so views can leave their loading state and show
// the empty result.
//
// Keep the open-time hint until that first pass has confirmed what
// the database holds; afterwards the database is the truth,
// including a deletion.
let keep_hint = announcement.is_none() && !this.loaded;
let first_pass = !this.loaded;
let head_changed = state
.as_ref()
.is_some_and(|(_, head)| this.head.as_deref() != head.as_deref());
let changed = first_pass
|| (!keep_hint && this.announcement != announcement)
|| head_changed
|| this.issues != issues
|| this.patches != patches
|| this.pull_requests != pull_requests
|| this.comments != comments
|| this.status_by_root != status_by_root;
if !keep_hint {
this.announcement = announcement;
}
// The announcement may list relays for this repository's activity.
// Connect to any we have not fetched from yet.
@@ -419,6 +521,7 @@ impl RepoStore {
this.status_by_root = status_by_root;
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.loaded = true;
this.version = this.version.wrapping_add(1);
// Comments and statuses without an `a` tag.
@@ -454,7 +557,9 @@ impl RepoStore {
});
}
cx.notify();
if changed {
cx.notify();
}
this.refresh.finish()
})?;
@@ -475,8 +580,6 @@ impl RepoStore {
status_of(&self.status_by_root, root)
}
/// Refresh generation, incremented on every applied refresh.
/// Views use it to key their derived-data caches.
pub fn version(&self) -> u64 {
self.version
}
@@ -502,13 +605,19 @@ impl RepoStore {
/// 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
self.addr
.as_ref()
.is_some_and(|addr| &addr.public_key == user)
}
/// Open an issue on this repository.
pub fn open_issue(&mut self, subject: Option<String>, content: String, cx: &mut Context<Self>) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let builder = GitIssue {
repository: self.addr.clone(),
repository: addr,
content,
subject,
labels: Vec::new(),
@@ -518,7 +627,6 @@ impl RepoStore {
self.publish(builder, cx);
}
/// 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()
@@ -540,6 +648,11 @@ impl RepoStore {
content: String,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let relay_hint = self
.announcement
.as_ref()
@@ -547,12 +660,11 @@ impl RepoStore {
.cloned();
self.publish(
comment_builder(root, parent, relay_hint.as_ref(), &self.addr, content),
comment_builder(root, parent, relay_hint.as_ref(), &addr, content),
cx,
);
}
/// Open a pull request on this repository.
#[allow(clippy::too_many_arguments)]
pub fn open_pull_request(
&mut self,
@@ -568,6 +680,11 @@ impl RepoStore {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let series: Vec<String> = signed_git::split_patch_series(&patch)
.into_iter()
.map(str::to_owned)
@@ -611,8 +728,7 @@ impl RepoStore {
// The author's npub names their GRASP-06 namespace, `/prs/...`.
let author_npub = user.to_bech32().unwrap();
let addr = self.addr.clone();
let owner = self.addr.public_key;
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let repo_id = addr.identifier.clone();
let base_npub = owner.to_bech32().unwrap();
@@ -728,7 +844,7 @@ impl RepoStore {
let clone = pr_clone_urls(prs_urls, base_clone);
let builder = GitPullRequest {
repository: this.addr.clone(),
repository: addr.clone(),
content: description,
subject,
labels: Vec::new(),
@@ -947,8 +1063,11 @@ impl RepoStore {
.map(|p| p.id)
});
let addr = self.addr.clone();
let owner = self.addr.public_key;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let owner = addr.public_key;
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
let root = root.clone();
let clone: Vec<Url> = self
@@ -976,9 +1095,9 @@ impl RepoStore {
});
}
let builder = this.update(cx, |this, _cx| {
let builder = this.update(cx, |_this, _cx| {
let builder = GitPullRequestUpdate {
repository: this.addr.clone(),
repository: addr.clone(),
pull_request_event: root.id,
pull_request_author: root.pubkey,
current_commit,
@@ -1035,6 +1154,11 @@ impl RepoStore {
pub fn set_status(&mut self, root: &Event, status: RepoStatus, cx: &mut Context<Self>) {
self.last_error = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let maintainers = self
.announcement
.as_ref()
@@ -1060,19 +1184,23 @@ impl RepoStore {
let builder = EventBuilder::new(status.kind(), "").tags([
root_ref,
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
]);
self.publish(builder, cx);
}
/// Merge a pull request.
pub fn merge_pull_request(&mut self, root: &Event, cx: &mut Context<Self>) {
self.last_error = None;
self.last_warning = None;
let Some(addr) = self.addr.clone() else {
self.not_announced(cx);
return;
};
let is_author = Backend::global(cx)
.read(cx)
.current_user()
@@ -1083,7 +1211,6 @@ impl RepoStore {
}
let cache = GitStore::global(cx).cache().clone();
let addr = self.addr.clone();
let clone_urls: Vec<Url> = self
.announcement
@@ -1152,17 +1279,17 @@ impl RepoStore {
/// The latest announcement of this repository,
/// for operations that need its clone URLs and relays.
fn action_announcement(&self, cx: &App) -> Option<Announcement> {
let addr = self.addr.as_ref()?;
self.announcement.clone().or_else(|| {
RepoListStore::global(cx)
.read(cx)
.announcements
.iter()
.find(|announcement| announcement.addr() == self.addr)
.find(|announcement| announcement.addr() == *addr)
.cloned()
})
}
/// Re-push the repository's refs to its announced grasp servers, republish.
pub fn push_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
if self.pushing {
return Task::ready(Err(anyhow::anyhow!(
@@ -1207,7 +1334,6 @@ impl RepoStore {
})
}
/// Push the unpushed commits of the local checkout at `path`.
pub fn push_checkout(
&mut self,
path: PathBuf,
@@ -1219,6 +1345,10 @@ impl RepoStore {
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
@@ -1226,7 +1356,6 @@ impl RepoStore {
// The state event's `HEAD` stays the announced default branch.
// The checkout may be on a side branch.
let head = self.head.clone();
let addr = self.addr.clone();
self.pushing = true;
self.last_error = None;
@@ -1274,7 +1403,9 @@ impl RepoStore {
/// Only the repository owner may delete it. The lists update when the
/// deletion events arrive.
pub fn delete_repository(&mut self, cx: &mut Context<Self>) -> Task<Result<(), Error>> {
let addr = self.addr.clone();
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
self.last_error = None;
let backend = Backend::global(cx);
@@ -1304,12 +1435,16 @@ impl RepoStore {
"A clone of this repository is already in progress"
)));
}
let Some(addr) = self.addr.clone() else {
return self.action_error("This repository is not published to Nostr yet", cx);
};
let Some(announcement) = self.action_announcement(cx) else {
return self.action_error("Repository announcement is not loaded yet", cx);
};
let clone_urls = announcement.clone.clone();
let addr = self.addr.clone();
self.cloning = true;
self.last_error = None;
@@ -1328,7 +1463,6 @@ impl RepoStore {
match &result {
Ok(()) => {
// Remember the clone as a checkout of this repository.
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |store, cx| {
store.record(destination.clone(), addr.clone(), cx);
@@ -1346,7 +1480,11 @@ impl RepoStore {
})
}
/// Fail an operation whose announcement is not loaded yet.
fn not_announced(&mut self, cx: &mut Context<Self>) {
self.last_error = Some("This repository is not published to Nostr yet".into());
cx.notify();
}
fn action_error(
&mut self,
message: impl Into<String>,
@@ -1369,11 +1507,15 @@ impl RepoStore {
euc: Option<&str>,
cx: &mut Context<Self>,
) {
let Some(addr) = self.addr.clone() else {
return;
};
let mut tags = vec![
Tag::parse(["e", &root.id.to_hex(), "", "root"]).expect("valid root tag"),
Tag::public_key(self.addr.public_key),
Tag::public_key(addr.public_key),
Tag::public_key(root.pubkey),
Tag::coordinate(self.addr.clone(), None),
Tag::coordinate(addr, None),
];
if let Some(euc) = euc
@@ -1466,7 +1608,6 @@ where
events.into_iter().max_by_key(|e| e.created_at)
}
/// Status of `root` from the precomputed map.
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
@@ -1474,7 +1615,6 @@ fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> Rep
.unwrap_or(RepoStatus::Open)
}
/// Resolve every root event's status in one pass.
fn resolve_statuses(
issues: &[Event],
patches: &[Event],
@@ -1641,13 +1781,6 @@ mod tests {
);
}
#[test]
fn no_commit_without_header() {
assert_eq!(patch_current_commit(""), None);
assert_eq!(patch_current_commit("Subject: [PATCH] x\n\n---\n"), None);
assert_eq!(patch_current_commit("From short\n"), None);
}
#[test]
fn comment_builder_follows_nip22() {
let keys = Keys::generate();
@@ -1683,30 +1816,4 @@ mod tests {
// Signed's own `references_root` must keep matching the comment.
assert!(signed_core::references_root(&event, &root.id));
}
#[test]
fn comment_builder_replies_nest_under_the_parent() {
let keys = Keys::generate();
let root = EventBuilder::new(Kind::GitIssue, "issue body")
.finalize(&keys)
.expect("signed event");
let parent = EventBuilder::new(Kind::Comment, "first comment")
.finalize(&keys)
.expect("signed event");
let addr = Coordinate::new(Kind::GitRepoAnnouncement, root.pubkey).identifier("my-repo");
let event = comment_builder(&root, Some(&parent), None, &addr, "reply".into())
.finalize(&keys)
.expect("signed event");
// 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());
assert_eq!(parent_ref.as_slice()[1], parent.id.to_hex());
// The reply still threads under the root for Signed's own display.
assert!(signed_core::references_root(&event, &root.id));
}
}
+9 -44
View File
@@ -18,18 +18,14 @@ impl Global for GlobalLocalReposStore {}
/// Store of the git repositories discovered under a set of scan paths.
pub struct LocalReposStore {
/// The directories being scanned.
pub roots: Arc<Vec<PathBuf>>,
/// Git repositories discovered under [`Self::roots`], sorted by path.
pub repos: Arc<Vec<PathBuf>>,
/// A scan is currently running.
pub scanning: bool,
/// A scan was requested while one was already running.
scan_dirty: bool,
}
impl LocalReposStore {
/// Retrieve the global local-repositories store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalLocalReposStore>().0.clone()
}
@@ -38,7 +34,6 @@ impl LocalReposStore {
cx.set_global(GlobalLocalReposStore(entity));
}
/// Create a store scanning `roots` right away.
pub fn new(roots: Vec<PathBuf>, cx: &mut Context<Self>) -> Self {
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
@@ -67,7 +62,6 @@ impl LocalReposStore {
cx.notify();
}
/// Re-run the scan.
pub fn rescan(&mut self, cx: &mut Context<Self>) {
if self.scanning {
self.scan_dirty = true;
@@ -117,11 +111,6 @@ impl LocalReposStore {
}
}
/// 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.
const ACTIVITY_WINDOW: Duration = Duration::from_secs(90 * 86_400);
@@ -154,19 +143,16 @@ 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.
/// Covers announcements, state updates, patches, PRs, issues and statuses.
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
/// Issues, pull requests and commits per repository.
///
/// Used for the Popular ranking of the explore list.
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
/// Refresh coalescing, see [`RefreshGate`].
refresh: RefreshGate,
_subscription: Subscription,
}
impl RepoListStore {
/// Retrieve the global repository list store.
pub fn global(cx: &App) -> Entity<Self> {
cx.global::<GlobalRepoListStore>().0.clone()
}
@@ -175,9 +161,9 @@ impl RepoListStore {
cx.set_global(GlobalRepoListStore(entity));
}
/// Create the store listing all announcements.
pub fn new(cx: &mut Context<Self>) -> Self {
let backend = Backend::global(cx);
let weak = cx.entity().downgrade();
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
let relevant = match event {
@@ -217,17 +203,12 @@ impl RepoListStore {
}
});
let weak = cx.entity().downgrade();
cx.defer(move |cx| {
let result = weak.update(cx, |this, cx| {
weak.update(cx, |this, cx| {
this.subscribe_remote(cx);
// Query the local database right away.
// The list never waits for the relay syncs started above to finish.
this.refresh_initial(cx);
});
if let Err(error) = result {
log::warn!("repo list store dropped before bootstrap could run: {error}");
}
this.refresh(cx);
})
.ok();
});
Self {
@@ -248,7 +229,6 @@ impl RepoListStore {
.collect()
}
/// Negentropy-sync announcements with the bootstrap relays.
fn subscribe_remote(&mut self, cx: &mut Context<Self>) {
let backend = Backend::global(cx);
@@ -259,33 +239,18 @@ impl RepoListStore {
});
}
/// One-shot initial load.
///
/// Query the local database immediately, no debounce.
/// Stored announcements appear as soon as the app opens.
fn refresh_initial(&mut self, cx: &mut Context<Self>) {
debug_assert!(!self.refresh.debouncing());
if self.refresh.running() {
self.refresh.request();
return;
}
self.run_refresh(cx);
}
/// Re-query the local database.
///
/// Runs immediately. The backend pump already batches the relay events that
/// trigger a refresh, so no per-store debounce is needed.
pub fn refresh(&mut self, cx: &mut Context<Self>) {
if self.refresh.request() != RefreshRequest::Schedule {
return;
}
cx.spawn(async move |this, cx| {
cx.background_executor().timer(REFRESH_DEBOUNCE).await;
this.update(cx, |this, cx| this.run_refresh(cx))
})
.detach();
self.run_refresh(cx);
}
/// One query and apply cycle, the debounced entry point.
fn run_refresh(&mut self, cx: &mut Context<Self>) {
self.refresh.begin();