diff --git a/Cargo.toml b/Cargo.toml index 0b8f27f..2281829 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,7 +20,7 @@ reqwest_client = { git = "https://github.com/zed-industries/zed" } gpui-component = { git = "https://github.com/longbridge/gpui-component" } -nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44"] } +nostr = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215", features = ["nip59", "nip49", "nip44", "os-rng"] } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } nostr-memory = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } nostr-blossom = { git = "https://github.com/rust-nostr/nostr", rev = "d0a1d67d3c9e5cf9710807a6a414c155a5f47215" } diff --git a/crates/signed_core/src/lib.rs b/crates/signed_core/src/lib.rs index 19adfec..e80f267 100644 --- a/crates/signed_core/src/lib.rs +++ b/crates/signed_core/src/lib.rs @@ -2,9 +2,11 @@ pub mod addr; pub mod clone_url; pub mod filters; pub mod model; +pub mod state; pub mod status; pub use addr::{RepoAddr, repo_addr}; pub use clone_url::{CloneTarget, parse_clone_url}; pub use model::Announcement; +pub use state::parse_state; pub use status::{RepoStatus, references_root, resolve_status}; diff --git a/crates/signed_core/src/model.rs b/crates/signed_core/src/model.rs index b1a6ceb..796282c 100644 --- a/crates/signed_core/src/model.rs +++ b/crates/signed_core/src/model.rs @@ -81,3 +81,109 @@ impl Announcement { crate::repo_addr(self.owner, self.id.clone()) } } + +#[cfg(test)] +mod tests { + use super::*; + + const MAINTAINER_HEX: &str = "68d81165918100b7da43fc28f7d1fc12554466e1115886b9e7bb326f65ec4272"; + + fn keys() -> Keys { + Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .expect("valid secret key"), + ) + } + + /// Build a signed kind `30617` event from raw tag values. + fn announcement_event(tags: &[&[&str]]) -> Event { + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + + EventBuilder::new(Kind::GitRepoAnnouncement, "") + .tags(tags) + .finalize(&keys()) + .expect("signed event") + } + + #[test] + fn parses_full_announcement() { + let event = announcement_event(&[ + &["d", "my-repo"], + &["name", "My Repo"], + &["description", "A test repository"], + &["web", "https://example.com/repo"], + &["clone", "https://example.com/repo.git"], + &["relays", "wss://relay.example.com"], + &["r", "aa231c4c6a5777dc89b42207b499891a344add5c", "euc"], + &["maintainers", MAINTAINER_HEX], + ]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + assert_eq!(announcement.owner, keys().public_key()); + assert_eq!(announcement.id, "my-repo"); + assert_eq!(announcement.name.as_deref(), Some("My Repo")); + assert_eq!( + announcement.description.as_deref(), + Some("A test repository") + ); + assert_eq!(announcement.web, vec!["https://example.com/repo"]); + assert_eq!(announcement.clone, vec!["https://example.com/repo.git"]); + assert_eq!(announcement.relays, vec!["wss://relay.example.com"]); + assert_eq!( + announcement.euc.as_deref(), + Some("aa231c4c6a5777dc89b42207b499891a344add5c") + ); + assert_eq!( + announcement.maintainers, + vec![PublicKey::from_hex(MAINTAINER_HEX).expect("valid pubkey")] + ); + } + + #[test] + fn requires_d_tag() { + let event = announcement_event(&[&["name", "No id"]]); + + assert!(Announcement::from_event(&event).is_none()); + } + + #[test] + fn ignores_other_kinds() { + let event = EventBuilder::new(Kind::GitIssue, "") + .finalize(&keys()) + .expect("signed event"); + + assert!(Announcement::from_event(&event).is_none()); + } + + #[test] + fn drops_malformed_values() { + let event = announcement_event(&[ + &["d", "my-repo"], + &["clone", "not a url"], + &["relays", "wss://good.example.com"], + &["maintainers", "not-a-pubkey"], + ]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + // An invalid URL keeps the whole clone tag from being parsed. + assert!(announcement.clone.is_empty()); + assert_eq!(announcement.relays, vec!["wss://good.example.com"]); + assert!(announcement.maintainers.is_empty()); + } + + #[test] + fn ignores_unknown_tags() { + let event = announcement_event(&[&["d", "my-repo"], &["t", "label"], &["subject", "n/a"]]); + + let announcement = Announcement::from_event(&event).expect("parses"); + + assert_eq!(announcement.id, "my-repo"); + assert!(announcement.name.is_none()); + assert!(announcement.web.is_empty()); + } +} diff --git a/crates/signed_core/src/state.rs b/crates/signed_core/src/state.rs new file mode 100644 index 0000000..dff0fcb --- /dev/null +++ b/crates/signed_core/src/state.rs @@ -0,0 +1,95 @@ +use nostr::prelude::*; + +/// Parse a kind `30618` repository state event into refs and HEAD. +/// +/// `refs` are `(refname, commit-id)` pairs; `head` is the branch pointed to +/// by the `HEAD` tag, if any. +pub fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { + let mut refs = Vec::new(); + let mut head = None; + + for tag in event.tags.iter() { + match Nip34Tag::parse(tag.as_slice()) { + Ok(Nip34Tag::Head(branch)) => head = Some(branch), + Ok(Nip34Tag::RefHead { branch, commit }) => { + refs.push((format!("refs/heads/{branch}"), commit.to_string())); + } + Ok(Nip34Tag::RefTag { name, commit }) => { + refs.push((format!("refs/tags/{name}"), commit.to_string())); + } + _ => {} + } + } + + (refs, head) +} + +#[cfg(test)] +mod tests { + use super::*; + + const COMMIT_A: &str = "aa231c4c6a5777dc89b42207b499891a344add5c"; + const COMMIT_B: &str = "59429cfc6cb35b0a1ddace73b5a5c5ed57b8f5ca"; + + fn keys() -> Keys { + Keys::new( + SecretKey::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .expect("valid secret key"), + ) + } + + /// Build a signed kind `30618` event from raw tag values. + fn state_event(tags: &[&[&str]]) -> Event { + let tags: Vec = tags + .iter() + .map(|t| Tag::parse(t.to_vec()).expect("valid tag")) + .collect(); + + EventBuilder::new(Kind::RepoState, "") + .tags(tags) + .finalize(&keys()) + .expect("signed event") + } + + #[test] + fn parses_heads_and_tags() { + let event = state_event(&[ + &["HEAD", "ref: refs/heads/main"], + &["refs/heads/main", COMMIT_A], + &["refs/heads/dev", COMMIT_B], + &["refs/tags/v1.0", COMMIT_A], + ]); + + let (refs, head) = parse_state(&event); + + assert_eq!(head.as_deref(), Some("main")); + assert_eq!( + refs, + vec![ + ("refs/heads/main".to_owned(), COMMIT_A.to_owned()), + ("refs/heads/dev".to_owned(), COMMIT_B.to_owned()), + ("refs/tags/v1.0".to_owned(), COMMIT_A.to_owned()), + ] + ); + } + + #[test] + fn head_without_prefix_is_ignored() { + let event = state_event(&[&["HEAD", "main"]]); + + let (refs, head) = parse_state(&event); + + assert!(refs.is_empty()); + assert!(head.is_none()); + } + + #[test] + fn ignores_non_state_tags() { + let event = state_event(&[&["d", "my-repo"], &["name", "ignored"]]); + + let (refs, head) = parse_state(&event); + + assert!(refs.is_empty()); + assert!(head.is_none()); + } +} diff --git a/crates/signed_core/src/status.rs b/crates/signed_core/src/status.rs index 1cd7bc9..41b87ed 100644 --- a/crates/signed_core/src/status.rs +++ b/crates/signed_core/src/status.rs @@ -54,3 +54,137 @@ where .and_then(|e| RepoStatus::from_kind(e.kind)) .unwrap_or(RepoStatus::Open) } + +#[cfg(test)] +mod tests { + use super::*; + + const ROOT_ID_HEX: &str = "1111111111111111111111111111111111111111111111111111111111111111"; + const OTHER_ID_HEX: &str = "2222222222222222222222222222222222222222222222222222222222222222"; + + fn keys_from_hex(hex: &str) -> Keys { + Keys::new(SecretKey::from_hex(hex).expect("valid secret key")) + } + + fn root_event_id() -> EventId { + EventId::from_hex(ROOT_ID_HEX).expect("valid event id") + } + + /// Build a signed status event with a controlled `created_at`. + fn status_event(author: &Keys, kind: Kind, root: EventId, created_at: u64) -> Event { + EventBuilder::new(kind, "") + .tags([Tag::event(root)]) + .custom_created_at(Timestamp::from(created_at)) + .finalize(author) + .expect("signed event") + } + + #[test] + fn references_root_matches_e_tag() { + let root = root_event_id(); + let event = EventBuilder::new(Kind::GitStatusOpen, "") + .tags([Tag::event(root)]) + .finalize(&keys_from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + )) + .expect("signed event"); + + assert!(references_root(&event, &root)); + assert!(!references_root( + &event, + &EventId::from_hex(OTHER_ID_HEX).expect("valid id") + )); + } + + #[test] + fn references_root_false_without_e_tags() { + let event = EventBuilder::new(Kind::GitStatusOpen, "") + .finalize(&keys_from_hex( + "0000000000000000000000000000000000000000000000000000000000000001", + )) + .expect("signed event"); + + assert!(!references_root(&event, &root_event_id())); + } + + #[test] + fn defaults_to_open_without_status_events() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + + let statuses: Vec = Vec::new(); + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Open + ); + } + + #[test] + fn latest_status_wins() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + let root = root_event_id(); + + let statuses = [ + status_event(&maintainer, Kind::GitStatusClosed, root, 100), + status_event(&owner, Kind::GitStatusOpen, root, 200), + ]; + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Open + ); + } + + #[test] + fn ignores_statuses_from_others() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let maintainer = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000002"); + let stranger = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000003"); + let root = root_event_id(); + + let statuses = [ + status_event(&stranger, Kind::GitStatusClosed, root, 300), + status_event(&maintainer, Kind::GitStatusDraft, root, 100), + ]; + + assert_eq!( + resolve_status( + statuses.iter(), + &owner.public_key(), + &[maintainer.public_key()] + ), + RepoStatus::Draft + ); + } + + #[test] + fn ignores_non_status_kinds() { + let owner = + keys_from_hex("0000000000000000000000000000000000000000000000000000000000000001"); + let root = root_event_id(); + + let statuses = [status_event(&owner, Kind::GitIssue, root, 100)]; + + assert_eq!( + resolve_status(statuses.iter(), &owner.public_key(), &[]), + RepoStatus::Open + ); + } +} diff --git a/crates/signed_state/src/repo.rs b/crates/signed_state/src/repo.rs index 22cd35a..74bd0d2 100644 --- a/crates/signed_state/src/repo.rs +++ b/crates/signed_state/src/repo.rs @@ -1,10 +1,16 @@ +use std::time::Duration; + use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; use nostr_sdk::prelude::*; -use signed_core::{Announcement, RepoAddr, RepoStatus, filters}; +use signed_core::{Announcement, RepoAddr, RepoStatus, filters, parse_state}; use crate::backend::{Backend, BackendEvent}; +/// Delay between a refresh request and the actual re-query, so bursts of +/// events (e.g. per-event `NostrUpdate`s) collapse into one query. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + /// Per-repository store: announcement, state, issues, patches, PRs and /// their resolved statuses. Always derived from the local database. pub struct RepoStore { @@ -22,6 +28,8 @@ pub struct RepoStore { pub last_error: Option, refreshing: bool, refresh_dirty: bool, + /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. + debouncing: bool, tasks: Vec>>, _subscription: Subscription, } @@ -66,6 +74,7 @@ impl RepoStore { last_error: None, refreshing: false, refresh_dirty: false, + debouncing: false, _subscription: subscription, tasks: Vec::new(), }; @@ -98,14 +107,34 @@ impl RepoStore { /// Re-query the local database and update all fields. /// - /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. The query and processing run on a + /// Debounced: a short delay collapses bursts of requests (e.g. per-event + /// `NostrUpdate`s), 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. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } + if self.debouncing { + return; + } + self.debouncing = true; + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + + this.update(cx, |this, cx| { + this.debouncing = false; + this.run_refresh(cx); + }) + }); + + self.tasks.push(task); + } + + /// One query + apply cycle (debounced entry point). + fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; let client = Backend::global(cx).read(cx).client(); @@ -293,24 +322,3 @@ fn latest(events: Events) -> Option { fn sort_newest_first(events: &mut [Event]) { events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); } - -/// Parse a kind `30618` state event into refs and HEAD. -fn parse_state(event: &Event) -> (Vec<(String, String)>, Option) { - let mut refs = Vec::new(); - let mut head = None; - - for tag in event.tags.iter() { - match Nip34Tag::parse(tag.as_slice()) { - Ok(Nip34Tag::Head(branch)) => head = Some(branch), - Ok(Nip34Tag::RefHead { branch, commit }) => { - refs.push((format!("refs/heads/{branch}"), commit.to_string())); - } - Ok(Nip34Tag::RefTag { name, commit }) => { - refs.push((format!("refs/tags/{name}"), commit.to_string())); - } - _ => {} - } - } - - (refs, head) -} diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index bf2a2b3..b09cdd4 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -1,4 +1,6 @@ use std::collections::HashMap; +use std::sync::Arc; +use std::time::Duration; use anyhow::Error; use gpui::{AppContext, Context, Subscription, Task}; @@ -7,12 +9,19 @@ use signed_core::{Announcement, RepoAddr, filters}; 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. +const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300); + /// Store listing repository announcements (global discovery or per-author). pub struct RepoListStore { - pub announcements: Vec, + /// Shared so views can clone the list per frame without a deep copy. + pub announcements: Arc>, author: Option, refreshing: bool, refresh_dirty: bool, + /// A refresh is waiting out [`REFRESH_DEBOUNCE`]. + debouncing: bool, tasks: Vec>>, _subscription: Subscription, } @@ -42,10 +51,11 @@ impl RepoListStore { }); let mut store = Self { - announcements: Vec::new(), + announcements: Arc::new(Vec::new()), author, refreshing: false, refresh_dirty: false, + debouncing: false, _subscription: subscription, tasks: Vec::new(), }; @@ -78,14 +88,34 @@ impl RepoListStore { /// Re-query the local database. Latest announcement per repository wins. /// - /// Debounced: concurrent requests are coalesced into a single re-query - /// after the running one finishes. The query and processing run on a - /// background thread; only the results are applied on the main thread. + /// 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. pub fn refresh(&mut self, cx: &mut Context) { if self.refreshing { self.refresh_dirty = true; return; } + if self.debouncing { + return; + } + self.debouncing = true; + + let task = cx.spawn(async move |this, cx| { + cx.background_executor().timer(REFRESH_DEBOUNCE).await; + + this.update(cx, |this, cx| { + this.debouncing = false; + this.run_refresh(cx); + }) + }); + + self.tasks.push(task); + } + + /// One query + apply cycle (debounced entry point). + fn run_refresh(&mut self, cx: &mut Context) { self.refreshing = true; let client = Backend::global(cx).read(cx).client(); @@ -136,7 +166,7 @@ impl RepoListStore { }; let again = this.update(cx, |this, cx| { - this.announcements = announcements; + this.announcements = Arc::new(announcements); cx.notify(); this.refreshing = false;