refactor 2
This commit is contained in:
+1
-1
@@ -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" }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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<Tag> = 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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>) {
|
||||
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<Tag> = 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());
|
||||
}
|
||||
}
|
||||
@@ -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<Event> = 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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<String>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_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<Self>) {
|
||||
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>) {
|
||||
self.refreshing = true;
|
||||
|
||||
let client = Backend::global(cx).read(cx).client();
|
||||
@@ -293,24 +322,3 @@ fn latest(events: Events) -> Option<Event> {
|
||||
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<String>) {
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -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<Announcement>,
|
||||
/// Shared so views can clone the list per frame without a deep copy.
|
||||
pub announcements: Arc<Vec<Announcement>>,
|
||||
author: Option<PublicKey>,
|
||||
refreshing: bool,
|
||||
refresh_dirty: bool,
|
||||
/// A refresh is waiting out [`REFRESH_DEBOUNCE`].
|
||||
debouncing: bool,
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
_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<Self>) {
|
||||
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>) {
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user