feat: out-of-box experience #2

Merged
reya merged 64 commits from feat/ui into master 2026-08-25 13:23:08 +00:00
7 changed files with 406 additions and 31 deletions
Showing only changes of commit 63f2de70e1 - Show all commits
+1 -1
View File
@@ -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
View File
@@ -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};
+106
View File
@@ -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());
}
}
+95
View File
@@ -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());
}
}
+134
View File
@@ -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
);
}
}
+32 -24
View File
@@ -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)
}
+36 -6
View File
@@ -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;