Author SHA1 Message Date
reya 7a6877ab0b clean up
Rust / build (macos-latest, stable) (push) Canceled after 0s
Rust / build (ubuntu-latest, stable) (push) Canceled after 0s
Rust / build (windows-latest, stable) (push) Canceled after 0s
Rust / build (macos-latest, stable) (pull_request) Canceled after 0s
Rust / build (ubuntu-latest, stable) (pull_request) Canceled after 0s
Rust / build (windows-latest, stable) (pull_request) Canceled after 0s
2026-09-25 20:11:20 +07:00
reya 333989813e sync user own repos 2026-09-25 20:01:28 +07:00
reya 42c1229a90 fix: date repository cards from state events, not announcements
The explore list fell back to the announcement created_at because kind 30618 state events were only fetched per repository, on open. Sync them globally and refresh the list on published state events, so a card shows the last push without the user cloning first.

Open repository panels now also react to state events from relays and to locally published state events.
2026-09-25 17:36:35 +07:00
reya 1d44338037 prepare 2026-09-25 17:34:39 +07:00
23 changed files with 519 additions and 566 deletions
-2
View File
@@ -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".
-7
View File
@@ -8,27 +8,20 @@
- Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise - Show an avatar in each panel's tab, using the repository owner's profile picture when set and a pixel avatar otherwise
- Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default - Add a Tab Bar setting to hide the previous/next tab buttons, hidden by default
- Add an Event Fetching Strategy setting, fetching a repository's activity from its announced relays only (Curated) or from every maintainer's relays as well (Uncensored, the default)
### Changed ### Changed
- 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
View File
@@ -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
-22
View File
@@ -18,16 +18,6 @@ pub enum AppearanceMode {
Dark, Dark,
} }
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EventFetchingStrategy {
/// Only the relays declared in the repository announcement.
Curated,
/// Repository relays plus every maintainer's NIP-65 relays.
#[default]
Uncensored,
}
/// Fields mirror the gpui-component `Theme` surface customized at startup. /// Fields mirror the gpui-component `Theme` surface customized at startup.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(default)] #[serde(default)]
@@ -151,7 +141,6 @@ pub struct CreateRepositorySettings {
#[serde(default)] #[serde(default)]
pub struct Settings { pub struct Settings {
pub appearance: AppearanceMode, pub appearance: AppearanceMode,
pub event_fetching: EventFetchingStrategy,
pub theme: ThemeSettings, pub theme: ThemeSettings,
pub tab_bar: TabBarSettings, pub tab_bar: TabBarSettings,
pub grasp_servers: GraspServersSettings, pub grasp_servers: GraspServersSettings,
@@ -168,7 +157,6 @@ mod tests {
fn json_roundtrip_preserves_everything() { fn json_roundtrip_preserves_everything() {
let settings = Settings { let settings = Settings {
appearance: AppearanceMode::Dark, appearance: AppearanceMode::Dark,
event_fetching: EventFetchingStrategy::Curated,
theme: ThemeSettings { theme: ThemeSettings {
radius: 8.0, radius: 8.0,
..Default::default() ..Default::default()
@@ -190,19 +178,9 @@ mod tests {
serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap(); serde_json::from_str(r#"{"appearance": "dark", "theme": {"radius": 4.0}}"#).unwrap();
assert_eq!(settings.appearance, AppearanceMode::Dark); assert_eq!(settings.appearance, AppearanceMode::Dark);
assert_eq!(settings.theme.radius, 4.0); assert_eq!(settings.theme.radius, 4.0);
// Unset fields fall back to their defaults, including the fetching strategy.
assert_eq!(settings.event_fetching, EventFetchingStrategy::Uncensored);
// The rest of the theme and the other groups keep their defaults. // The rest of the theme and the other groups keep their defaults.
assert_eq!(settings.theme.light_theme, "Signed Light"); assert_eq!(settings.theme.light_theme, "Signed Light");
assert_eq!(settings.grasp_servers, GraspServersSettings::default()); assert_eq!(settings.grasp_servers, GraspServersSettings::default());
assert_eq!(settings.create_repository.default_folder, None); assert_eq!(settings.create_repository.default_folder, None);
} }
#[test]
fn event_fetching_uses_snake_case() {
let json = serde_json::to_string(&EventFetchingStrategy::Curated).unwrap();
assert_eq!(json, r#""curated""#);
let parsed: EventFetchingStrategy = serde_json::from_str(r#""uncensored""#).unwrap();
assert_eq!(parsed, EventFetchingStrategy::Uncensored);
}
} }
+7
View File
@@ -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);
+25 -15
View File
@@ -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),
+5 -2
View File
@@ -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) {
+2
View File
@@ -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,
+124 -149
View File
@@ -21,8 +21,15 @@ 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; 4] = [
"wss://relay.primal.net",
"wss://relay.ditto.pub",
"wss://index.ngit.dev",
"wss://profiles.nostr1.com",
];
/// 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 +42,20 @@ 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>), ///
/// Repository events arrived: announcements, states, activity and deletions. /// Batched: [`Backend`]'s notification pump coalesces everything a
RepoUpdates(Vec<Update>), /// relay delivers within one debounce window into a single event,
/// instead of emitting per-event and making every subscriber debounce
/// the same burst independently.
NostrUpdate(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 +100,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 +123,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 +154,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 +170,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 +230,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 +241,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>(())
@@ -413,6 +446,9 @@ impl Backend {
let client = self.client.clone(); let client = self.client.clone();
// Initialize directly at the user's chosen destination. // Initialize directly at the user's chosen destination.
// No mirror is pre-populated: `GitCache::ensure_clone` lazily clones
// from the grasp server the first time the repo detail view needs it,
// exactly like every other repository.
let destination = { let destination = {
let dir_name = signed_git::sanitize_path_component(&name); let dir_name = signed_git::sanitize_path_component(&name);
let dir_name = if dir_name.is_empty() { let dir_name = if dir_name.is_empty() {
@@ -477,7 +513,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,10 +570,14 @@ 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)
@@ -624,7 +666,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,10 +725,12 @@ 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();
} }
} }
@@ -861,10 +907,14 @@ 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 +1086,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();
} }
@@ -1155,21 +1205,6 @@ impl Backend {
.detach(); .detach();
} }
/// Sync filters through the SDK's NIP-65 gossip targeting.
pub fn sync_auto(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone();
cx.spawn(async move |_this, _cx| {
for filter in filters {
if let Err(e) = client.sync(filter).await {
log::warn!("gossip relay fetch failed: {e}");
}
}
Ok::<(), Error>(())
})
.detach();
}
pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) { pub fn subscribe_bootstrap(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
@@ -1187,8 +1222,7 @@ impl Backend {
.detach(); .detach();
} }
/// Sync several bootstrap filters in order, within a single task. pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
pub fn sync_bootstraps(&mut self, filters: Vec<Filter>, cx: &mut Context<Self>) {
let client = self.client.clone(); let client = self.client.clone();
let (tx, mut rx) = SyncProgress::channel(); let (tx, mut rx) = SyncProgress::channel();
@@ -1225,19 +1259,8 @@ impl Backend {
.detach(); .detach();
let sync = cx.background_spawn(async move { let sync = cx.background_spawn(async move {
let mut first_error = None; let opts = SyncOptions::default().progress(tx);
sync_bootstrap_only(&client, filter, opts).await
for filter in filters {
let opts = SyncOptions::default().progress(tx.clone());
if let Err(error) = sync_bootstrap_only(&client, filter, opts).await {
first_error.get_or_insert(error);
}
}
match first_error {
Some(error) => Err(error),
None => Ok(()),
}
}); });
cx.spawn(async move |this, cx| { cx.spawn(async move |this, cx| {
@@ -1262,6 +1285,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 +1315,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 +1324,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 +1391,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,14 +1414,11 @@ 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)
.opts(opts) .opts(opts)
.await?; .await?;
Ok(output.value) Ok(output.value)
} }
@@ -1516,7 +1491,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
+46 -4
View File
@@ -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
} }
+2 -7
View File
@@ -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 {
+98 -57
View File
@@ -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(); let filter = Filter::new()
.kind(Kind::Metadata)
.authors(batch.drain().collect::<Vec<PublicKey>>());
for chunk in authors.chunks(REQUEST_CHUNK) { // Negentropy-sync with the bootstrap relays.
let opts = SyncOptions::default(); // Synced events are written to the database directly, no NostrUpdate.
let filter = Filter::new() // Re-apply from the database afterwards.
.kind(Kind::Metadata) match sync_bootstrap_only(client, filter, SyncOptions::default()).await {
.authors(chunk.iter().copied()); Ok(_) => {
this.update(cx, |this, cx| this.apply_seen(cx)).ok();
if let Err(e) = sync_bootstrap_only(client, filter, opts).await {
log::warn!("profile fetch failed: {e}");
} }
} Err(e) => log::warn!("profile sync failed: {e}"),
if this.update(cx, |this, cx| this.apply_seen(cx)).is_err() {
return Ok(());
} }
} }
} }
+4
View File
@@ -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;
+134 -209
View File
@@ -4,16 +4,14 @@ 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 signed_core::{ use signed_core::{
Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch, Announcement, Deletions, RepoAddr, RepoStatus, filters, parse_state, pull_request_patch,
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 +19,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 +30,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>,
@@ -84,12 +84,7 @@ pub struct RepoStore {
/// ///
/// The per-root fetches cover NIP-22 comments and statuses without an `a` tag. /// The per-root fetches cover NIP-22 comments and statuses without an `a` tag.
root_fetches: HashSet<EventId>, root_fetches: HashSet<EventId>,
/// Maintainers already synced through gossip in Uncensored mode. refresh: RefreshGate,
///
/// Avoids re-running the maintainer Auto sync on every refresh.
synced_maintainers: HashSet<PublicKey>,
/// In-flight tasks, cancelled when the store drops.
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>,
} }
@@ -137,8 +132,7 @@ impl RepoStore {
cloning: false, cloning: false,
repo_relays: HashSet::new(), repo_relays: HashSet::new(),
root_fetches: HashSet::new(), root_fetches: HashSet::new(),
synced_maintainers: HashSet::new(), refresh: RefreshGate::default(),
tasks: Vec::new(),
_subscription: Some(subscription), _subscription: Some(subscription),
} }
} }
@@ -166,8 +160,7 @@ impl RepoStore {
cloning: false, cloning: false,
repo_relays: HashSet::new(), repo_relays: HashSet::new(),
root_fetches: HashSet::new(), root_fetches: HashSet::new(),
synced_maintainers: HashSet::new(), refresh: RefreshGate::default(),
tasks: Vec::new(),
_subscription: None, _subscription: None,
} }
} }
@@ -208,7 +201,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 +218,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,
}; };
@@ -280,71 +288,9 @@ impl RepoStore {
self.repo_relays.extend(new.iter().cloned()); self.repo_relays.extend(new.iter().cloned());
let backend = Backend::global(cx); let backend = Backend::global(cx);
let filters = Self::repo_filters(&addr);
backend.update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
backend.connect_repo_relays(new, filters, cx); backend.connect_repo_relays(new, Self::repo_filters(&addr), cx);
});
}
/// Filters the SDK resolves through NIP-65 gossip in Uncensored mode.
fn maintainer_filters(addr: &RepoAddr, maintainers: &[PublicKey]) -> Vec<Filter> {
let mut pubkeys = maintainers.to_vec();
// NIP-34 events tag the announcement author,
// which may not be a maintainer for subordinate forks.
if !pubkeys.contains(&addr.public_key) {
pubkeys.push(addr.public_key);
}
vec![
// Announcement and state events, including co-maintainer states.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.authors(pubkeys.clone())
.identifier(addr.identifier.clone()),
// Activity tagging a maintainer, resolved to their read relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr)
.pubkeys(pubkeys.clone()),
// Activity authored by a maintainer, resolved to their write relays.
Filter::new()
.kinds(filters::ACTIVITY_KINDS)
.coordinate(addr)
.authors(pubkeys.clone()),
// Deletions authored by a maintainer.
Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.authors(pubkeys),
]
}
/// In Uncensored mode, sync the maintainer-shaped filters through the SDK's NIP-65 gossip targeting
fn sync_maintainer_relays(&mut self, maintainers: &[PublicKey], cx: &mut Context<Self>) {
let strategy = SettingsStore::try_global(cx)
.map(|store| store.read(cx).settings().event_fetching)
.unwrap_or_default();
if strategy != EventFetchingStrategy::Uncensored {
return;
}
let Some(addr) = self.addr.clone() else {
return;
};
if !maintainers
.iter()
.any(|public_key| !self.synced_maintainers.contains(public_key))
{
return;
}
self.synced_maintainers.extend(maintainers.iter().copied());
let filters = Self::maintainer_filters(&addr, maintainers);
let backend = Backend::global(cx);
backend.update(cx, |backend, cx| {
backend.sync_auto(filters, cx);
}); });
} }
@@ -365,6 +311,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 +324,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 +444,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 +459,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;
@@ -534,6 +488,7 @@ impl RepoStore {
} }
// The announcement may list relays for this repository's activity. // The announcement may list relays for this repository's activity.
// Connect to any we have not fetched from yet.
let relays = this let relays = this
.announcement .announcement
.as_ref() .as_ref()
@@ -542,15 +497,6 @@ impl RepoStore {
this.connect_announced_relays(&relays, cx); this.connect_announced_relays(&relays, cx);
// Uncensored mode also covers the maintainers' NIP-65 relays.
let maintainers = this
.announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
this.sync_maintainer_relays(&maintainers, cx);
if let Some((_, head)) = state { if let Some((_, head)) = state {
this.head = head; this.head = head;
} }
@@ -596,12 +542,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 +685,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 +722,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 +752,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 +790,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 +832,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 +844,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 +866,6 @@ impl RepoStore {
Err(e) => failures.push(format!("{label}: {e}")), Err(e) => failures.push(format!("{label}: {e}")),
} }
} }
(pushed, failures) (pushed, failures)
} }
}) })
@@ -931,6 +882,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 +900,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 +914,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 +979,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 +1034,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 +1062,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 +1079,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 +1094,25 @@ impl RepoStore {
} }
.await; .await;
if let Err(e) = publish_result { match publish_result {
return this.update(cx, |this, cx| { Ok(event) => {
this.last_error = Some(e.to_string()); this.update(cx, |_this, cx| {
cx.notify(); Backend::global(cx).update(cx, |backend, cx| {
}); backend.announce_published(event.clone(), cx)
})
})?;
}
Err(e) => {
return this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
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 +1238,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 +1545,24 @@ impl RepoStore {
} }
.await; .await;
if let Err(e) = publish_result { match publish_result {
this.update(cx, |this, cx| { Ok(event) => {
this.last_error = Some(e.to_string()); this.update(cx, |_this, cx| {
cx.notify(); Backend::global(cx)
})?; .update(cx, |backend, cx| backend.announce_published(event, cx))
})?;
}
Err(e) => {
this.update(cx, |this, cx| {
this.last_error = Some(e.to_string());
cx.notify();
})?;
}
} }
Ok(()) Ok(())
}); });
self.tasks.push(task); task.detach();
} }
} }
@@ -1657,8 +1632,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 +1641,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 +1659,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 +1673,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());
@@ -1750,11 +1733,9 @@ fn comment_builder(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use std::collections::HashSet;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use super::{RepoStore, comment_builder, patch_current_commit}; use super::{comment_builder, patch_current_commit};
#[test] #[test]
fn parses_format_patch_header() { fn parses_format_patch_header() {
@@ -1800,60 +1781,4 @@ mod tests {
// Signed's own `references_root` must keep matching the comment. // Signed's own `references_root` must keep matching the comment.
assert!(signed_core::references_root(&event, &root.id)); assert!(signed_core::references_root(&event, &root.id));
} }
#[test]
fn maintainer_filters_name_owner_and_maintainers() {
let owner = Keys::generate().public_key();
let maintainer = Keys::generate().public_key();
let addr = Coordinate::new(Kind::GitRepoAnnouncement, owner).identifier("my-repo");
// The owner is not among the maintainers, as on a subordinate fork.
let filters = RepoStore::maintainer_filters(&addr, &[maintainer]);
assert_eq!(filters.len(), 4);
let expected = HashSet::from([owner, maintainer]);
// Gossip only resolves pubkeys from `authors` and the lowercase `#p` tag.
let named = |filter: &Filter| -> HashSet<PublicKey> {
let authors = filter.authors.iter().flatten().copied();
let p_tag = filter
.generic_tags
.get(&SingleLetterTag::LOWERCASE_P)
.into_iter()
.flatten()
.filter_map(|value| PublicKey::from_hex(value).ok());
authors.chain(p_tag).collect()
};
// Announcement and state events, scoped to the repository identifier.
let announcement = &filters[0];
assert_eq!(named(announcement), expected);
assert!(
announcement
.generic_tags
.contains_key(&SingleLetterTag::LOWERCASE_D)
);
// Activity filters, scoped to the repository coordinate.
for filter in &filters[1..3] {
assert_eq!(named(filter), expected);
assert!(
filter
.generic_tags
.contains_key(&SingleLetterTag::LOWERCASE_A)
);
}
// Deletions, named by author only.
let deletions = &filters[3];
assert_eq!(
deletions
.authors
.clone()
.unwrap_or_default()
.into_iter()
.collect::<HashSet<_>>(),
expected
);
}
} }
+26 -10
View File
@@ -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,
}; };
@@ -113,15 +134,10 @@ impl RepoListStore {
let backend = Backend::global(cx); let backend = Backend::global(cx);
backend.update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
backend.sync_bootstraps( backend.sync_bootstrap(filters::all_announcements(), cx);
vec![ backend.sync_bootstrap(filters::all_states(), cx);
filters::all_announcements(), // Deletion requests, NIP-09/62, must be known before any announcement is shown.
filters::all_states(), backend.sync_bootstrap(filters::deletions(), cx);
// Deletion requests, NIP-09/62, must be known before any announcement is shown.
filters::deletions(),
],
cx,
);
}); });
} }
@@ -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 {
+22 -9
View File
@@ -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, _| {
+9 -3
View File
@@ -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);
@@ -18,7 +18,7 @@ use gpui_component::{
ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex, ActiveTheme, IconName, IndexPath, Sizable, Theme, ThemeMode, ThemeRegistry, WindowExt, h_flex,
v_flex, v_flex,
}; };
use settings::{AppearanceMode, EventFetchingStrategy, Settings, SettingsStore}; use settings::{AppearanceMode, Settings, SettingsStore};
use signed_ui::{SelectOption, setting_block, setting_row}; use signed_ui::{SelectOption, setting_block, setting_row};
use super::{normalize_server, server_host}; use super::{normalize_server, server_host};
@@ -52,7 +52,6 @@ fn theme_options(cx: &App) -> (Vec<SelectOption>, Vec<SelectOption>) {
/// Created once when the dialog opens, so control state survives re-renders. /// Created once when the dialog opens, so control state survives re-renders.
struct SettingsControls { struct SettingsControls {
appearance: Entity<SelectState<Vec<SelectOption>>>, appearance: Entity<SelectState<Vec<SelectOption>>>,
event_fetching: Entity<SelectState<Vec<SelectOption>>>,
light_theme: Entity<SelectState<Vec<SelectOption>>>, light_theme: Entity<SelectState<Vec<SelectOption>>>,
dark_theme: Entity<SelectState<Vec<SelectOption>>>, dark_theme: Entity<SelectState<Vec<SelectOption>>>,
font_size: Entity<InputState>, font_size: Entity<InputState>,
@@ -90,23 +89,6 @@ impl SettingsControls {
) )
}); });
let event_fetching_options = vec![
SelectOption::new("curated", "Curated"),
SelectOption::new("uncensored", "Uncensored"),
];
let event_fetching_value = match settings.event_fetching {
EventFetchingStrategy::Curated => "curated",
EventFetchingStrategy::Uncensored => "uncensored",
};
let event_fetching = cx.new(|cx| {
SelectState::new(
event_fetching_options.clone(),
selected_index(&event_fetching_options, event_fetching_value),
window,
cx,
)
});
let (light_options, dark_options) = theme_options(cx); let (light_options, dark_options) = theme_options(cx);
let light_theme = cx.new(|cx| { let light_theme = cx.new(|cx| {
SelectState::new( SelectState::new(
@@ -165,19 +147,6 @@ impl SettingsControls {
} }
})); }));
subscriptions.push(cx.subscribe(&event_fetching, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event {
let strategy = match value.as_ref() {
"curated" => EventFetchingStrategy::Curated,
_ => EventFetchingStrategy::Uncensored,
};
let store = SettingsStore::global(cx);
store.update(cx, |store, cx| {
store.edit(|settings| settings.event_fetching = strategy, cx);
});
}
}));
subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| { subscriptions.push(cx.subscribe(&light_theme, |_, event, cx| {
if let SelectEvent::Confirm(Some(value)) = event { if let SelectEvent::Confirm(Some(value)) = event {
let store = SettingsStore::global(cx); let store = SettingsStore::global(cx);
@@ -283,7 +252,6 @@ impl SettingsControls {
Self { Self {
appearance, appearance,
event_fetching,
light_theme, light_theme,
dark_theme, dark_theme,
font_size, font_size,
@@ -326,8 +294,6 @@ fn settings_view(controls: &SettingsControls, cx: &mut App) -> impl IntoElement
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(grasp_servers_section(&settings, controls, cx)) .child(grasp_servers_section(&settings, controls, cx))
.child(Separator::horizontal()) .child(Separator::horizontal())
.child(event_fetching_section(controls, cx))
.child(Separator::horizontal())
.child(repositories_section(&settings, controls, cx)) .child(repositories_section(&settings, controls, cx))
} }
@@ -340,16 +306,6 @@ fn appearance_section(controls: &SettingsControls, cx: &App) -> impl IntoElement
)) ))
} }
/// Which relays repository activity is fetched from.
fn event_fetching_section(controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex().w_full().gap_3().child(setting_row(
cx,
"Event Fetching Strategy",
"Curated fetches events from relays in the repository's announcement. Uncensored also fetches from every maintainer's relays.",
Select::new(&controls.event_fetching).w_full(),
))
}
fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement { fn theme_section(settings: &Settings, controls: &SettingsControls, cx: &App) -> impl IntoElement {
v_flex() v_flex()
.gap_3() .gap_3()
+1 -1
View File
@@ -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"]