Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a70bb7228 | ||
|
|
80d4ca754b | ||
|
|
5a74bf1f0d |
@@ -81,8 +81,6 @@ Both `cx.spawn` and `cx.background_spawn` return a `Task<R>`, which is a future
|
|||||||
|
|
||||||
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
A task which doesn't do anything but provide a value can be created with `Task::ready(value)`.
|
||||||
|
|
||||||
Prefer keeping a task in a field over `.detach()` when the work belongs to a view or store. A detached task outlives the view that started it (for example, a repository panel the user has closed), while a task stored in a field is cancelled when that struct drops. A finished `Task` held in a container is not reaped by GPUI - it stays alive until its handle is dropped - so if the container can accumulate many runs, either drop the finished handles before pushing a new one, or hold a single `Option<Task<_>>` and replace it.
|
|
||||||
|
|
||||||
## Elements
|
## Elements
|
||||||
|
|
||||||
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
The `Render` trait is used to render some state into an element tree that is laid out using flexbox layout. An `Entity<T>` where `T` implements `Render` is sometimes called a "view".
|
||||||
|
|||||||
@@ -15,20 +15,14 @@
|
|||||||
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
- Migrate the GPUI foundation to the published `gpui-pre` crates and GPUI Kit 0.6, off the zed and gpui-component git pins
|
||||||
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
- Use the pixel avatar as the single fallback for a missing picture, sized and rounded to match the other avatars
|
||||||
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
- Redesign the dock tab bar, using muted grey active tab, added close buttons, double-click to zoom, and removed panel toolbar
|
||||||
- Connect to bootstrap relays on demand instead of at startup
|
|
||||||
- Fetch profile metadata in batches of 100 authors, applying every requested profile in a single database query
|
|
||||||
- Prefetch the 500 most recent profiles at startup instead of 200
|
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
- Render every avatar at one consistent size, where a surrounding border had shrunk pictures by two pixels and the pixel avatar ignored an explicit size
|
||||||
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
- Date repositories from their repository state event, so the explore list and open repository views show the latest push instead of the announcement date
|
||||||
- Fix background tasks outliving their view, so a closed repository panel or pull request view stops fetching and publishing on its own
|
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|
||||||
- Remove cover note support, the kind-1624 GitWorkshop and `ngit` extension outside the NIP-34
|
|
||||||
|
|
||||||
### Deprecated
|
### Deprecated
|
||||||
|
|
||||||
## v0.1.0-alpha - 2026/09/14
|
## v0.1.0-alpha - 2026/09/14
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ members = ["crates/*", "desktop"]
|
|||||||
default-members = ["desktop"]
|
default-members = ["desktop"]
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.2.0-alpha"
|
version = "0.1.0-alpha"
|
||||||
edition = "2024"
|
edition = "2024"
|
||||||
publish = false
|
publish = false
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
use nostr::prelude::*;
|
||||||
|
|
||||||
|
/// GitWorkshop and `ngit` cover-note extension, kind 1624.
|
||||||
|
///
|
||||||
|
/// A markdown note attached to an issue, patch or PR by its author or a maintainer,
|
||||||
|
/// not part of the NIP-34 draft, read support for interop.
|
||||||
|
pub const COVER_NOTE_KIND: Kind = Kind::Custom(1624);
|
||||||
@@ -2,7 +2,7 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
|
|
||||||
use crate::RepoAddr;
|
use crate::{COVER_NOTE_KIND, RepoAddr};
|
||||||
|
|
||||||
/// Kinds that make up the activity of a repository.
|
/// Kinds that make up the activity of a repository.
|
||||||
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
pub const ACTIVITY_KINDS: [Kind; 9] = [
|
||||||
@@ -18,18 +18,19 @@ pub const ACTIVITY_KINDS: [Kind; 9] = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that notify a user when they tag them via their `p` tag.
|
/// Kinds that notify a user when they tag them via their `p` tag.
|
||||||
pub const NOTIFICATION_KINDS: [Kind; 8] = [
|
pub const NOTIFICATION_KINDS: [Kind; 9] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPullRequest,
|
Kind::GitPullRequest,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
Kind::GitPullRequestUpdate,
|
Kind::GitPullRequestUpdate,
|
||||||
|
COVER_NOTE_KIND,
|
||||||
Kind::GitStatusOpen,
|
Kind::GitStatusOpen,
|
||||||
Kind::GitStatusApplied,
|
Kind::GitStatusApplied,
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
Kind::GitStatusDraft,
|
Kind::GitStatusDraft,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Git root kinds that make a comment count as git activity.
|
/// Git root kinds that make a comment or cover note count as git activity.
|
||||||
const GIT_ROOT_KINDS: [Kind; 4] = [
|
const GIT_ROOT_KINDS: [Kind; 4] = [
|
||||||
Kind::GitIssue,
|
Kind::GitIssue,
|
||||||
Kind::GitPatch,
|
Kind::GitPatch,
|
||||||
@@ -37,15 +38,6 @@ const GIT_ROOT_KINDS: [Kind; 4] = [
|
|||||||
Kind::GitRepoAnnouncement,
|
Kind::GitRepoAnnouncement,
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Kinds that carry repository data: announcements, states, activity and deletions.
|
|
||||||
pub fn is_repo_kind(kind: Kind) -> bool {
|
|
||||||
kind == Kind::GitRepoAnnouncement
|
|
||||||
|| kind == Kind::RepoState
|
|
||||||
|| kind == Kind::EventDeletion
|
|
||||||
|| kind == Kind::RequestToVanish
|
|
||||||
|| ACTIVITY_KINDS.contains(&kind)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Value of the first tag named `name` on `event`.
|
/// Value of the first tag named `name` on `event`.
|
||||||
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
fn tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> {
|
||||||
event
|
event
|
||||||
@@ -148,7 +140,13 @@ pub fn notifications(me: PublicKey) -> Vec<Filter> {
|
|||||||
/// A comment on an unrelated kind is matched too, so results must be filtered
|
/// A comment on an unrelated kind is matched too, so results must be filtered
|
||||||
/// through [`is_git_activity`] before display.
|
/// through [`is_git_activity`] before display.
|
||||||
pub fn authored_activity(me: PublicKey) -> Filter {
|
pub fn authored_activity(me: PublicKey) -> Filter {
|
||||||
Filter::new().kinds(ACTIVITY_KINDS).author(me)
|
Filter::new()
|
||||||
|
.kinds(
|
||||||
|
ACTIVITY_KINDS
|
||||||
|
.into_iter()
|
||||||
|
.chain(std::iter::once(COVER_NOTE_KIND)),
|
||||||
|
)
|
||||||
|
.author(me)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
/// Whether a kind-1111 comment targets a git root, checked via its `K` tag.
|
||||||
@@ -157,6 +155,12 @@ fn is_git_comment(event: &Event) -> bool {
|
|||||||
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
&& tag_kind(event, "K").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Whether a kind-1624 cover note targets a git root, checked via its `k` tag.
|
||||||
|
fn is_git_cover_note(event: &Event) -> bool {
|
||||||
|
event.kind == COVER_NOTE_KIND
|
||||||
|
&& tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
|
}
|
||||||
|
|
||||||
/// Whether a status event references a git root, checked via its `k` tag.
|
/// Whether a status event references a git root, checked via its `k` tag.
|
||||||
fn is_git_status(event: &Event) -> bool {
|
fn is_git_status(event: &Event) -> bool {
|
||||||
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
tag_kind(event, "k").is_some_and(|kind| GIT_ROOT_KINDS.contains(&kind))
|
||||||
@@ -171,7 +175,7 @@ pub fn is_git_activity(event: &Event) -> bool {
|
|||||||
| Kind::GitStatusApplied
|
| Kind::GitStatusApplied
|
||||||
| Kind::GitStatusClosed
|
| Kind::GitStatusClosed
|
||||||
| Kind::GitStatusDraft => is_git_status(event),
|
| Kind::GitStatusDraft => is_git_status(event),
|
||||||
_ => false,
|
kind => kind == COVER_NOTE_KIND && is_git_cover_note(event),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -263,12 +267,17 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn status_activity_depends_on_the_lowercase_k_tag() {
|
fn status_and_cover_note_activity_depend_on_the_lowercase_k_tag() {
|
||||||
let status = signed(
|
let status = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
vec![kind_tag("k", Kind::GitPullRequest)],
|
vec![kind_tag("k", Kind::GitPullRequest)],
|
||||||
);
|
);
|
||||||
|
let cover = signed(
|
||||||
|
&keys(1),
|
||||||
|
COVER_NOTE_KIND,
|
||||||
|
vec![kind_tag("k", Kind::GitPatch)],
|
||||||
|
);
|
||||||
let unrelated = signed(
|
let unrelated = signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
Kind::GitStatusClosed,
|
Kind::GitStatusClosed,
|
||||||
@@ -276,6 +285,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assert!(is_git_activity(&status));
|
assert!(is_git_activity(&status));
|
||||||
|
assert!(is_git_activity(&cover));
|
||||||
assert!(!is_git_activity(&unrelated));
|
assert!(!is_git_activity(&unrelated));
|
||||||
assert!(!is_git_activity(&signed(
|
assert!(!is_git_activity(&signed(
|
||||||
&keys(1),
|
&keys(1),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use std::time::Duration;
|
|||||||
use nostr::prelude::*;
|
use nostr::prelude::*;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::{RepoAddr, activity_subject};
|
use crate::{COVER_NOTE_KIND, RepoAddr, activity_subject};
|
||||||
|
|
||||||
/// Window before `now` that an advanced cutoff retreats to.
|
/// Window before `now` that an advanced cutoff retreats to.
|
||||||
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
const ADVANCE_WINDOW: Duration = Duration::from_secs(3 * 24 * 60 * 60);
|
||||||
@@ -121,11 +121,14 @@ impl InboxItem {
|
|||||||
/// - patch (1617): its `e` parent patch, else itself
|
/// - patch (1617): its `e` parent patch, else itself
|
||||||
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
/// - NIP-22 comment (1111): uppercase `E` root pointer
|
||||||
/// - PR update (1619): uppercase `E`
|
/// - PR update (1619): uppercase `E`
|
||||||
/// - statuses (1630-1633): NIP-10 root `e`
|
/// - statuses (1630-1633) / cover note (1624): NIP-10 root `e`
|
||||||
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
pub fn notification_root<L>(event: &Event, lookup: &L) -> Option<EventId>
|
||||||
where
|
where
|
||||||
L: Fn(EventId) -> Option<Event>,
|
L: Fn(EventId) -> Option<Event>,
|
||||||
{
|
{
|
||||||
|
if event.kind == COVER_NOTE_KIND {
|
||||||
|
return nip10_root_id(event).map(|root| resolve_thread_root(root, lookup));
|
||||||
|
}
|
||||||
match event.kind {
|
match event.kind {
|
||||||
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
Kind::GitIssue | Kind::GitPullRequest => Some(event.id),
|
||||||
Kind::GitPatch => Some(match first_e_id(event) {
|
Kind::GitPatch => Some(match first_e_id(event) {
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
pub mod addr;
|
pub mod addr;
|
||||||
|
pub mod annotations;
|
||||||
pub mod deletions;
|
pub mod deletions;
|
||||||
pub mod filters;
|
pub mod filters;
|
||||||
pub mod inbox;
|
pub mod inbox;
|
||||||
@@ -7,6 +8,7 @@ pub mod state;
|
|||||||
pub mod status;
|
pub mod status;
|
||||||
|
|
||||||
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
pub use addr::{RepoAddr, identifier_from_name, repo_addr};
|
||||||
|
pub use annotations::COVER_NOTE_KIND;
|
||||||
pub use deletions::Deletions;
|
pub use deletions::Deletions;
|
||||||
pub use filters::{
|
pub use filters::{
|
||||||
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
NOTIFICATION_KINDS, authored_activity, is_git_activity, notification_comments, notifications,
|
||||||
|
|||||||
+104
-114
@@ -21,8 +21,10 @@ use crate::repos::RepoListStore;
|
|||||||
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
pub const USER_KEYRING: &str = "Signed Safe Storage";
|
||||||
/// Timeout for NIP-46 signer responses.
|
/// Timeout for NIP-46 signer responses.
|
||||||
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
pub const NOSTR_CONNECT_TIMEOUT: u64 = 60;
|
||||||
|
|
||||||
/// Relays connected at startup, before any user-specific relay config is known.
|
/// Relays connected at startup, before any user-specific relay config is known.
|
||||||
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
pub const BOOTSTRAP_RELAYS: [&str; 2] = ["wss://relay.ditto.pub", "wss://index.ngit.dev"];
|
||||||
|
|
||||||
/// Relays used to index the user's NIP-65 relay list.
|
/// 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"];
|
pub const INDEXER_RELAYS: [&str; 2] = ["wss://indexer.coracle.social", "wss://user.kindpag.es"];
|
||||||
|
|
||||||
@@ -35,15 +37,15 @@ pub enum BackendEvent {
|
|||||||
PassphraseRequired,
|
PassphraseRequired,
|
||||||
/// The signer changed on login, logout or account switch.
|
/// The signer changed on login, logout or account switch.
|
||||||
SignerChanged,
|
SignerChanged,
|
||||||
/// Kind-0 metadata arrived for these authors; re-read them from the store.
|
/// New events were received from a relay and stored in the database.
|
||||||
ProfileUpdates(Vec<PublicKey>),
|
NostrUpdate(Vec<Update>),
|
||||||
/// Repository events arrived: announcements, states, activity and deletions.
|
|
||||||
RepoUpdates(Vec<Update>),
|
|
||||||
Synced,
|
Synced,
|
||||||
SyncProgress {
|
SyncProgress {
|
||||||
total: u64,
|
total: u64,
|
||||||
current: u64,
|
current: u64,
|
||||||
},
|
},
|
||||||
|
/// An event built locally was signed, broadcast and stored.
|
||||||
|
Published(Box<Event>),
|
||||||
Error(String),
|
Error(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -88,16 +90,14 @@ impl Backend {
|
|||||||
|
|
||||||
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
let pump: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let mut notifications = pump_client.notifications();
|
let mut notifications = pump_client.notifications();
|
||||||
let mut pending_profiles: HashSet<PublicKey> = HashSet::new();
|
let mut pending: Vec<Update> = Vec::new();
|
||||||
let mut pending_repos: Vec<Update> = Vec::new();
|
|
||||||
let mut seen: HashSet<EventId> = HashSet::new();
|
|
||||||
|
|
||||||
'outer: loop {
|
'outer: loop {
|
||||||
match next_update(&mut notifications, &mut seen).await {
|
match notifications.next().await {
|
||||||
Some(UpdateEvent::Profile(author)) => {
|
Some(ClientNotification::Event { event, .. }) => {
|
||||||
pending_profiles.insert(author);
|
pending.push(Update::from_event(&event));
|
||||||
}
|
}
|
||||||
Some(UpdateEvent::Repo(update)) => pending_repos.push(update),
|
Some(_) => continue,
|
||||||
None => break,
|
None => break,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,33 +113,28 @@ impl Backend {
|
|||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let next = next_update(&mut notifications, &mut seen);
|
let next = notifications.next();
|
||||||
futures::pin_mut!(next);
|
futures::pin_mut!(next);
|
||||||
|
|
||||||
match futures::future::select(next, timer).await {
|
match futures::future::select(next, timer).await {
|
||||||
futures::future::Either::Left((Some(UpdateEvent::Profile(author)), _)) => {
|
futures::future::Either::Left((
|
||||||
pending_profiles.insert(author);
|
Some(ClientNotification::Event { event, .. }),
|
||||||
}
|
_,
|
||||||
futures::future::Either::Left((Some(UpdateEvent::Repo(update)), _)) => {
|
)) => {
|
||||||
pending_repos.push(update);
|
pending.push(Update::from_event(&event));
|
||||||
}
|
}
|
||||||
|
futures::future::Either::Left((Some(_), _)) => continue,
|
||||||
futures::future::Either::Left((None, _)) => break 'outer,
|
futures::future::Either::Left((None, _)) => break 'outer,
|
||||||
futures::future::Either::Right(_) => break,
|
futures::future::Either::Right(_) => break,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let profiles: Vec<PublicKey> = pending_profiles.drain().collect();
|
let batch = std::mem::take(&mut pending);
|
||||||
let repos = std::mem::take(&mut pending_repos);
|
|
||||||
|
|
||||||
if let Err(e) = this.update(cx, |_this, cx| {
|
if let Err(e) =
|
||||||
if !profiles.is_empty() {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::NostrUpdate(batch)))
|
||||||
cx.emit(BackendEvent::ProfileUpdates(profiles));
|
{
|
||||||
}
|
log::warn!("failed to emit nostr update: {e}");
|
||||||
if !repos.is_empty() {
|
|
||||||
cx.emit(BackendEvent::RepoUpdates(repos));
|
|
||||||
}
|
|
||||||
}) {
|
|
||||||
log::warn!("failed to emit backend update: {e}");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,10 +144,9 @@ impl Backend {
|
|||||||
pump.detach();
|
pump.detach();
|
||||||
|
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
weak.update(cx, |this, cx| {
|
if let Err(error) = weak.update(cx, |this, cx| this.bootstrap(cx)) {
|
||||||
this.restore_session(cx);
|
log::warn!("backend dropped before bootstrap could run: {error}");
|
||||||
})
|
}
|
||||||
.ok();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -166,6 +160,42 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn bootstrap(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let client = self.client.clone();
|
||||||
|
|
||||||
|
let task = cx.background_spawn(async move {
|
||||||
|
for url in BOOTSTRAP_RELAYS {
|
||||||
|
client.add_relay(url).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
for url in INDEXER_RELAYS {
|
||||||
|
client
|
||||||
|
.add_relay(url)
|
||||||
|
.capabilities(RelayCapabilities::DISCOVERY)
|
||||||
|
.await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.connect().await;
|
||||||
|
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
|
||||||
|
let notify_task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
match task.await {
|
||||||
|
Ok(()) => {
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.restore_session(cx);
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::error(e.to_string())))?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok::<(), Error>(())
|
||||||
|
});
|
||||||
|
notify_task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
/// Restore the saved session from the Keyring.
|
/// Restore the saved session from the Keyring.
|
||||||
///
|
///
|
||||||
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
/// - Emits [`BackendEvent::SignerRequired`] when no credential is stored.
|
||||||
@@ -190,9 +220,7 @@ impl Backend {
|
|||||||
let result = async {
|
let result = async {
|
||||||
if content.starts_with("nsec1") {
|
if content.starts_with("nsec1") {
|
||||||
let keys = Keys::new(SecretKey::parse(&content)?);
|
let keys = Keys::new(SecretKey::parse(&content)?);
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| this.set_signer(keys, cx))?;
|
||||||
this.set_signer(keys, cx);
|
|
||||||
})?;
|
|
||||||
} else if content.starts_with("bunker://") {
|
} else if content.starts_with("bunker://") {
|
||||||
let (base, keys) = extract_master_key(&content);
|
let (base, keys) = extract_master_key(&content);
|
||||||
let uri = NostrConnectUri::parse(base)?;
|
let uri = NostrConnectUri::parse(base)?;
|
||||||
@@ -203,19 +231,14 @@ impl Backend {
|
|||||||
None,
|
None,
|
||||||
)?;
|
)?;
|
||||||
signer.auth_url_handler(SignedAuthUrlHandler);
|
signer.auth_url_handler(SignedAuthUrlHandler);
|
||||||
|
this.update(cx, |this, cx| this.set_signer(signer, cx))?;
|
||||||
this.update(cx, |this, cx| {
|
|
||||||
this.set_signer(signer, cx);
|
|
||||||
})?;
|
|
||||||
} else if content.starts_with("ncryptsec1") {
|
} else if content.starts_with("ncryptsec1") {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.passphrase_required = true;
|
this.passphrase_required = true;
|
||||||
cx.emit(BackendEvent::PassphraseRequired);
|
cx.emit(BackendEvent::PassphraseRequired);
|
||||||
})?;
|
})?;
|
||||||
} else {
|
} else {
|
||||||
this.update(cx, |_this, cx| {
|
this.update(cx, |_this, cx| cx.emit(BackendEvent::SignerRequired))?;
|
||||||
cx.emit(BackendEvent::SignerRequired);
|
|
||||||
})?;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok::<_, Error>(())
|
||||||
@@ -477,7 +500,9 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
require_relay_accepted(output, event)?
|
let event = require_relay_accepted(output, event)?;
|
||||||
|
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
||||||
|
event
|
||||||
};
|
};
|
||||||
|
|
||||||
// The state event is the push authorization. Stage it on each
|
// The state event is the push authorization. Stage it on each
|
||||||
@@ -532,11 +557,15 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it
|
// Staging already stored the event locally, publishing makes it
|
||||||
// visible to the other relays and clients.
|
// visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event
|
if let Some(state_event) = &outcome.state_event {
|
||||||
&& let Err(e) = client.send_event(state_event).broadcast().await
|
if let Err(e) = client.send_event(state_event).broadcast().await {
|
||||||
{
|
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.announce_published(state_event.clone(), cx)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
let announcement = Announcement::from_event(&event)
|
let announcement = Announcement::from_event(&event)
|
||||||
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
.ok_or_else(|| anyhow!("failed to parse announcement"))?;
|
||||||
@@ -624,7 +653,9 @@ impl Backend {
|
|||||||
let builder = announcement.into_event_builder();
|
let builder = announcement.into_event_builder();
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
require_relay_accepted(output, event)?
|
let event = require_relay_accepted(output, event)?;
|
||||||
|
this.update(cx, |this, cx| this.announce_published(event.clone(), cx))?;
|
||||||
|
event
|
||||||
};
|
};
|
||||||
|
|
||||||
let refs = state.refs.clone();
|
let refs = state.refs.clone();
|
||||||
@@ -681,11 +712,13 @@ impl Backend {
|
|||||||
|
|
||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
// Staging already stored the event locally, publishing makes it visible to the other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event
|
if let Some(state_event) = &outcome.state_event {
|
||||||
&& let Err(e) = client.send_event(state_event).broadcast().await
|
if let Err(e) = client.send_event(state_event).broadcast().await {
|
||||||
{
|
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
|
this.update(cx, |this, cx| this.announce_published(state_event.clone(), cx))
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Point `origin` at the first grasp server so later pushes have a target.
|
// Point `origin` at the first grasp server so later pushes have a target.
|
||||||
@@ -861,11 +894,15 @@ impl Backend {
|
|||||||
// Fan the state out to the relays once a git server holds the objects.
|
// Fan the state out to the relays once a git server holds the objects.
|
||||||
// Staging already stored the event locally, publishing notifies
|
// Staging already stored the event locally, publishing notifies
|
||||||
// the repository views and other relays and clients.
|
// the repository views and other relays and clients.
|
||||||
if let Some(state_event) = &outcome.state_event
|
if let Some(state_event) = &outcome.state_event {
|
||||||
&& let Err(e) = client.send_event(state_event).broadcast().await
|
if let Err(e) = client.send_event(state_event).broadcast().await {
|
||||||
{
|
|
||||||
log::warn!("failed to broadcast repository state: {e}");
|
log::warn!("failed to broadcast repository state: {e}");
|
||||||
}
|
}
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
this.announce_published(state_event.clone(), cx)
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
Ok(outcome)
|
Ok(outcome)
|
||||||
})
|
})
|
||||||
@@ -1036,7 +1073,7 @@ impl Backend {
|
|||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
for url in user_grasp_list_servers(&client, public_key).await? {
|
for url in user_grasp_list_servers(client.clone(), public_key).await? {
|
||||||
client.add_relay(url).and_connect().await.ok();
|
client.add_relay(url).and_connect().await.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1262,6 +1299,14 @@ impl Backend {
|
|||||||
.detach();
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Emit [`BackendEvent::Published`] for cross-store invalidation.
|
||||||
|
///
|
||||||
|
/// Callers publish with `client.send_event(...)` directly, then call this
|
||||||
|
/// so stores like `RepoListStore` refresh without re-querying the relays.
|
||||||
|
pub fn announce_published(&self, event: Event, cx: &mut Context<Self>) {
|
||||||
|
cx.emit(BackendEvent::Published(Box::new(event)));
|
||||||
|
}
|
||||||
|
|
||||||
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
/// Publish a NIP-09 deletion for each of `events`, best-effort.
|
||||||
///
|
///
|
||||||
/// Each target gets its own deletion event: a relay rejecting or
|
/// Each target gets its own deletion event: a relay rejecting or
|
||||||
@@ -1284,42 +1329,6 @@ impl Backend {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A relay event the backend routes to a store group.
|
|
||||||
enum UpdateEvent {
|
|
||||||
Profile(PublicKey),
|
|
||||||
Repo(Update),
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Await the next relay event from the notification stream.
|
|
||||||
async fn next_update(
|
|
||||||
notifications: &mut (impl futures::Stream<Item = ClientNotification> + Unpin),
|
|
||||||
seen: &mut HashSet<EventId>,
|
|
||||||
) -> Option<UpdateEvent> {
|
|
||||||
loop {
|
|
||||||
match notifications.next().await {
|
|
||||||
Some(ClientNotification::Message { message, .. }) => {
|
|
||||||
let RelayMessage::Event { event, .. } = *message else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
|
|
||||||
let update = match event.kind {
|
|
||||||
Kind::Metadata => UpdateEvent::Profile(event.pubkey),
|
|
||||||
kind if filters::is_repo_kind(kind) => {
|
|
||||||
UpdateEvent::Repo(Update::from_event(&event))
|
|
||||||
}
|
|
||||||
_ => continue,
|
|
||||||
};
|
|
||||||
|
|
||||||
if seen.insert(event.id) {
|
|
||||||
return Some(update);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Some(_) => continue,
|
|
||||||
None => return None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Sign and send a single NIP-09 deletion request for `event`.
|
/// Sign and send a single NIP-09 deletion request for `event`.
|
||||||
async fn retract_event(
|
async fn retract_event(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -1329,14 +1338,16 @@ async fn retract_event(
|
|||||||
let builder = EventDeletionRequest::new()
|
let builder = EventDeletionRequest::new()
|
||||||
.id(event.id)
|
.id(event.id)
|
||||||
.into_event_builder();
|
.into_event_builder();
|
||||||
|
|
||||||
let deletion = builder.finalize_async(signer).await?;
|
let deletion = builder.finalize_async(signer).await?;
|
||||||
client.send_event(&deletion).broadcast().await?;
|
client.send_event(&deletion).broadcast().await?;
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
/// The event was accepted by at least one relay, or a descriptive error otherwise.
|
||||||
|
///
|
||||||
|
/// The SDK does not treat "accepted by zero relays" as an error on its own:
|
||||||
|
/// [`SendEventOutput::success`] may be empty while the call still returns `Ok`.
|
||||||
|
/// This turns that case into an error the caller can surface.
|
||||||
pub(crate) fn require_relay_accepted(
|
pub(crate) fn require_relay_accepted(
|
||||||
output: SendEventOutput,
|
output: SendEventOutput,
|
||||||
event: Event,
|
event: Event,
|
||||||
@@ -1394,29 +1405,10 @@ async fn connect_repo_relays(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Add and connect the startup relays.
|
|
||||||
async fn ensure_bootstrap_relays(client: &Client) -> Result<(), Error> {
|
|
||||||
for url in BOOTSTRAP_RELAYS {
|
|
||||||
client.add_relay(url).and_connect().await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
for url in INDEXER_RELAYS {
|
|
||||||
client
|
|
||||||
.add_relay(url)
|
|
||||||
.capabilities(RelayCapabilities::DISCOVERY)
|
|
||||||
.and_connect()
|
|
||||||
.await?;
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) async fn subscribe_bootstrap_only(
|
pub(crate) async fn subscribe_bootstrap_only(
|
||||||
client: &Client,
|
client: &Client,
|
||||||
filters: Vec<Filter>,
|
filters: Vec<Filter>,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
ensure_bootstrap_relays(client).await?;
|
|
||||||
|
|
||||||
let opts = SubscribeAutoCloseOptions::default()
|
let opts = SubscribeAutoCloseOptions::default()
|
||||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||||
.timeout(Some(Duration::from_secs(10)));
|
.timeout(Some(Duration::from_secs(10)));
|
||||||
@@ -1436,8 +1428,6 @@ pub(crate) async fn sync_bootstrap_only(
|
|||||||
filter: Filter,
|
filter: Filter,
|
||||||
opts: SyncOptions,
|
opts: SyncOptions,
|
||||||
) -> Result<SyncSummary, Error> {
|
) -> Result<SyncSummary, Error> {
|
||||||
ensure_bootstrap_relays(client).await?;
|
|
||||||
|
|
||||||
let output = client
|
let output = client
|
||||||
.sync(filter)
|
.sync(filter)
|
||||||
.with(BOOTSTRAP_RELAYS)
|
.with(BOOTSTRAP_RELAYS)
|
||||||
@@ -1516,7 +1506,7 @@ fn latest_grasp_list_servers(events: Vec<Event>) -> Vec<RelayUrl> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pub async fn user_grasp_list_servers(
|
pub async fn user_grasp_list_servers(
|
||||||
client: &Client,
|
client: Client,
|
||||||
user: PublicKey,
|
user: PublicKey,
|
||||||
) -> Result<Vec<RelayUrl>, Error> {
|
) -> Result<Vec<RelayUrl>, Error> {
|
||||||
let events: Vec<Event> = client
|
let events: Vec<Event> = client
|
||||||
|
|||||||
@@ -17,9 +17,15 @@ use crate::repos::RepoListStore;
|
|||||||
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
const REFRESH_DEBOUNCE: Duration = Duration::from_millis(300);
|
||||||
|
|
||||||
/// How often the statuses are recomputed against the local refs.
|
/// How often the statuses are recomputed against the local refs.
|
||||||
|
///
|
||||||
|
/// A commit lands in a checkout long before the remote reconciliation cadence,
|
||||||
|
/// so this fast pass surfaces ready-to-push and ready-to-contribute checkouts
|
||||||
|
/// within a second or two. It reads the tracking refs only, no network.
|
||||||
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
const LOCAL_POLL: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// How often a full pass refreshes the remotes while any repository panel is open.
|
/// How often a full pass refreshes the remotes while any repository panel is open.
|
||||||
const STATUS_POLL: Duration = Duration::from_secs(15);
|
const STATUS_POLL: Duration = Duration::from_secs(15);
|
||||||
|
|
||||||
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
/// Remote refresh interval for the `ready to push` badges of the user's own repositories.
|
||||||
const PUSH_POLL: Duration = Duration::from_secs(60);
|
const PUSH_POLL: Duration = Duration::from_secs(60);
|
||||||
|
|
||||||
@@ -40,6 +46,10 @@ pub struct CheckoutStatus {
|
|||||||
/// Commit the branch points at, for tip-based PR dedupe.
|
/// Commit the branch points at, for tip-based PR dedupe.
|
||||||
pub head: String,
|
pub head: String,
|
||||||
/// What the branch is compared against.
|
/// What the branch is compared against.
|
||||||
|
/// For ready-to-contribute statuses, the announced HEAD branch.
|
||||||
|
/// The fallbacks are `main`, then the first local branch.
|
||||||
|
/// For ready-to-push statuses, the remote-tracking ref.
|
||||||
|
/// Unpushed commits are counted against it.
|
||||||
///
|
///
|
||||||
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
/// It is `refs/remotes/origin/<branch>`, else `origin/HEAD` for new branches.
|
||||||
pub base: String,
|
pub base: String,
|
||||||
@@ -57,6 +67,11 @@ struct Remembered {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Global store of local-checkout associations and per-checkout statuses.
|
/// Global store of local-checkout associations and per-checkout statuses.
|
||||||
|
///
|
||||||
|
/// Readers (the sidebar rows, the repository panels) observe this store and
|
||||||
|
/// derive what they display from their own snapshots, so publishing needs no
|
||||||
|
/// fine-grained entities: the store notifies when a slice changed and each
|
||||||
|
/// reader re-derives only what it shows.
|
||||||
pub struct CheckoutsStore {
|
pub struct CheckoutsStore {
|
||||||
/// Checkout paths per announced repository.
|
/// Checkout paths per announced repository.
|
||||||
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
by_repo: HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
@@ -129,7 +144,6 @@ impl CheckoutsStore {
|
|||||||
this.statuses.clear();
|
this.statuses.clear();
|
||||||
this.push_statuses.clear();
|
this.push_statuses.clear();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refresh(cx);
|
this.refresh(cx);
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -268,6 +282,9 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Re-resolve the associations and the requested statuses.
|
/// Re-resolve the associations and the requested statuses.
|
||||||
|
///
|
||||||
|
/// Requests arriving while a pass runs fold into a follow-up, requests
|
||||||
|
/// arriving while the debounce timer is pending are dropped.
|
||||||
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
pub fn refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
if self.debounce_pending || self.refresh.request() != RefreshRequest::Schedule {
|
||||||
return;
|
return;
|
||||||
@@ -283,6 +300,12 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// One full resolve and apply cycle, the debounced entry point.
|
/// One full resolve and apply cycle, the debounced entry point.
|
||||||
|
///
|
||||||
|
/// Re-resolves the associations from the settings, the scan and the
|
||||||
|
/// announcements, then recomputes the requested statuses against freshly
|
||||||
|
/// fetched remotes. Full passes run on every input change and on the
|
||||||
|
/// remote reconciliation cadence ([`Self::local_tick`]); they also restart
|
||||||
|
/// the fast local pass.
|
||||||
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
fn run_refresh(&mut self, cx: &mut Context<Self>) {
|
||||||
self.debounce_pending = false;
|
self.debounce_pending = false;
|
||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
@@ -408,6 +431,10 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Schedule the fast local status pass, unless one is already pending.
|
/// Schedule the fast local status pass, unless one is already pending.
|
||||||
|
///
|
||||||
|
/// Every [`LOCAL_POLL`] the pass recomputes the requested statuses against
|
||||||
|
/// 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>) {
|
fn schedule_local_pass(&mut self, cx: &mut Context<Self>) {
|
||||||
if self.local_pending {
|
if self.local_pending {
|
||||||
return;
|
return;
|
||||||
@@ -425,6 +452,10 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The fast local status pass.
|
/// The fast local status pass.
|
||||||
|
///
|
||||||
|
/// Recomputes the statuses against the local refs; when the remote
|
||||||
|
/// reconciliation cadence elapsed, it runs a full pass instead so pushes
|
||||||
|
/// made elsewhere do not linger as `to push`.
|
||||||
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
fn local_tick(&mut self, cx: &mut Context<Self>) {
|
||||||
// Nothing watched: the chain idles out until a new request restarts it.
|
// Nothing watched: the chain idles out until a new request restarts it.
|
||||||
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
if self.status_requested.is_empty() && self.push_requested.is_empty() {
|
||||||
@@ -459,6 +490,10 @@ impl CheckoutsStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Recompute the requested statuses against the tracking refs only.
|
/// Recompute the requested statuses against the tracking refs only.
|
||||||
|
///
|
||||||
|
/// The refs were last refreshed by a full pass. Comparing against them is
|
||||||
|
/// enough to pick up new local commits, and skipping the network keeps
|
||||||
|
/// this pass cheap enough to run every [`LOCAL_POLL`].
|
||||||
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
fn run_local_statuses(&mut self, cx: &mut Context<Self>) {
|
||||||
let associations = self.by_repo.clone();
|
let associations = self.by_repo.clone();
|
||||||
|
|
||||||
@@ -600,6 +635,10 @@ fn checkout_status(path: &Path, announced_head: Option<&str>) -> Option<Checkout
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The `ready to push` status of one checkout of the user's own repository.
|
/// The `ready to push` status of one checkout of the user's own repository.
|
||||||
|
///
|
||||||
|
/// `fetch` refreshes the remote heads first, so a full pass sees pushes made
|
||||||
|
/// elsewhere; the fast local pass skips it and compares against the tracking
|
||||||
|
/// refs left by the last full pass, which is enough to detect local commits.
|
||||||
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
||||||
if signed_git::worktree_dirty(path) {
|
if signed_git::worktree_dirty(path) {
|
||||||
return None;
|
return None;
|
||||||
@@ -610,6 +649,8 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
let origin = signed_git::origin_url(path).ok().flatten()?;
|
let origin = signed_git::origin_url(path).ok().flatten()?;
|
||||||
|
|
||||||
if fetch {
|
if fetch {
|
||||||
|
// Refresh the remote heads first.
|
||||||
|
// Commits made elsewhere or pushed from another machine must not linger as `to push`.
|
||||||
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
signed_git::fetch_repo_refs(path, &[origin], "+refs/heads/*:refs/remotes/origin/*").ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -637,6 +678,10 @@ fn checkout_push_status(path: &Path, fetch: bool) -> Option<CheckoutStatus> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Compute the requested statuses against the checkout paths of `associations`.
|
/// Compute the requested statuses against the checkout paths of `associations`.
|
||||||
|
///
|
||||||
|
/// Shared by the full and the local pass. `fetch` refreshes the checkouts'
|
||||||
|
/// remote heads first, so the full pass sees remote moves; the fast local
|
||||||
|
/// pass reads the tracking refs only, which is enough to detect local commits.
|
||||||
fn compute_statuses(
|
fn compute_statuses(
|
||||||
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
associations: &HashMap<RepoAddr, Vec<PathBuf>>,
|
||||||
requested: &[(RepoAddr, Option<String>)],
|
requested: &[(RepoAddr, Option<String>)],
|
||||||
@@ -692,14 +737,12 @@ pub fn pr_proposes_checkout(
|
|||||||
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
if pr.kind != Kind::GitPullRequest || !open || pr.pubkey != user {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
let branch_matches = pr
|
let branch_matches = pr
|
||||||
.tags
|
.tags
|
||||||
.iter()
|
.iter()
|
||||||
.find(|t| t.kind() == "branch-name")
|
.find(|t| t.kind() == "branch-name")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|name| name == checkout.branch);
|
.is_some_and(|name| name == checkout.branch);
|
||||||
|
|
||||||
// A renamed branch falls back to the proposed tip commit.
|
// A renamed branch falls back to the proposed tip commit.
|
||||||
let tip_matches = pr
|
let tip_matches = pr
|
||||||
.tags
|
.tags
|
||||||
@@ -707,7 +750,6 @@ pub fn pr_proposes_checkout(
|
|||||||
.find(|t| t.kind() == "c")
|
.find(|t| t.kind() == "c")
|
||||||
.and_then(|t| t.content())
|
.and_then(|t| t.content())
|
||||||
.is_some_and(|tip| tip == checkout.head);
|
.is_some_and(|tip| tip == checkout.head);
|
||||||
|
|
||||||
branch_matches || tip_matches
|
branch_matches || tip_matches
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -104,16 +104,11 @@ impl Inbox {
|
|||||||
|
|
||||||
/// Sign the state with a random key and store it locally.
|
/// Sign the state with a random key and store it locally.
|
||||||
fn persist(&mut self, cx: &mut Context<Self>) {
|
fn persist(&mut self, cx: &mut Context<Self>) {
|
||||||
let backend = Backend::global(cx);
|
let Some(me) = Backend::global(cx).read(cx).current_user() else {
|
||||||
let (me, client) = {
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.current_user(), backend.client())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(me) = me else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let client = Backend::global(cx).read(cx).client();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
let task: Task<Result<(), Error>> = cx.background_spawn(async move {
|
||||||
|
|||||||
@@ -5,20 +5,14 @@ use std::time::{Duration, Instant};
|
|||||||
use anyhow::Error;
|
use anyhow::Error;
|
||||||
use flume::{Receiver, Sender};
|
use flume::{Receiver, Sender};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, WeakEntity,
|
App, AppContext, AsyncApp, Context, Entity, Global, SharedString, Subscription, Task,
|
||||||
|
WeakEntity,
|
||||||
};
|
};
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use utils::shorten_pubkey;
|
use utils::shorten_pubkey;
|
||||||
|
|
||||||
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
use crate::backend::{Backend, BackendEvent, sync_bootstrap_only};
|
||||||
|
|
||||||
/// How long to wait for more requests before firing a batched fetch.
|
|
||||||
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
|
||||||
/// Max authors per profile request, keeping each filter within relay limits.
|
|
||||||
const REQUEST_CHUNK: usize = 100;
|
|
||||||
/// Recent profiles prefetched at startup and read back from the cache.
|
|
||||||
const WARM_LIMIT: usize = 500;
|
|
||||||
|
|
||||||
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
/// A user profile as plain data for the UI, from the kind-0 metadata.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Profile {
|
pub struct Profile {
|
||||||
@@ -68,6 +62,9 @@ impl Profile {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long to wait for more requests before firing a batched sync.
|
||||||
|
const BATCH_TIMEOUT: Duration = Duration::from_millis(500);
|
||||||
|
|
||||||
/// Global profile cache.
|
/// Global profile cache.
|
||||||
///
|
///
|
||||||
/// Profiles are fetched in batches and kept as plain data.
|
/// Profiles are fetched in batches and kept as plain data.
|
||||||
@@ -95,26 +92,40 @@ impl ProfileStore {
|
|||||||
|
|
||||||
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
pub(crate) fn new(cx: &mut Context<Self>) -> Self {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let entity = cx.entity().downgrade();
|
|
||||||
let client = backend.read(cx).client();
|
|
||||||
|
|
||||||
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
|
BackendEvent::NostrUpdate(updates) => {
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
for update in updates
|
||||||
if let BackendEvent::ProfileUpdates(authors) = event {
|
.iter()
|
||||||
this.apply_authors(authors.clone(), cx);
|
.filter(|update| update.kind == Kind::Metadata)
|
||||||
|
{
|
||||||
|
this.apply_author(update.author, cx);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
BackendEvent::Published(event) if event.kind == Kind::Metadata => {
|
||||||
|
let metadata = Metadata::from_json(&event.content).unwrap_or_default();
|
||||||
|
this.profiles
|
||||||
|
.insert(event.pubkey, Profile::new(event.pubkey, metadata));
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
// Fetch requests are queued on a channel, batched into one sync per debounce window.
|
||||||
if let Err(e) = Self::handle_requests(this, &client, &receiver, cx).await {
|
let client = backend.read(cx).client();
|
||||||
log::error!("Failed to handle requests: {e}");
|
let (sender, receiver) = flume::unbounded::<PublicKey>();
|
||||||
}
|
let entity = cx.entity().downgrade();
|
||||||
|
|
||||||
|
cx.spawn(async move |_this, cx| {
|
||||||
|
Self::handle_requests(entity, &client, &receiver, cx).await
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
let weak = cx.entity().downgrade();
|
||||||
cx.defer(move |cx| {
|
cx.defer(move |cx| {
|
||||||
entity.update(cx, |this, cx| this.load(cx)).ok();
|
if let Err(error) = weak.update(cx, |this, cx| this.load(cx)) {
|
||||||
|
log::warn!("profile store dropped before initial load could run: {error}");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
@@ -149,9 +160,11 @@ impl ProfileStore {
|
|||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).limit(WARM_LIMIT);
|
let filter = Filter::new().kind(Kind::Metadata).limit(200);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
// Parse off the main thread.
|
||||||
|
// Only plain profiles cross back.
|
||||||
let profiles: Vec<Profile> = events
|
let profiles: Vec<Profile> = events
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|event| {
|
.map(|event| {
|
||||||
@@ -163,7 +176,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -173,13 +186,53 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of `authors` from the local database in one query.
|
fn apply_author(&mut self, public_key: PublicKey, cx: &mut Context<Self>) {
|
||||||
fn apply_authors(&mut self, authors: Vec<PublicKey>, cx: &mut Context<Self>) {
|
let backend = Backend::global(cx);
|
||||||
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
|
let work = cx.background_spawn(async move {
|
||||||
|
let filter = Filter::new().kind(Kind::Metadata).author(public_key);
|
||||||
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
|
// Parse off the main thread.
|
||||||
|
// Only the profile crosses back.
|
||||||
|
let profile = events
|
||||||
|
.into_iter()
|
||||||
|
.max_by_key(|e| e.created_at)
|
||||||
|
.map(|event| {
|
||||||
|
let metadata = Metadata::from_json(event.content).unwrap_or_default();
|
||||||
|
Profile::new(event.pubkey, metadata)
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok::<_, Error>(profile)
|
||||||
|
});
|
||||||
|
|
||||||
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
|
let profile = work.await?;
|
||||||
|
|
||||||
|
this.update(cx, |this, cx| {
|
||||||
|
if let Some(profile) = profile {
|
||||||
|
this.profiles.insert(profile.public_key(), profile);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
})?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
});
|
||||||
|
task.detach();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Re-read the latest metadata of every requested author from the local database.
|
||||||
|
///
|
||||||
|
/// Used after a sync, which produces no NostrUpdate events.
|
||||||
|
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
||||||
|
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
||||||
|
|
||||||
if authors.is_empty() {
|
if authors.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -190,9 +243,9 @@ impl ProfileStore {
|
|||||||
let work = cx.background_spawn(async move {
|
let work = cx.background_spawn(async move {
|
||||||
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
let filter = Filter::new().kind(Kind::Metadata).authors(authors);
|
||||||
let events = client.database().query(filter).await?;
|
let events = client.database().query(filter).await?;
|
||||||
|
|
||||||
// Pick the latest metadata per author off the main thread.
|
// Pick the latest metadata per author off the main thread.
|
||||||
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
let mut latest: HashMap<PublicKey, (Timestamp, Metadata)> = HashMap::new();
|
||||||
|
|
||||||
for event in events {
|
for event in events {
|
||||||
match latest.get(&event.pubkey) {
|
match latest.get(&event.pubkey) {
|
||||||
Some((ts, _)) if *ts >= event.created_at => {}
|
Some((ts, _)) if *ts >= event.created_at => {}
|
||||||
@@ -216,7 +269,7 @@ impl ProfileStore {
|
|||||||
Ok::<_, Error>(profiles)
|
Ok::<_, Error>(profiles)
|
||||||
});
|
});
|
||||||
|
|
||||||
cx.spawn(async move |this, cx| {
|
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
||||||
let profiles = work.await?;
|
let profiles = work.await?;
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
@@ -226,18 +279,14 @@ impl ProfileStore {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok::<_, Error>(())
|
Ok(())
|
||||||
})
|
});
|
||||||
.detach();
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Re-read the latest metadata of every requested author from the local database.
|
/// Sync metadata for requested authors in batches, debounced to collect requests.
|
||||||
fn apply_seen(&mut self, cx: &mut Context<Self>) {
|
///
|
||||||
let authors: Vec<PublicKey> = self.seen.borrow().iter().copied().collect();
|
/// After each batch, the seen profiles are re-read from the database on the main thread.
|
||||||
self.apply_authors(authors, cx);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Fetch metadata for requested authors in batches, debounced to collect requests.
|
|
||||||
async fn handle_requests(
|
async fn handle_requests(
|
||||||
this: WeakEntity<ProfileStore>,
|
this: WeakEntity<ProfileStore>,
|
||||||
client: &Client,
|
client: &Client,
|
||||||
@@ -256,20 +305,15 @@ impl ProfileStore {
|
|||||||
|
|
||||||
// The channel has no async timeout, race the receive against a timer.
|
// The channel has no async timeout, race the receive against a timer.
|
||||||
let deadline = Instant::now() + BATCH_TIMEOUT;
|
let deadline = Instant::now() + BATCH_TIMEOUT;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
let now = Instant::now();
|
let now = Instant::now();
|
||||||
|
|
||||||
if now >= deadline {
|
if now >= deadline {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
let timer = cx.background_executor().timer(deadline - now);
|
let timer = cx.background_executor().timer(deadline - now);
|
||||||
futures::pin_mut!(timer);
|
futures::pin_mut!(timer);
|
||||||
|
|
||||||
let recv = receiver.recv_async();
|
let recv = receiver.recv_async();
|
||||||
futures::pin_mut!(recv);
|
futures::pin_mut!(recv);
|
||||||
|
|
||||||
match futures::future::select(recv, timer).await {
|
match futures::future::select(recv, timer).await {
|
||||||
futures::future::Either::Left((Ok(public_key), _)) => {
|
futures::future::Either::Left((Ok(public_key), _)) => {
|
||||||
batch.insert(public_key);
|
batch.insert(public_key);
|
||||||
@@ -279,21 +323,18 @@ impl ProfileStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let authors: Vec<PublicKey> = batch.drain().collect();
|
|
||||||
|
|
||||||
for chunk in authors.chunks(REQUEST_CHUNK) {
|
|
||||||
let opts = SyncOptions::default();
|
|
||||||
let filter = Filter::new()
|
let filter = Filter::new()
|
||||||
.kind(Kind::Metadata)
|
.kind(Kind::Metadata)
|
||||||
.authors(chunk.iter().copied());
|
.authors(batch.drain().collect::<Vec<PublicKey>>());
|
||||||
|
|
||||||
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
|
// Negentropy-sync with the bootstrap relays.
|
||||||
log::warn!("profile fetch failed: {e}");
|
// Synced events are written to the database directly, no NostrUpdate.
|
||||||
|
// Re-apply from the database afterwards.
|
||||||
|
match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
|
||||||
|
Ok(_) => {
|
||||||
|
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
|
||||||
}
|
}
|
||||||
}
|
Err(e) => log::warn!("profile sync failed: {e}"),
|
||||||
|
|
||||||
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
|
|
||||||
return Ok(());
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,6 +18,10 @@ impl RefreshGate {
|
|||||||
self.running
|
self.running
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A new refresh request arrived.
|
||||||
|
///
|
||||||
|
/// Folded into a follow-up run while one is in flight, otherwise the
|
||||||
|
/// caller starts the run itself.
|
||||||
pub fn request(&mut self) -> RefreshRequest {
|
pub fn request(&mut self) -> RefreshRequest {
|
||||||
if self.running {
|
if self.running {
|
||||||
self.dirty = true;
|
self.dirty = true;
|
||||||
|
|||||||
+123
-63
@@ -4,7 +4,7 @@ use std::path::PathBuf;
|
|||||||
|
|
||||||
use anyhow::{Error, bail};
|
use anyhow::{Error, bail};
|
||||||
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
use bitcoin_hashes::sha1::Hash as Sha1Hash;
|
||||||
use gpui::{App, AppContext, Context, SharedString, Subscription, Task};
|
use gpui::{App, AppContext, AsyncApp, Context, SharedString, Subscription, Task, WeakEntity};
|
||||||
use nostr::event::IntoEventBuilder;
|
use nostr::event::IntoEventBuilder;
|
||||||
use nostr_sdk::prelude::*;
|
use nostr_sdk::prelude::*;
|
||||||
use settings::{EventFetchingStrategy, SettingsStore};
|
use settings::{EventFetchingStrategy, SettingsStore};
|
||||||
@@ -13,7 +13,6 @@ use signed_core::{
|
|||||||
pull_request_patches,
|
pull_request_patches,
|
||||||
};
|
};
|
||||||
use signed_git::Nip34Binding;
|
use signed_git::Nip34Binding;
|
||||||
use signed_nostr::UniversalSigner;
|
|
||||||
|
|
||||||
use crate::backend::{
|
use crate::backend::{
|
||||||
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
Backend, BackendEvent, grasp_base_url, grasp06_prs_url, pr_clone_urls, require_relay_accepted,
|
||||||
@@ -21,6 +20,7 @@ use crate::backend::{
|
|||||||
};
|
};
|
||||||
use crate::checkouts::CheckoutsStore;
|
use crate::checkouts::CheckoutsStore;
|
||||||
use crate::git_store::ensure_repo_mirror;
|
use crate::git_store::ensure_repo_mirror;
|
||||||
|
use crate::refresh::{RefreshGate, RefreshRequest};
|
||||||
use crate::repos::RepoListStore;
|
use crate::repos::RepoListStore;
|
||||||
|
|
||||||
/// Maximum size of one patch event.
|
/// Maximum size of one patch event.
|
||||||
@@ -31,6 +31,7 @@ const MAX_PATCH_EVENT_BYTES: usize = 60 * 1024;
|
|||||||
/// Per-repository store.
|
/// Per-repository store.
|
||||||
///
|
///
|
||||||
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
/// Holds the announcement, state, issues, patches, PRs, comments and resolved statuses.
|
||||||
|
/// Always derived from the local database.
|
||||||
pub struct RepoStore {
|
pub struct RepoStore {
|
||||||
/// NIP-34 address. `None` while the repository is local-only.
|
/// NIP-34 address. `None` while the repository is local-only.
|
||||||
addr: Option<RepoAddr>,
|
addr: Option<RepoAddr>,
|
||||||
@@ -88,8 +89,7 @@ pub struct RepoStore {
|
|||||||
///
|
///
|
||||||
/// Avoids re-running the maintainer Auto sync on every refresh.
|
/// Avoids re-running the maintainer Auto sync on every refresh.
|
||||||
synced_maintainers: HashSet<PublicKey>,
|
synced_maintainers: HashSet<PublicKey>,
|
||||||
/// In-flight tasks, cancelled when the store drops.
|
refresh: RefreshGate,
|
||||||
tasks: Vec<Task<Result<(), Error>>>,
|
|
||||||
/// Backend subscription of an announced repository. `None` while local-only.
|
/// Backend subscription of an announced repository. `None` while local-only.
|
||||||
_subscription: Option<Subscription>,
|
_subscription: Option<Subscription>,
|
||||||
}
|
}
|
||||||
@@ -138,7 +138,7 @@ impl RepoStore {
|
|||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
synced_maintainers: HashSet::new(),
|
synced_maintainers: HashSet::new(),
|
||||||
tasks: Vec::new(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: Some(subscription),
|
_subscription: Some(subscription),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -167,7 +167,7 @@ impl RepoStore {
|
|||||||
repo_relays: HashSet::new(),
|
repo_relays: HashSet::new(),
|
||||||
root_fetches: HashSet::new(),
|
root_fetches: HashSet::new(),
|
||||||
synced_maintainers: HashSet::new(),
|
synced_maintainers: HashSet::new(),
|
||||||
tasks: Vec::new(),
|
refresh: RefreshGate::default(),
|
||||||
_subscription: None,
|
_subscription: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,8 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
BackendEvent::RepoUpdates(updates) => updates.iter().any(|update| {
|
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||||
|
// Deletions may target any event of this repository.
|
||||||
let deletion =
|
let deletion =
|
||||||
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
@@ -224,6 +225,20 @@ impl RepoStore {
|
|||||||
|
|
||||||
deletion || coordinate || authored || comment || status
|
deletion || coordinate || authored || comment || status
|
||||||
}),
|
}),
|
||||||
|
BackendEvent::Published(event) => {
|
||||||
|
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||||
|
let author = event.pubkey == addr.public_key;
|
||||||
|
let coordinate = event.tags.coordinates().into_iter().any(|c| c == *addr);
|
||||||
|
|
||||||
|
let state = event.kind == Kind::RepoState
|
||||||
|
&& author
|
||||||
|
&& event.tags.identifier().as_deref() == Some(addr.identifier.as_str());
|
||||||
|
|
||||||
|
let deletion =
|
||||||
|
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
|
coordinate || (announcement && author) || state || deletion
|
||||||
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -365,6 +380,11 @@ impl RepoStore {
|
|||||||
if self.addr.is_none() {
|
if self.addr.is_none() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.refresh.request() != RefreshRequest::Schedule {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
self.run_refresh(cx);
|
self.run_refresh(cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,6 +393,8 @@ impl RepoStore {
|
|||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
self.refresh.begin();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let client = backend.read(cx).client();
|
let client = backend.read(cx).client();
|
||||||
|
|
||||||
@@ -491,7 +513,7 @@ impl RepoStore {
|
|||||||
))
|
))
|
||||||
});
|
});
|
||||||
|
|
||||||
let task = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
let (
|
let (
|
||||||
announcement,
|
announcement,
|
||||||
state,
|
state,
|
||||||
@@ -506,13 +528,14 @@ impl RepoStore {
|
|||||||
Ok(data) => data,
|
Ok(data) => data,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
|
this.refresh.abort();
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
this.update(cx, |this, cx| {
|
let again = this.update(cx, |this, cx| {
|
||||||
let keep_hint = announcement.is_none() && !this.loaded;
|
let keep_hint = announcement.is_none() && !this.loaded;
|
||||||
let first_pass = !this.loaded;
|
let first_pass = !this.loaded;
|
||||||
|
|
||||||
@@ -596,12 +619,17 @@ impl RepoStore {
|
|||||||
if changed {
|
if changed {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.refresh.finish()
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(())
|
if again {
|
||||||
});
|
this.update(cx, |this, cx| this.refresh(cx))?;
|
||||||
|
}
|
||||||
|
|
||||||
self.tasks.push(task);
|
Ok(())
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
/// Resolve the status of a root event, an issue, patch or PR, per NIP-34.
|
||||||
@@ -734,12 +762,9 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let (signer, client, user) = {
|
let signer = backend.read(cx).signer();
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.signer(), backend.client(), backend.current_user())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(user) = user else {
|
let Some(user) = backend.read(cx).current_user() else {
|
||||||
self.last_error = Some("Sign in to open a pull request".into());
|
self.last_error = Some("Sign in to open a pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -774,12 +799,12 @@ impl RepoStore {
|
|||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
// The PR references the root patch,
|
// The PR references the root patch event.
|
||||||
// viewers can then find the patch without carrying it inline.
|
// Viewers can then find the patch without carrying it inline.
|
||||||
let root_patch = match publish_patch_series(
|
let root_patch = match publish_patch_series(
|
||||||
&client,
|
&this,
|
||||||
&signer,
|
cx,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -804,14 +829,11 @@ impl RepoStore {
|
|||||||
// Resolve the servers from the author's latest kind-10317 grasp list.
|
// Resolve the servers from the author's latest kind-10317 grasp list.
|
||||||
// The settings defaults stand in when no list is published or the query fails.
|
// The settings defaults stand in when no list is published or the query fails.
|
||||||
let author_servers = {
|
let author_servers = {
|
||||||
let query_client = client.clone();
|
let query = this.update(cx, |_this, cx| {
|
||||||
let published = cx
|
let client = Backend::global(cx).read(cx).client();
|
||||||
.background_spawn(
|
user_grasp_list_servers(client, user)
|
||||||
async move { user_grasp_list_servers(&query_client, user).await },
|
})?;
|
||||||
)
|
match cx.background_spawn(query).await {
|
||||||
.await;
|
|
||||||
|
|
||||||
match published {
|
|
||||||
Ok(published) if !published.is_empty() => published,
|
Ok(published) if !published.is_empty() => published,
|
||||||
_ => defaults,
|
_ => defaults,
|
||||||
}
|
}
|
||||||
@@ -845,17 +867,24 @@ impl RepoStore {
|
|||||||
};
|
};
|
||||||
|
|
||||||
let builder = this.update(cx, |this, _cx| {
|
let builder = this.update(cx, |this, _cx| {
|
||||||
|
// NIP-34 PRs carry at least one clone URL.
|
||||||
|
// The tip commit is downloadable from it.
|
||||||
|
// The author's `/prs/` URLs come first.
|
||||||
|
// They are author-controlled and most likely alive.
|
||||||
|
// The announced mirrors follow.
|
||||||
|
// The list is fixed before signing.
|
||||||
|
// The pushed ref name embeds the event id.
|
||||||
|
// Every candidate URL is listed up front.
|
||||||
|
// Dead URLs are inert, the linked patch stays the source of truth.
|
||||||
let prs_urls: Vec<Url> = author_targets
|
let prs_urls: Vec<Url> = author_targets
|
||||||
.iter()
|
.iter()
|
||||||
.filter_map(|(url, _)| Url::parse(url).ok())
|
.filter_map(|(url, _)| Url::parse(url).ok())
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let base_clone = this
|
let base_clone = this
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let clone = pr_clone_urls(prs_urls, base_clone);
|
let clone = pr_clone_urls(prs_urls, base_clone);
|
||||||
|
|
||||||
let builder = GitPullRequest {
|
let builder = GitPullRequest {
|
||||||
@@ -880,6 +909,8 @@ impl RepoStore {
|
|||||||
})?;
|
})?;
|
||||||
|
|
||||||
// Sign before publishing.
|
// Sign before publishing.
|
||||||
|
// The tip is pushed to the grasp servers under `refs/nostr/<event-id>`.
|
||||||
|
// Nak's convention, readers fetch that ref for the commit behind the `c` tag.
|
||||||
let event = cx
|
let event = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let signer = signer.clone();
|
let signer = signer.clone();
|
||||||
@@ -890,22 +921,20 @@ impl RepoStore {
|
|||||||
if let Some(path) = push_from.as_ref() {
|
if let Some(path) = push_from.as_ref() {
|
||||||
let tip = current_commit.to_string();
|
let tip = current_commit.to_string();
|
||||||
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
let reference = format!("refs/nostr/{}", event.id.to_hex());
|
||||||
|
|
||||||
let (pushed, failures) = cx
|
let (pushed, failures) = cx
|
||||||
.background_spawn({
|
.background_spawn({
|
||||||
let path = path.clone();
|
let path = path.clone();
|
||||||
let tip = tip.clone();
|
let tip = tip.clone();
|
||||||
let reference = reference.clone();
|
let reference = reference.clone();
|
||||||
// Author servers first, then the announced base grasp servers.
|
// Author servers first, then the announced base grasp servers.
|
||||||
|
// The extra targets are best-effort redundancy.
|
||||||
let targets: Vec<(String, String)> = author_targets
|
let targets: Vec<(String, String)> = author_targets
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.chain(base_targets)
|
.chain(base_targets)
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
async move {
|
async move {
|
||||||
let mut failures = Vec::new();
|
let mut failures = Vec::new();
|
||||||
let mut pushed = 0;
|
let mut pushed = 0;
|
||||||
|
|
||||||
for (url, label) in &targets {
|
for (url, label) in &targets {
|
||||||
match signed_git::push_commit_ref(
|
match signed_git::push_commit_ref(
|
||||||
&path, url, &tip, &reference,
|
&path, url, &tip, &reference,
|
||||||
@@ -914,7 +943,6 @@ impl RepoStore {
|
|||||||
Err(e) => failures.push(format!("{label}: {e}")),
|
Err(e) => failures.push(format!("{label}: {e}")),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
(pushed, failures)
|
(pushed, failures)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -931,6 +959,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let client = this.update(cx, |_this, cx| Backend::global(cx).read(cx).client())?;
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
require_relay_accepted(output, event)
|
require_relay_accepted(output, event)
|
||||||
@@ -947,6 +977,11 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx)
|
||||||
|
.update(cx, |backend, cx| backend.announce_published(pr_event.clone(), cx))
|
||||||
|
})?;
|
||||||
|
|
||||||
// A draft PR carries a kind-1633 status event, NIP-34.
|
// A draft PR carries a kind-1633 status event, NIP-34.
|
||||||
// Publish it right after the PR event so viewers never show it open.
|
// Publish it right after the PR event so viewers never show it open.
|
||||||
if draft {
|
if draft {
|
||||||
@@ -956,8 +991,8 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
})
|
||||||
self.tasks.push(task);
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
/// Generate the patch between `merge_base` and `compare_ref` in `repo_path`,
|
||||||
@@ -1021,12 +1056,8 @@ impl RepoStore {
|
|||||||
self.last_warning = None;
|
self.last_warning = None;
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let (user, client, signer) = {
|
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.current_user(), backend.client(), backend.signer())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(user) = user else {
|
let Some(user) = backend.read(cx).current_user() else {
|
||||||
self.last_error = Some("Sign in to update the pull request".into());
|
self.last_error = Some("Sign in to update the pull request".into());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
return;
|
return;
|
||||||
@@ -1080,21 +1111,19 @@ impl RepoStore {
|
|||||||
self.not_announced(cx);
|
self.not_announced(cx);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
let owner = addr.public_key;
|
let owner = addr.public_key;
|
||||||
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
let euc = self.announcement.as_ref().and_then(|a| a.euc.clone());
|
||||||
let root = root.clone();
|
let root = root.clone();
|
||||||
|
|
||||||
let clone: Vec<Url> = self
|
let clone: Vec<Url> = self
|
||||||
.announcement
|
.announcement
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|a| a.clone.clone())
|
.map(|a| a.clone.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let task: Task<Result<(), Error>> = cx.spawn(async move |this, cx| {
|
cx.spawn(async move |this, cx| {
|
||||||
if let Err(e) = publish_patch_series(
|
if let Err(e) = publish_patch_series(
|
||||||
&client,
|
&this,
|
||||||
&signer,
|
cx,
|
||||||
&addr,
|
&addr,
|
||||||
owner,
|
owner,
|
||||||
euc.as_deref(),
|
euc.as_deref(),
|
||||||
@@ -1110,7 +1139,7 @@ impl RepoStore {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = {
|
let builder = this.update(cx, |_this, _cx| {
|
||||||
let builder = GitPullRequestUpdate {
|
let builder = GitPullRequestUpdate {
|
||||||
repository: addr.clone(),
|
repository: addr.clone(),
|
||||||
pull_request_event: root.id,
|
pull_request_event: root.id,
|
||||||
@@ -1127,7 +1156,13 @@ impl RepoStore {
|
|||||||
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
Some(euc) => builder.tag(Tag::parse(["r", euc]).expect("valid r tag")),
|
||||||
None => builder,
|
None => builder,
|
||||||
}
|
}
|
||||||
};
|
})?;
|
||||||
|
|
||||||
|
let (client, signer) = this.update(cx, |_this, cx| {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.client(), backend.signer())
|
||||||
|
})?;
|
||||||
|
|
||||||
let publish_result: Result<Event, Error> = async {
|
let publish_result: Result<Event, Error> = async {
|
||||||
let event = builder.finalize_async(&signer).await?;
|
let event = builder.finalize_async(&signer).await?;
|
||||||
@@ -1136,16 +1171,25 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = publish_result {
|
match publish_result {
|
||||||
|
Ok(event) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
|
backend.announce_published(event.clone(), cx)
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
return this.update(cx, |this, cx| {
|
return this.update(cx, |this, cx| {
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
})
|
||||||
self.tasks.push(task);
|
.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Set the status of a root event.
|
/// Set the status of a root event.
|
||||||
@@ -1271,7 +1315,7 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The latest announcement of this repository,
|
/// The latest announcement of this repository,
|
||||||
@@ -1578,16 +1622,24 @@ impl RepoStore {
|
|||||||
}
|
}
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
if let Err(e) = publish_result {
|
match publish_result {
|
||||||
|
Ok(event) => {
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx)
|
||||||
|
.update(cx, |backend, cx| backend.announce_published(event, cx))
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
this.update(cx, |this, cx| {
|
this.update(cx, |this, cx| {
|
||||||
this.last_error = Some(e.to_string());
|
this.last_error = Some(e.to_string());
|
||||||
cx.notify();
|
cx.notify();
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1657,8 +1709,8 @@ fn patch_current_commit(patch: &str) -> Option<&str> {
|
|||||||
/// Returns the root event, the one a PR references.
|
/// Returns the root event, the one a PR references.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
async fn publish_patch_series(
|
async fn publish_patch_series(
|
||||||
client: &Client,
|
this: &WeakEntity<RepoStore>,
|
||||||
signer: &UniversalSigner,
|
cx: &mut AsyncApp,
|
||||||
addr: &RepoAddr,
|
addr: &RepoAddr,
|
||||||
owner: PublicKey,
|
owner: PublicKey,
|
||||||
euc: Option<&str>,
|
euc: Option<&str>,
|
||||||
@@ -1666,6 +1718,12 @@ async fn publish_patch_series(
|
|||||||
first_marker: &str,
|
first_marker: &str,
|
||||||
reply_to: Option<EventId>,
|
reply_to: Option<EventId>,
|
||||||
) -> Result<Event, Error> {
|
) -> Result<Event, Error> {
|
||||||
|
let (client, signer) = this.update(cx, |_this, cx| {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let backend = backend.read(cx);
|
||||||
|
(backend.client(), backend.signer())
|
||||||
|
})?;
|
||||||
|
|
||||||
let mut root: Option<Event> = None;
|
let mut root: Option<Event> = None;
|
||||||
let mut previous = reply_to;
|
let mut previous = reply_to;
|
||||||
|
|
||||||
@@ -1678,7 +1736,6 @@ async fn publish_patch_series(
|
|||||||
};
|
};
|
||||||
|
|
||||||
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
let mut tags = vec![Tag::coordinate(addr.clone(), None), Tag::public_key(owner)];
|
||||||
|
|
||||||
if ix == 0 {
|
if ix == 0 {
|
||||||
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
if let Ok(tag) = Tag::parse(["t", first_marker]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
@@ -1693,25 +1750,28 @@ async fn publish_patch_series(
|
|||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(euc) = euc
|
if let Some(euc) = euc
|
||||||
&& let Ok(tag) = Tag::parse(["r", euc])
|
&& let Ok(tag) = Tag::parse(["r", euc])
|
||||||
{
|
{
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
if let Ok(tag) = Tag::parse(["commit", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Ok(tag) = Tag::parse(["r", commit]) {
|
if let Ok(tag) = Tag::parse(["r", commit]) {
|
||||||
tags.push(tag);
|
tags.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
let builder = EventBuilder::new(Kind::GitPatch, part.clone()).tags(tags);
|
||||||
let event = builder.finalize_async(signer).await?;
|
|
||||||
|
let event = builder.finalize_async(&signer).await?;
|
||||||
let output = client.send_event(&event).broadcast().await?;
|
let output = client.send_event(&event).broadcast().await?;
|
||||||
let event = require_relay_accepted(output, event)?;
|
let event = require_relay_accepted(output, event)?;
|
||||||
|
this.update(cx, |_this, cx| {
|
||||||
|
Backend::global(cx).update(cx, |backend, cx| {
|
||||||
|
backend.announce_published(event.clone(), cx)
|
||||||
|
})
|
||||||
|
})?;
|
||||||
|
|
||||||
if root.is_none() {
|
if root.is_none() {
|
||||||
root = Some(event.clone());
|
root = Some(event.clone());
|
||||||
|
|||||||
@@ -68,11 +68,32 @@ impl RepoListStore {
|
|||||||
|
|
||||||
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
let subscription = cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
let relevant = match event {
|
let relevant = match event {
|
||||||
|
BackendEvent::NostrUpdate(updates) => updates.iter().any(|update| {
|
||||||
|
// Deletions may target anything we list, always refresh.
|
||||||
|
if update.kind == Kind::EventDeletion || update.kind == Kind::RequestToVanish {
|
||||||
|
true
|
||||||
|
} else if filters::ACTIVITY_KINDS.contains(&update.kind) {
|
||||||
|
// Activity events are addressed to repos via `a` tags.
|
||||||
|
// Their author is not the repo owner, always refresh.
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
let is_announcement = update.kind == Kind::GitRepoAnnouncement;
|
||||||
|
let is_repo_state = update.kind == Kind::RepoState;
|
||||||
|
is_announcement || is_repo_state
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
BackendEvent::Published(event) => {
|
||||||
|
let announcement = event.kind == Kind::GitRepoAnnouncement;
|
||||||
|
let state = event.kind == Kind::RepoState;
|
||||||
|
let deletion =
|
||||||
|
event.kind == Kind::EventDeletion || event.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
|
announcement || state || deletion
|
||||||
|
}
|
||||||
BackendEvent::SignerChanged => {
|
BackendEvent::SignerChanged => {
|
||||||
this.state_synced_repos.clear();
|
this.state_synced_repos.clear();
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
BackendEvent::RepoUpdates(_) => true,
|
|
||||||
BackendEvent::Synced => true,
|
BackendEvent::Synced => true,
|
||||||
_ => false,
|
_ => false,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -303,7 +303,6 @@ pub struct CommitDiffView {
|
|||||||
loading: bool,
|
loading: bool,
|
||||||
error: Option<SharedString>,
|
error: Option<SharedString>,
|
||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl CommitDiffView {
|
impl CommitDiffView {
|
||||||
@@ -337,7 +336,6 @@ impl CommitDiffView {
|
|||||||
loading: true,
|
loading: true,
|
||||||
error: None,
|
error: None,
|
||||||
pane,
|
pane,
|
||||||
tasks: Vec::new(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,7 +383,7 @@ impl CommitDiffView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ use gpui::{
|
|||||||
use gpui_component::button::{Button, ButtonVariants};
|
use gpui_component::button::{Button, ButtonVariants};
|
||||||
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
use gpui_component::{ActiveTheme, Icon, IconName, IconNamed, Sizable, StyledExt, h_flex, v_flex};
|
||||||
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
use nostr::prelude::{Event, EventId, Kind, PublicKey, Timestamp};
|
||||||
use signed_core::{InboxItem, InboxReadState, RepoAddr};
|
use signed_core::{COVER_NOTE_KIND, InboxItem, InboxReadState, RepoAddr, filters};
|
||||||
use signed_state::{
|
use signed_state::{
|
||||||
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
Backend, BackendEvent, ProfileStore, RefreshGate, RefreshRequest, RepoListStore, query_inbox,
|
||||||
};
|
};
|
||||||
@@ -157,8 +157,21 @@ impl InboxView {
|
|||||||
|
|
||||||
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
fn handle_backend_event(&mut self, event: &BackendEvent, cx: &mut Context<Self>) {
|
||||||
match event {
|
match event {
|
||||||
BackendEvent::RepoUpdates(_) => self.refresh(cx),
|
BackendEvent::NostrUpdate(updates) => {
|
||||||
BackendEvent::Synced => self.refresh(cx),
|
let relevant = updates.iter().any(|update| {
|
||||||
|
let is_notification = filters::NOTIFICATION_KINDS.contains(&update.kind);
|
||||||
|
let is_comment = update.kind == Kind::Comment;
|
||||||
|
let is_event_deletion = update.kind == Kind::EventDeletion;
|
||||||
|
let is_request_to_vanish = update.kind == Kind::RequestToVanish;
|
||||||
|
|
||||||
|
is_notification || is_comment || is_event_deletion || is_request_to_vanish
|
||||||
|
});
|
||||||
|
|
||||||
|
if relevant {
|
||||||
|
self.refresh(cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BackendEvent::Synced | BackendEvent::Published(_) => self.refresh(cx),
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -187,16 +200,12 @@ impl InboxView {
|
|||||||
self.refresh.begin();
|
self.refresh.begin();
|
||||||
|
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let (me, client) = {
|
let Some(me) = backend.read(cx).current_user() else {
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.current_user(), backend.client())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(me) = me else {
|
|
||||||
self.refresh.abort();
|
self.refresh.abort();
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let client = backend.read(cx).client();
|
||||||
let state = self.state.clone();
|
let state = self.state.clone();
|
||||||
|
|
||||||
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
let work = cx.background_spawn(async move { query_inbox(&client, me, &state).await });
|
||||||
@@ -561,6 +570,10 @@ fn sub_activity(event: &Event, me: Option<PublicKey>, cx: &App) -> AnyElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn activity_phrase(kind: Kind) -> &'static str {
|
fn activity_phrase(kind: Kind) -> &'static str {
|
||||||
|
if kind == COVER_NOTE_KIND {
|
||||||
|
return "added a note";
|
||||||
|
}
|
||||||
|
|
||||||
match kind {
|
match kind {
|
||||||
Kind::GitIssue => "opened an issue",
|
Kind::GitIssue => "opened an issue",
|
||||||
Kind::GitPullRequest => "opened a PR",
|
Kind::GitPullRequest => "opened a PR",
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ pub struct PullRequestDetailView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
commit_scroll_handle: VirtualListScrollHandle,
|
commit_scroll_handle: VirtualListScrollHandle,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
/// The dock caches item panels, so without this observer a panel opened
|
||||||
|
/// before the store loaded would stay on its placeholder.
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -123,7 +124,6 @@ impl PullRequestDetailView {
|
|||||||
pane,
|
pane,
|
||||||
commit_item_sizes: Rc::new(Vec::new()),
|
commit_item_sizes: Rc::new(Vec::new()),
|
||||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscription: subscription,
|
_subscription: subscription,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -327,7 +327,7 @@ impl PullRequestDetailView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Open the diff of `commit_id` in the bottom dock of the area.
|
/// Open the diff of `commit_id` in the bottom dock of the area.
|
||||||
|
|||||||
@@ -68,7 +68,6 @@ pub struct NewPullRequestView {
|
|||||||
pane: Entity<DiffPane>,
|
pane: Entity<DiffPane>,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
tasks: Vec<gpui::Task<Result<(), anyhow::Error>>>,
|
|
||||||
_subscriptions: Vec<Subscription>,
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +323,6 @@ impl NewPullRequestView {
|
|||||||
pane,
|
pane,
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes: Rc::new(Vec::new()),
|
item_sizes: Rc::new(Vec::new()),
|
||||||
tasks: Vec::new(),
|
|
||||||
_subscriptions: subscriptions,
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,8 +390,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
task.detach();
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Branches and the current branch are read off the UI thread, then applied.
|
/// Branches and the current branch are read off the UI thread, then applied.
|
||||||
@@ -422,8 +419,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
task.detach();
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn apply_checkout(
|
fn apply_checkout(
|
||||||
@@ -640,8 +636,7 @@ impl NewPullRequestView {
|
|||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
task.detach();
|
||||||
self.tasks.push(task);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
@@ -835,7 +830,7 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
fn submit(&mut self, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
@@ -916,7 +911,7 @@ impl NewPullRequestView {
|
|||||||
Ok(())
|
Ok(())
|
||||||
});
|
});
|
||||||
|
|
||||||
self.tasks.push(task);
|
task.detach();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
fn open_commit_diff(&mut self, commit_id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
|||||||
@@ -215,19 +215,15 @@ pub fn load_user_grasp_servers(
|
|||||||
cx: &mut App,
|
cx: &mut App,
|
||||||
) {
|
) {
|
||||||
let backend = Backend::global(cx);
|
let backend = Backend::global(cx);
|
||||||
let (user, client) = {
|
let Some(user) = backend.read(cx).current_user() else {
|
||||||
let backend = backend.read(cx);
|
|
||||||
(backend.current_user(), backend.client())
|
|
||||||
};
|
|
||||||
|
|
||||||
let Some(user) = user else {
|
|
||||||
state.update(cx, |state, _| state.loading_servers = false);
|
state.update(cx, |state, _| state.loading_servers = false);
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
|
let client = backend.read(cx).client();
|
||||||
let handle = window.window_handle();
|
let handle = window.window_handle();
|
||||||
|
|
||||||
cx.spawn(async move |cx| {
|
cx.spawn(async move |cx| {
|
||||||
let result = signed_state::user_grasp_list_servers(&client, user).await;
|
let result = signed_state::user_grasp_list_servers(client, user).await;
|
||||||
|
|
||||||
let _ = cx.update_window(handle, |_, _window, cx| {
|
let _ = cx.update_window(handle, |_, _window, cx| {
|
||||||
state.update(cx, |state, _| {
|
state.update(cx, |state, _| {
|
||||||
|
|||||||
@@ -79,12 +79,16 @@ impl SidebarPanel {
|
|||||||
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
|
|
||||||
|
if !signer_changed && !signer_required {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if signer_required {
|
if signer_required {
|
||||||
this.banner = pick_banner();
|
this.banner = pick_banner();
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
if this.refresh(cx) || signer_changed {
|
if this.refresh(cx) || signer_required {
|
||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -127,14 +131,16 @@ impl SidebarPanel {
|
|||||||
let user = backend.read(cx).current_user();
|
let user = backend.read(cx).current_user();
|
||||||
|
|
||||||
let (announcements, local_repos, scanning) = {
|
let (announcements, local_repos, scanning) = {
|
||||||
let repo_list = RepoListStore::global(cx).read(cx);
|
let repo_list = RepoListStore::global(cx);
|
||||||
|
let repo_list = repo_list.read(cx);
|
||||||
|
|
||||||
let announcements = user
|
let announcements = user
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map(|user| repo_list.announcements_of(user))
|
.map(|user| repo_list.announcements_of(user))
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
|
|
||||||
let local = LocalReposStore::global(cx).read(cx);
|
let local = LocalReposStore::global(cx);
|
||||||
|
let local = local.read(cx);
|
||||||
let local_repos =
|
let local_repos =
|
||||||
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
resolve_local_repos(&local.repos, &repo_list.announcements, &announcements);
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -14,7 +14,7 @@ product-name = "Signed"
|
|||||||
description = "Nostr Git client for browsing repositories and collaborating"
|
description = "Nostr Git client for browsing repositories and collaborating"
|
||||||
identifier = "info.reya.signed"
|
identifier = "info.reya.signed"
|
||||||
category = "DeveloperTool"
|
category = "DeveloperTool"
|
||||||
version = "0.2.0-alpha"
|
version = "0.1.0-alpha"
|
||||||
out-dir = "../dist"
|
out-dir = "../dist"
|
||||||
before-packaging-command = "cargo build --release"
|
before-packaging-command = "cargo build --release"
|
||||||
resources = ["Cargo.toml", "src"]
|
resources = ["Cargo.toml", "src"]
|
||||||
|
|||||||
Reference in New Issue
Block a user