fix performance

This commit is contained in:
2026-09-01 07:40:55 +07:00
parent 8dc45d08c0
commit ab2277ee83
11 changed files with 471 additions and 176 deletions
Generated
+1
View File
@@ -7919,6 +7919,7 @@ dependencies = [
"anyhow", "anyhow",
"bitcoin_hashes 1.2.0", "bitcoin_hashes 1.2.0",
"flume 0.11.1", "flume 0.11.1",
"futures",
"gpui", "gpui",
"log", "log",
"nostr", "nostr",
+33 -12
View File
@@ -1,3 +1,5 @@
use std::time::Duration;
use nostr::prelude::*; use nostr::prelude::*;
use crate::RepoAddr; use crate::RepoAddr;
@@ -40,8 +42,10 @@ pub fn activity(addr: &RepoAddr) -> Filter {
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr) Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
} }
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag). /// Status events (`1630..=1633`) referencing any of the given root events
pub fn statuses_for(root: EventId) -> Filter { /// (`#e` tag). Batched: one filter covers all roots, so a negentropy sync
/// reconciles them in a single session instead of one per root.
pub fn statuses_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([ .kinds([
Kind::GitStatusOpen, Kind::GitStatusOpen,
@@ -49,16 +53,17 @@ pub fn statuses_for(root: EventId) -> Filter {
Kind::GitStatusClosed, Kind::GitStatusClosed,
Kind::GitStatusDraft, Kind::GitStatusDraft,
]) ])
.event(root) .events(roots)
} }
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a /// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
/// specific root event (`#e` tag), fetched per root like comments and /// any of the given root events (`#e` tag), fetched per root like comments
/// statuses because they carry no repository `a` tag. /// and statuses because they carry no repository `a` tag. Batched, like
pub fn annotations_for(root: EventId) -> Filter { /// [`statuses_for`].
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
Filter::new() Filter::new()
.kinds([crate::COVER_NOTE_KIND, Kind::Label]) .kinds([crate::COVER_NOTE_KIND, Kind::Label])
.event(root) .events(roots)
} }
/// A user's grasp list (kind `10317`). /// A user's grasp list (kind `10317`).
@@ -109,12 +114,28 @@ pub fn all_announcements() -> Filter {
Filter::new().kind(Kind::GitRepoAnnouncement) Filter::new().kind(Kind::GitRepoAnnouncement)
} }
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`). /// How far back deletion requests are fetched and stored.
/// ///
/// Unbounded, like [`all_announcements`]: deletion requests must be known /// A deletion request can only target events created before it, and NIP-34
/// before any other event can be shown. /// events are all far younger than this window, so older requests can never
/// match anything shown. Bounding the window keeps the kind-5/62 set (one of
/// the largest on public relays) from being fully reconciled on every sync.
const DELETIONS_LOOKBACK: Duration = Duration::from_secs(3 * 365 * 86_400);
/// `now` minus [`DELETIONS_LOOKBACK`], quantized to whole days so identical
/// filters hash the same and the backend's sync dedup can match them.
fn deletions_since() -> Timestamp {
let now = Timestamp::now().as_secs();
Timestamp::from_secs(now - now % 86_400) - DELETIONS_LOOKBACK
}
/// All deletion-related events (NIP-09 kind `5`, NIP-62 kind `62`) within
/// [`DELETIONS_LOOKBACK`]. Deletion requests must be known before any other
/// event can be shown.
pub fn deletions() -> Filter { pub fn deletions() -> Filter {
Filter::new().kinds([Kind::EventDeletion, Kind::RequestToVanish]) Filter::new()
.kinds([Kind::EventDeletion, Kind::RequestToVanish])
.since(deletions_since())
} }
/// Deletion events relevant to a single repository: requests authored by /// Deletion events relevant to a single repository: requests authored by
+1
View File
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
gpui.workspace = true gpui.workspace = true
flume.workspace = true flume.workspace = true
futures.workspace = true
anyhow.workspace = true anyhow.workspace = true
log.workspace = true log.workspace = true
+87 -10
View File
@@ -1,7 +1,9 @@
use std::collections::HashMap; use std::collections::HashMap;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::str::FromStr; use std::str::FromStr;
use std::time::Duration; use std::time::{Duration, Instant};
use anyhow::{Error, anyhow, bail}; use anyhow::{Error, anyhow, bail};
use bitcoin_hashes::sha1::Hash as Sha1Hash; use bitcoin_hashes::sha1::Hash as Sha1Hash;
@@ -32,6 +34,12 @@ pub const BOOTSTRAP_RELAYS: [&str; 4] = [
/// Relays used for indexing user's relay list (NIP-65). /// Relays used for indexing user's relay list (NIP-65).
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"];
/// How long an identical fetch/sync request is suppressed after it started.
/// A second panel for the same repository (or the global and per-author
/// list stores at login) doesn't duplicate a sync that just ran; after the
/// window, re-fetching is allowed again so data stays fresh.
const FETCH_DEDUP_WINDOW: Duration = Duration::from_secs(5 * 60);
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum BackendEvent { pub enum BackendEvent {
/// User has no signer configured. /// User has no signer configured.
@@ -84,6 +92,10 @@ pub struct Backend {
/// Whether the stored credential is NIP-49 encrypted and a passphrase /// Whether the stored credential is NIP-49 encrypted and a passphrase
/// is still needed to resume the session. /// is still needed to resume the session.
passphrase_required: bool, passphrase_required: bool,
/// Fingerprints of recently started fetches/syncs (relay + filter set),
/// so duplicate requests within [`FETCH_DEDUP_WINDOW`] collapse into
/// one. Entries are pruned lazily on the next request.
recent_fetches: HashMap<u64, Instant>,
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
} }
@@ -134,6 +146,7 @@ impl Backend {
connected: false, connected: false,
sync_progress: None, sync_progress: None,
passphrase_required: false, passphrase_required: false,
recent_fetches: HashMap::new(),
tasks: vec![pump], tasks: vec![pump],
}; };
@@ -1082,11 +1095,28 @@ impl Backend {
})); }));
} }
/// Whether an identical fetch was started within [`FETCH_DEDUP_WINDOW`]
/// and is still recent enough to suppress a duplicate. Records the
/// fingerprint (after pruning expired entries) when returning `false`.
fn fetch_recently_started(&mut self, fingerprint: u64) -> bool {
self.recent_fetches
.retain(|_, started| started.elapsed() < FETCH_DEDUP_WINDOW);
if self.recent_fetches.contains_key(&fingerprint) {
return true;
}
self.recent_fetches.insert(fingerprint, Instant::now());
false
}
/// Connect to relays announced by a repository (NIP-34 `relays` tag) and /// Connect to relays announced by a repository (NIP-34 `relays` tag) and
/// fetch its events from them: a one-shot auto-closing subscription for /// fetch its events from them: a one-shot auto-closing subscription for
/// `filters`, plus a negentropy sync so issues, patches and PRs stored /// `filters`, plus a negentropy sync so issues, patches and PRs stored
/// only on those relays are not missed. /// only on those relays are not missed.
/// ///
/// Deduplicated: an identical request (same relays and filters) started
/// within [`FETCH_DEDUP_WINDOW`] is skipped, so a second panel for the
/// same repository doesn't re-run the fetch.
///
/// Best-effort: failures are logged, not surfaced. The relays stay in /// Best-effort: failures are logged, not surfaced. The relays stay in
/// the pool, so later publishes for this repository also reach them. /// the pool, so later publishes for this repository also reach them.
pub fn connect_repo_relays( pub fn connect_repo_relays(
@@ -1095,11 +1125,23 @@ impl Backend {
filters: Vec<Filter>, filters: Vec<Filter>,
cx: &mut Context<Self>, cx: &mut Context<Self>,
) { ) {
let relay_strs: Vec<&str> = relays.iter().map(|url| url.as_str()).collect();
let fingerprint = fetch_fingerprint(&relay_strs, &filters);
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate repo relay fetch");
return;
}
let client = self.client.clone(); let client = self.client.clone();
self.tasks.push(cx.spawn(async move |_this, _cx| { self.tasks.push(cx.spawn(async move |this, cx| {
if let Err(e) = connect_repo_relays_only(&client, relays, filters).await { if let Err(e) = connect_repo_relays_only(&client, relays, filters).await {
log::warn!("repo relay fetch failed: {e}"); log::warn!("repo relay fetch failed: {e}");
// Allow an immediate retry after a failure.
this.update(cx, |this, _cx| {
this.recent_fetches.remove(&fingerprint);
})
.ok();
} }
Ok(()) Ok(())
})); }));
@@ -1127,7 +1169,17 @@ impl Backend {
/// reconciles the local database with the relays in both directions. /// reconciles the local database with the relays in both directions.
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to /// Emits [`BackendEvent::SyncProgress`] while running (throttled to
/// whole-percent changes) and [`BackendEvent::Synced`] on completion. /// whole-percent changes) and [`BackendEvent::Synced`] on completion.
///
/// Deduplicated: an identical sync started within
/// [`FETCH_DEDUP_WINDOW`] is skipped. Observers still see the original
/// sync's progress and completion events.
pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) { pub fn sync_bootstrap(&mut self, filter: Filter, cx: &mut Context<Self>) {
let fingerprint = fetch_fingerprint(&BOOTSTRAP_RELAYS, std::slice::from_ref(&filter));
if self.fetch_recently_started(fingerprint) {
log::debug!("skipping duplicate bootstrap sync");
return;
}
let client = self.client.clone(); let client = self.client.clone();
self.sync_progress = Some((0, 0)); self.sync_progress = Some((0, 0));
@@ -1185,6 +1237,8 @@ impl Backend {
Err(e) => { Err(e) => {
this.update(cx, |this, cx| { this.update(cx, |this, cx| {
this.sync_progress = None; this.sync_progress = None;
// Allow an immediate retry after a failure.
this.recent_fetches.remove(&fingerprint);
cx.emit(BackendEvent::error(e.to_string())) cx.emit(BackendEvent::error(e.to_string()))
})?; })?;
} }
@@ -1303,6 +1357,20 @@ impl Backend {
} }
} }
/// Fingerprint of a relay + filter set, for fetch dedup. Relays and
/// filters are sorted first so the fingerprint is order-independent.
fn fetch_fingerprint(relays: &[&str], filters: &[Filter]) -> u64 {
let mut relays: Vec<&str> = relays.to_vec();
relays.sort_unstable();
let mut filters: Vec<&Filter> = filters.iter().collect();
filters.sort_unstable();
let mut hasher = DefaultHasher::new();
relays.hash(&mut hasher);
filters.hash(&mut hasher);
hasher.finish()
}
/// Add the given relays, connect to them, and fetch the filters: a one-shot /// Add the given relays, connect to them, and fetch the filters: a one-shot
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter /// subscription (auto-closing after EOSE) plus a negentropy sync per filter
/// as a second pass, so events that race with the subscription or relays /// as a second pass, so events that race with the subscription or relays
@@ -1317,10 +1385,15 @@ async fn connect_repo_relays_only(
return Ok(()); return Ok(());
} }
let mut added = false;
for url in &relays { for url in &relays {
client.add_relay(url).await?; added |= client.add_relay(url).await?;
} }
// Connecting is only needed when the pool grew; connected relays no-op,
// but the call still iterates every relay in the pool.
if added {
client.connect().await; client.connect().await;
}
let opts = SubscribeAutoCloseOptions::default() let opts = SubscribeAutoCloseOptions::default()
.exit_policy(ReqExitPolicy::ExitOnEOSE) .exit_policy(ReqExitPolicy::ExitOnEOSE)
@@ -1332,17 +1405,21 @@ async fn connect_repo_relays_only(
.collect(); .collect();
client.subscribe(target).close_on(opts).await?; client.subscribe(target).close_on(opts).await?;
for filter in filters { // Sync the filters concurrently: each reconciles against every relay
// either way, and a relay without NEG-XX support otherwise serializes
// its initial timeout behind every other filter.
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5)); let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
if let Err(e) = client let syncs = filters.into_iter().map(|filter| {
.sync(filter) let client = &client;
.with(relays.iter()) let relays = &relays;
.opts(sync_opts) let sync_opts = sync_opts.clone();
.await async move {
{ if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
log::warn!("repo relay negentropy sync failed: {e}"); log::warn!("repo relay negentropy sync failed: {e}");
} }
} }
});
futures::future::join_all(syncs).await;
Ok(()) Ok(())
} }
+121 -47
View File
@@ -1,5 +1,5 @@
use std::borrow::Cow; use std::borrow::Cow;
use std::collections::HashSet; use std::collections::{HashMap, HashSet};
use std::time::Duration; use std::time::Duration;
use anyhow::Error; use anyhow::Error;
@@ -33,11 +33,21 @@ pub struct RepoStore {
pub pull_requests: Vec<Event>, pub pull_requests: Vec<Event>,
/// Comments on issues / PRs, oldest first. /// Comments on issues / PRs, oldest first.
pub comments: Vec<Event>, pub comments: Vec<Event>,
statuses: Vec<Event>, /// Resolved status per root event (issue / patch / PR), recomputed on
/// every refresh so render paths are HashMap lookups instead of
/// scanning all status events per root.
status_by_root: HashMap<EventId, RepoStatus>,
/// Open issue / root PR counts, computed with [`Self::status_by_root`]
/// on every refresh.
open_issue_count: usize,
open_pr_count: usize,
/// Kind-1624 cover notes and kind-1985 label events referencing this /// Kind-1624 cover notes and kind-1985 label events referencing this
/// repository's roots (ngit / GitWorkshop extensions). /// repository's roots (ngit / GitWorkshop extensions).
cover_notes: Vec<Event>, cover_notes: Vec<Event>,
labels: Vec<Event>, labels: Vec<Event>,
/// Incremented on every applied refresh; views key their derived-data
/// caches to it instead of recomputing on every render.
version: u64,
/// Error of the last action initiated from this store, if any. /// Error of the last action initiated from this store, if any.
pub last_error: Option<String>, pub last_error: Option<String>,
/// Relays announced by this repository (NIP-34 `relays` tag) that we /// Relays announced by this repository (NIP-34 `relays` tag) that we
@@ -111,9 +121,12 @@ impl RepoStore {
patches: Vec::new(), patches: Vec::new(),
pull_requests: Vec::new(), pull_requests: Vec::new(),
comments: Vec::new(), comments: Vec::new(),
statuses: Vec::new(), status_by_root: HashMap::new(),
open_issue_count: 0,
open_pr_count: 0,
cover_notes: Vec::new(), cover_notes: Vec::new(),
labels: Vec::new(), labels: Vec::new(),
version: 0,
last_error: None, last_error: None,
repo_relays: HashSet::new(), repo_relays: HashSet::new(),
root_fetches: HashSet::new(), root_fetches: HashSet::new(),
@@ -141,8 +154,13 @@ impl RepoStore {
/// deletions targeting it. /// deletions targeting it.
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> { fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
let mut filters = vec![ let mut filters = vec![
filters::announcement(addr), // Announcement and state share author and identifier, so they
filters::state(addr), // combine into one filter: one fewer negentropy reconciliation
// per relay when fetching from the repo's announced relays.
Filter::new()
.kinds([Kind::GitRepoAnnouncement, Kind::RepoState])
.author(addr.public_key)
.identifier(addr.identifier.clone()),
filters::activity(addr), filters::activity(addr),
]; ];
// Deletion requests (NIP-09/62) must be known before any event of // Deletion requests (NIP-09/62) must be known before any event of
@@ -293,7 +311,7 @@ impl RepoStore {
.chain(&pull_requests) .chain(&pull_requests)
.map(|e| e.id); .map(|e| e.id);
for root in roots { for root in roots {
for event in db.query(filters::statuses_for(root)).await? { for event in db.query(filters::statuses_for([root])).await? {
if seen_statuses.insert(event.id) { if seen_statuses.insert(event.id) {
statuses.push(event); statuses.push(event);
} }
@@ -312,7 +330,7 @@ impl RepoStore {
.chain(&pull_requests) .chain(&pull_requests)
.map(|e| e.id); .map(|e| e.id);
for root in roots { for root in roots {
for event in db.query(filters::annotations_for(root)).await? { for event in db.query(filters::annotations_for([root])).await? {
if deletions.is_deleted(&event) { if deletions.is_deleted(&event) {
continue; continue;
} }
@@ -331,13 +349,36 @@ impl RepoStore {
sort_newest_first(&mut cover_notes); sort_newest_first(&mut cover_notes);
sort_newest_first(&mut labels); sort_newest_first(&mut labels);
// Resolve every root's status once here; render paths do
// HashMap lookups instead of scanning all status events per
// root (quadratic, with an allocation per pair).
let maintainers = announcement
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
let status_by_root =
resolve_statuses(&issues, &patches, &pull_requests, &statuses, &maintainers);
let open_issue_count = issues
.iter()
.filter(|issue| status_of(&status_by_root, issue) == RepoStatus::Open)
.count();
let open_pr_count = pull_requests
.iter()
.filter(|pr| {
pr.kind == Kind::GitPullRequest
&& status_of(&status_by_root, pr) == RepoStatus::Open
})
.count();
Ok::<_, Error>(( Ok::<_, Error>((
announcement, announcement,
state, state,
issues, issues,
patches, patches,
pull_requests, pull_requests,
statuses, status_by_root,
open_issue_count,
open_pr_count,
comments, comments,
cover_notes, cover_notes,
labels, labels,
@@ -353,7 +394,9 @@ impl RepoStore {
issues, issues,
patches, patches,
pull_requests, pull_requests,
statuses, status_by_root,
open_issue_count,
open_pr_count,
comments, comments,
cover_notes, cover_notes,
labels, labels,
@@ -389,9 +432,12 @@ impl RepoStore {
this.patches = patches; this.patches = patches;
this.pull_requests = pull_requests; this.pull_requests = pull_requests;
this.comments = comments; this.comments = comments;
this.statuses = statuses; this.status_by_root = status_by_root;
this.open_issue_count = open_issue_count;
this.open_pr_count = open_pr_count;
this.cover_notes = cover_notes; this.cover_notes = cover_notes;
this.labels = labels; this.labels = labels;
this.version = this.version.wrapping_add(1);
// Comments, statuses without an `a` tag, cover notes and // Comments, statuses without an `a` tag, cover notes and
// labels are not addressed to the repository, so fetch them // labels are not addressed to the repository, so fetch them
@@ -411,25 +457,18 @@ impl RepoStore {
.collect(); .collect();
if !new_roots.is_empty() { if !new_roots.is_empty() {
this.root_fetches.extend(new_roots.iter().copied()); this.root_fetches.extend(new_roots.iter().copied());
let comment_filters = filters::comments_for(new_roots.clone()); // Batch the per-root filters: one statuses filter and one
let status_filters: Vec<Filter> = new_roots // annotations filter covering all new roots, instead of
.iter() // one filter per root (each filter is a separate
.copied() // negentropy reconciliation per relay).
.map(filters::statuses_for) let mut root_filters = filters::comments_for(new_roots.clone());
.collect(); root_filters.push(filters::statuses_for(new_roots.iter().copied()));
let annotation_filters: Vec<Filter> = new_roots root_filters.push(filters::annotations_for(new_roots));
.into_iter()
.map(filters::annotations_for)
.collect();
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect(); let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
let backend = Backend::global(cx); let backend = Backend::global(cx);
backend.update(cx, |backend, cx| { backend.update(cx, |backend, cx| {
backend.subscribe_bootstrap(comment_filters.clone(), cx); backend.subscribe_bootstrap(root_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), comment_filters, cx); backend.connect_repo_relays(announced, root_filters, cx);
backend.subscribe_bootstrap(status_filters.clone(), cx);
backend.connect_repo_relays(announced.clone(), status_filters, cx);
backend.subscribe_bootstrap(annotation_filters.clone(), cx);
backend.connect_repo_relays(announced, annotation_filters, cx);
}); });
} }
@@ -454,20 +493,17 @@ impl RepoStore {
})); }));
} }
/// Resolve the status of a root event (issue / patch / PR) per NIP-34. /// Resolve the status of a root event (issue / patch / PR) per NIP-34:
/// a lookup into the map built on the last refresh.
pub fn status_of(&self, root: &Event) -> RepoStatus { pub fn status_of(&self, root: &Event) -> RepoStatus {
let maintainers = self status_of(&self.status_by_root, root)
.announcement }
.as_ref()
.map(Announcement::effective_maintainers)
.unwrap_or_default();
let events = self /// Refresh generation, incremented on every applied refresh. Views use
.statuses /// it to key their derived-data caches (filtered lists, counts) so
.iter() /// renders that change nothing stay O(1).
.filter(|e| signed_core::references_root(e, &root.id)); pub fn version(&self) -> u64 {
self.version
signed_core::resolve_status(events, &root.pubkey, &maintainers)
} }
/// The effective cover note of `root` (kind 1624), if any: the latest /// The effective cover note of `root` (kind 1624), if any: the latest
@@ -509,21 +545,16 @@ impl RepoStore {
/// Number of open issues: issues whose resolved status is /// Number of open issues: issues whose resolved status is
/// [`RepoStatus::Open`] (issues without status events default to open). /// [`RepoStatus::Open`] (issues without status events default to open).
/// Cached on the last refresh.
pub fn issue_count(&self) -> usize { pub fn issue_count(&self) -> usize {
self.issues self.open_issue_count
.iter()
.filter(|issue| self.status_of(issue) == RepoStatus::Open)
.count()
} }
/// Number of open pull requests: root PR events (not PR updates, whose /// Number of open pull requests: root PR events (not PR updates, whose
/// status is carried by the root) with a resolved status of /// status is carried by the root) with a resolved status of
/// [`RepoStatus::Open`]. /// [`RepoStatus::Open`]. Cached on the last refresh.
pub fn pull_request_count(&self) -> usize { pub fn pull_request_count(&self) -> usize {
self.pull_requests self.open_pr_count
.iter()
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
.count()
} }
/// Whether `user` is the author (owner) of this repository: the public /// Whether `user` is the author (owner) of this repository: the public
@@ -856,6 +887,49 @@ where
events.into_iter().max_by_key(|e| e.created_at) events.into_iter().max_by_key(|e| e.created_at)
} }
/// Status of `root` from the precomputed map; roots without status events
/// default to [`RepoStatus::Open`], like [`signed_core::resolve_status`].
fn status_of(status_by_root: &HashMap<EventId, RepoStatus>, root: &Event) -> RepoStatus {
status_by_root
.get(&root.id)
.copied()
.unwrap_or(RepoStatus::Open)
}
/// Resolve the status of every root event in one pass: status events are
/// indexed by the root they reference (`e`/`E` tag), then each root
/// resolves against its own slice. O(roots + statuses) instead of the
/// O(roots × statuses) of resolving per root on demand.
fn resolve_statuses(
issues: &[Event],
patches: &[Event],
pull_requests: &[Event],
statuses: &[Event],
maintainers: &[PublicKey],
) -> HashMap<EventId, RepoStatus> {
let mut by_root: HashMap<EventId, Vec<&Event>> = HashMap::new();
for event in statuses {
for tag in event.tags.iter() {
if matches!(tag.kind(), "e" | "E")
&& let Some(id) = tag.content().and_then(|hex| EventId::from_hex(hex).ok())
{
by_root.entry(id).or_default().push(event);
}
}
}
issues
.iter()
.chain(patches)
.chain(pull_requests)
.map(|root| {
let events = by_root.get(&root.id).map(Vec::as_slice).unwrap_or(&[]);
let status = signed_core::resolve_status(events.iter().copied(), &root.pubkey, maintainers);
(root.id, status)
})
.collect()
}
fn sort_newest_first(events: &mut [Event]) { fn sort_newest_first(events: &mut [Event]) {
events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
} }
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use assets::CustomIconName; use assets::CustomIconName;
use dock::{BasePanel, Panel, PanelEvent}; use dock::{BasePanel, Panel, PanelEvent};
use gpui::prelude::*; use gpui::prelude::*;
@@ -27,6 +29,10 @@ pub struct IssueDetailView {
issue_id: EventId, issue_id: EventId,
/// Input state of the "leave a comment" textarea. /// Input state of the "leave a comment" textarea.
comment_input: Entity<TextareaState>, comment_input: Entity<TextareaState>,
/// Issue/comment bodies as shared strings, keyed by event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
} }
impl IssueDetailView { impl IssueDetailView {
@@ -44,6 +50,7 @@ impl IssueDetailView {
store, store,
issue_id, issue_id,
comment_input, comment_input,
contents: HashMap::new(),
} }
} }
@@ -142,6 +149,13 @@ impl IssueDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex() v_flex()
.gap_1() .gap_1()
@@ -176,11 +190,7 @@ impl IssueDetailView {
.child(SharedString::from(age)), .child(SharedString::from(age)),
), ),
) )
.child( .child(div().text_sm().child(content))
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
})) }))
.into_any_element() .into_any_element()
} }
@@ -284,6 +294,11 @@ impl Render for IssueDetailView {
let (title, author, picture, status, age, issue_id, content) = { let (title, author, picture, status, age, issue_id, content) = {
let profile_store = ProfileStore::global(cx); let profile_store = ProfileStore::global(cx);
let profile = profile_store.read(cx).get(&issue.pubkey); let profile = profile_store.read(cx).get(&issue.pubkey);
let content = self
.contents
.entry(issue.id)
.or_insert_with(|| SharedString::from(issue.content.clone()))
.clone();
( (
activity_subject(issue), activity_subject(issue),
@@ -292,7 +307,7 @@ impl Render for IssueDetailView {
store.status_of(issue), store.status_of(issue),
relative_time(issue.created_at), relative_time(issue.created_at),
issue.id, issue.id,
issue.content.clone(), content,
) )
}; };
@@ -358,7 +373,7 @@ impl Render for IssueDetailView {
.child(SharedString::from(age)), .child(SharedString::from(age)),
), ),
) )
.child(div().text_sm().child(SharedString::from(&content))), .child(div().text_sm().child(content)),
) )
.child(self.render_comments(&issue_id, cx)) .child(self.render_comments(&issue_id, cx))
.child(self.render_form(&issue_id, cx)), .child(self.render_form(&issue_id, cx)),
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
}; };
use nostr::prelude::{Event, EventId}; use nostr::prelude::EventId;
use signed_core::{RepoStatus, activity_subject}; use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore}; use signed_state::{ProfileStore, RepoStore};
use utils::relative_time; use utils::relative_time;
@@ -43,15 +43,12 @@ enum IssueFilter {
} }
impl IssueFilter { impl IssueFilter {
/// Whether `issue` (of `store`) is included by this filter. /// Whether an issue with `status` is included by this filter.
fn matches(self, store: &RepoStore, issue: &Event) -> bool { fn matches(self, status: RepoStatus) -> bool {
match self { match self {
Self::All => true, Self::All => true,
Self::Open => store.status_of(issue) == RepoStatus::Open, Self::Open => status == RepoStatus::Open,
Self::Closed => matches!( Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
store.status_of(issue),
RepoStatus::Closed | RepoStatus::Applied
),
} }
} }
} }
@@ -70,9 +67,15 @@ pub struct IssuesView {
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count). /// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
issue_len: usize, issue_len: usize,
/// Indices into the store's `issues` matching [`Self::filter`], rebuilt /// Indices into the store's `issues` matching [`Self::filter`]; the
/// every render; the virtual list renders this slice. /// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_issues: Vec<usize>, visible_issues: Vec<usize>,
/// Header counts `(total, open, closed)`, rebuilt with
/// [`Self::visible_issues`].
counts: (usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, IssueFilter)>,
/// Virtual list state of the issues list. /// Virtual list state of the issues list.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
} }
@@ -94,6 +97,8 @@ impl IssuesView {
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
issue_len: 0, issue_len: 0,
visible_issues: Vec::new(), visible_issues: Vec::new(),
counts: (0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
} }
} }
@@ -186,19 +191,9 @@ impl IssuesView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); // Counts of the last list rebuild (`render` rebuilds first when the
let (total, open, closed) = // store version or filter changed, so this is never stale).
store let (total, open, closed) = self.counts;
.issues
.iter()
.fold(
(0usize, 0usize, 0usize),
|(total, open, closed), issue| match store.status_of(issue) {
RepoStatus::Open => (total + 1, open + 1, closed),
RepoStatus::Closed => (total + 1, open, closed + 1),
RepoStatus::Draft | RepoStatus::Applied => (total + 1, open, closed),
},
);
h_flex() h_flex()
.px_4() .px_4()
@@ -431,18 +426,30 @@ impl Render for IssuesView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Indices of the issues matching the active filter; the virtual // Rebuild the filtered rows and header counts only when the store
// list renders this filtered slice. // refreshed or the filter changed; other renders reuse the cache.
self.visible_issues = { let version = self.store.read(cx).version();
if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
store let mut counts = (0usize, 0usize, 0usize);
self.visible_issues = store
.issues .issues
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, issue)| filter.matches(store, issue)) .filter_map(|(ix, issue)| {
.map(|(ix, _)| ix) let status = store.status_of(issue);
.collect() counts.0 += 1;
}; match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft | RepoStatus::Applied => {}
}
filter.matches(status).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_issues.len(); let count = self.visible_issues.len();
+62 -25
View File
@@ -27,7 +27,7 @@ use gpui_component::{
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled, ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
VirtualListScrollHandle, h_flex, v_flex, VirtualListScrollHandle, h_flex, v_flex,
}; };
use nostr::prelude::{RelayUrl, ToBech32}; use nostr::prelude::{EventId, RelayUrl, ToBech32};
use signed_core::Announcement; use signed_core::Announcement;
use signed_git::{CommitList, FileCommit}; use signed_git::{CommitList, FileCommit};
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore}; use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
@@ -98,6 +98,20 @@ struct RepoData {
head_commit: Option<FileCommit>, head_commit: Option<FileCommit>,
} }
/// Derived NIP-34 header data, cached so renders don't re-encode bech32
/// share targets and rebuild clone command strings on every frame.
struct HeaderCache {
/// Announcement event ID and owner NIP-05 this cache was built from;
/// rebuilt when either changes (a new announcement version, or the
/// owner's profile arriving with a NIP-05 identifier).
key: (EventId, Option<String>),
announcement: Rc<Announcement>,
share: Rc<ShareTargets>,
ngit_command: SharedString,
nak_command: SharedString,
git_commands: Rc<Vec<SharedString>>,
}
/// Detail view of a repository: header, stats, a file explorer with README /// Detail view of a repository: header, stats, a file explorer with README
/// preview (cloned from the announcement's `clone` URLs), and metadata. /// preview (cloned from the announcement's `clone` URLs), and metadata.
pub struct RepoDetailView { pub struct RepoDetailView {
@@ -170,6 +184,10 @@ pub struct RepoDetailView {
/// Bumped on every branch/tag switch; in-flight loads tagged with an /// Bumped on every branch/tag switch; in-flight loads tagged with an
/// older generation are discarded when they complete. /// older generation are discarded when they complete.
ref_generation: u64, ref_generation: u64,
/// Derived NIP-34 header data (share targets, clone commands),
/// rebuilt only when the announcement or the owner's NIP-05 changes
/// instead of on every render.
header_cache: Option<HeaderCache>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec /// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads. /// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), Error>>>, tasks: Vec<Task<Result<(), Error>>>,
@@ -298,6 +316,7 @@ impl RepoDetailView {
tag_select, tag_select,
switching_ref: false, switching_ref: false,
ref_generation: 0, ref_generation: 0,
header_cache: None,
focus_handle: cx.focus_handle(), focus_handle: cx.focus_handle(),
tasks: Vec::new(), tasks: Vec::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
@@ -1193,7 +1212,7 @@ impl RepoDetailView {
/// The NIP-34 header (actions, issues/PR counts) or, for a local /// The NIP-34 header (actions, issues/PR counts) or, for a local
/// repository that hasn't been published yet, the local header with an /// repository that hasn't been published yet, the local header with an
/// Init button. /// Init button.
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&mut self, cx: &mut Context<Self>) -> AnyElement {
if self.local_path.is_some() { if self.local_path.is_some() {
return self.render_local_header(cx); return self.render_local_header(cx);
} }
@@ -1202,26 +1221,49 @@ impl RepoDetailView {
return div().into_any_element(); return div().into_any_element();
}; };
let store = store_entity.read(cx); let store = store_entity.read(cx);
let Some(announcement) = store
.announcement
.as_ref()
.or(self.initial.as_ref())
.cloned()
else {
return div().into_any_element();
};
let issue_count = SharedString::from(store.issue_count().to_string()); let issue_count = SharedString::from(store.issue_count().to_string());
let pr_count = SharedString::from(store.pull_request_count().to_string()); let pr_count = SharedString::from(store.pull_request_count().to_string());
let Some(source) = store.announcement.as_ref().or(self.initial.as_ref()) else {
return div().into_any_element();
};
// The header derives bech32 share targets and clone command strings
// from the announcement; rebuild them only when the announcement or
// the owner's NIP-05 changes, not on every render.
let nip05 = ProfileStore::global(cx)
.read(cx)
.get(&source.owner)
.metadata()
.nip05
.clone()
.filter(|nip05| !nip05.trim().is_empty());
let key = (source.event_id, nip05);
if self.header_cache.as_ref().is_none_or(|cache| cache.key != key) {
let announcement = source.clone();
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, key.1.as_deref());
self.header_cache = Some(HeaderCache {
ngit_command: SharedString::from(format!("git clone {nostr_url}")),
nak_command: SharedString::from(format!("nak git clone {nostr_url}")),
git_commands: Rc::new(announcement.clone_urls()),
share: Rc::new(share),
announcement: Rc::new(announcement),
key,
});
}
let cache = self.header_cache.as_ref().expect("cache just built");
let announcement = cache.announcement.clone();
let share = cache.share.clone();
let ngit_command = cache.ngit_command.clone();
let nak_command = cache.nak_command.clone();
let git_commands = cache.git_commands.clone();
let name = self.display_name(cx); let name = self.display_name(cx);
let description = announcement.description(); let description = announcement.description();
let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id));
let share = ShareTargets::from_announcement(&announcement);
let nostr_url = nostr_clone_url(&announcement, cx);
let ngit_command = SharedString::from(format!("git clone {nostr_url}"));
let nak_command = SharedString::from(format!("nak git clone {nostr_url}"));
let git_commands = announcement.clone_urls();
v_flex() v_flex()
.on_action( .on_action(
@@ -1951,16 +1993,11 @@ fn load_repo_data(repo: &Repository) -> Result<RepoData, Error> {
/// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a /// The `nostr://...` clone URL of an announcement (NIP-34): the owner as a
/// NIP-05 identifier when known (npub otherwise), the first announced relay /// NIP-05 identifier when known (npub otherwise), the first announced relay
/// as a hint, and the repository identifier. /// as a hint, and the repository identifier. `nip05` is the owner's
fn nostr_clone_url(announcement: &Announcement, cx: &App) -> SharedString { /// NIP-05 identifier from the profile store, already blank-filtered.
fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString {
let owner = announcement.owner; let owner = announcement.owner;
let user = ProfileStore::global(cx) let user = nip05
.read(cx)
.get(&owner)
.metadata()
.nip05
.as_deref()
.filter(|nip05| !nip05.trim().is_empty())
.map(str::to_owned) .map(str::to_owned)
.unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex())); .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_hex()));
@@ -1,3 +1,4 @@
use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
use std::rc::Rc; use std::rc::Rc;
@@ -38,6 +39,10 @@ use crate::image_cache::{MAX_IMAGES, image_cache};
/// Width of the changed-files column. /// Width of the changed-files column.
const TREE_WIDTH: f32 = 260.; const TREE_WIDTH: f32 = 260.;
/// Height of one commit row in the commits tab's virtual list: a single
/// text line plus the 1px bottom border.
const PR_COMMIT_ROW_HEIGHT: f32 = 37.;
/// Detail panel of a single pull request. /// Detail panel of a single pull request.
pub struct PullRequestDetailView { pub struct PullRequestDetailView {
focus_handle: FocusHandle, focus_handle: FocusHandle,
@@ -77,6 +82,15 @@ pub struct PullRequestDetailView {
item_sizes: Rc<Vec<Size<Pixels>>>, item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the diff rows. /// Virtual list state of the diff rows.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
/// Per-row heights of the commits tab's virtual list, built when the
/// patch series is loaded.
commit_item_sizes: Rc<Vec<Size<Pixels>>>,
/// Virtual list state of the commits tab.
commit_scroll_handle: VirtualListScrollHandle,
/// Comment bodies as shared strings, keyed by comment event ID, so
/// re-renders don't clone full contents again (events are immutable,
/// so the cache never needs invalidation).
contents: HashMap<EventId, SharedString>,
/// In-flight tasks; finished tasks are pruned on every push, so the vec /// In-flight tasks; finished tasks are pruned on every push, so the vec
/// stays bounded by the number of concurrent loads. /// stays bounded by the number of concurrent loads.
tasks: Vec<Task<Result<(), anyhow::Error>>>, tasks: Vec<Task<Result<(), anyhow::Error>>>,
@@ -137,6 +151,9 @@ impl PullRequestDetailView {
rows: Vec::new(), rows: Vec::new(),
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
commit_item_sizes: Rc::new(Vec::new()),
commit_scroll_handle: VirtualListScrollHandle::new(),
contents: HashMap::new(),
tasks: Vec::new(), tasks: Vec::new(),
_subscriptions: subscriptions, _subscriptions: subscriptions,
} }
@@ -262,6 +279,8 @@ impl PullRequestDetailView {
this.loading = false; this.loading = false;
this.worktree = worktree; this.worktree = worktree;
this.current_commit = current_commit.map(SharedString::from); this.current_commit = current_commit.map(SharedString::from);
this.commit_item_sizes =
Rc::new(vec![size(px(0.), px(PR_COMMIT_ROW_HEIGHT)); commits.len()]);
this.commits = commits; this.commits = commits;
match diff { match diff {
Ok(diff) => { Ok(diff) => {
@@ -798,15 +817,32 @@ impl PullRequestDetailView {
} }
v_flex() v_flex()
.relative()
.flex_1() .flex_1()
.w_full() .w_full()
.min_h_0() .min_h_0()
.overflow_y_scrollbar() .child(
.children( v_virtual_list(
self.commits cx.entity().clone(),
.iter() "pr-commits",
.enumerate() self.commit_item_sizes.clone(),
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)), move |this, range, _window, cx| {
range
.map(|ix| this.render_commit_row(ix, &this.commits[ix], cx))
.collect()
},
)
.track_scroll(&self.commit_scroll_handle)
.size_full(),
)
.child(
div()
.absolute()
.top_0()
.left_0()
.right_0()
.bottom_0()
.child(Scrollbar::vertical(&self.commit_scroll_handle)),
) )
.into_any_element() .into_any_element()
} }
@@ -825,7 +861,7 @@ impl PullRequestDetailView {
h_flex() h_flex()
.id(ix) .id(ix)
.px_4() .px_4()
.py_2() .h(px(PR_COMMIT_ROW_HEIGHT))
.gap_2() .gap_2()
.items_center() .items_center()
.text_sm() .text_sm()
@@ -880,6 +916,13 @@ impl PullRequestDetailView {
let author = profile.name(); let author = profile.name();
let picture = profile.picture(); let picture = profile.picture();
let age = relative_time(comment.created_at); let age = relative_time(comment.created_at);
// Comment bodies are cloned into shared strings once per
// comment, not on every render.
let content = self
.contents
.entry(comment.id)
.or_insert_with(|| SharedString::from(comment.content.clone()))
.clone();
v_flex() v_flex()
.gap_1() .gap_1()
@@ -914,11 +957,7 @@ impl PullRequestDetailView {
.child(SharedString::from(age)), .child(SharedString::from(age)),
), ),
) )
.child( .child(div().text_sm().child(content))
div()
.text_sm()
.child(SharedString::from(comment.content.clone())),
)
})) }))
.into_any_element() .into_any_element()
} }
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
use gpui_component::{ use gpui_component::{
ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list, ActiveTheme, Icon, Sizable, VirtualListScrollHandle, WindowExt, h_flex, v_flex, v_virtual_list,
}; };
use nostr::prelude::{Event, EventId, Kind}; use nostr::prelude::{EventId, Kind};
use signed_core::{RepoStatus, activity_subject}; use signed_core::{RepoStatus, activity_subject};
use signed_state::{ProfileStore, RepoStore}; use signed_state::{ProfileStore, RepoStore};
use utils::relative_time; use utils::relative_time;
@@ -48,14 +48,14 @@ enum PullRequestFilter {
} }
impl PullRequestFilter { impl PullRequestFilter {
/// Whether `pr` (of `store`) is included by this filter. /// Whether a pull request with `status` is included by this filter.
fn matches(self, store: &RepoStore, pr: &Event) -> bool { fn matches(self, status: RepoStatus) -> bool {
match self { match self {
Self::All => true, Self::All => true,
Self::Open => store.status_of(pr) == RepoStatus::Open, Self::Open => status == RepoStatus::Open,
Self::Closed => store.status_of(pr) == RepoStatus::Closed, Self::Closed => status == RepoStatus::Closed,
Self::Draft => store.status_of(pr) == RepoStatus::Draft, Self::Draft => status == RepoStatus::Draft,
Self::Merged => store.status_of(pr) == RepoStatus::Applied, Self::Merged => status == RepoStatus::Applied,
} }
} }
} }
@@ -76,9 +76,15 @@ pub struct PullRequestsView {
/// pull request count); rebuilt on change. /// pull request count); rebuilt on change.
pr_len: usize, pr_len: usize,
/// Indices into the store's `pull_requests` matching [`Self::filter`] /// Indices into the store's `pull_requests` matching [`Self::filter`]
/// (root PR events only; updates are revisions of the root), rebuilt /// (root PR events only; updates are revisions of the root); the
/// every render; the virtual list renders this slice. /// virtual list renders this slice. Rebuilt only when the store
/// version or the filter changes, keyed by [`Self::cache_key`].
visible_prs: Vec<usize>, visible_prs: Vec<usize>,
/// Header counts `(total, open, closed, draft, merged)`, rebuilt with
/// [`Self::visible_prs`].
counts: (usize, usize, usize, usize, usize),
/// Store version and filter the cached rows/counts were built from.
cache_key: Option<(u64, PullRequestFilter)>,
/// Virtual list state of the pull requests list. /// Virtual list state of the pull requests list.
scroll_handle: VirtualListScrollHandle, scroll_handle: VirtualListScrollHandle,
} }
@@ -100,6 +106,8 @@ impl PullRequestsView {
item_sizes: Rc::new(Vec::new()), item_sizes: Rc::new(Vec::new()),
pr_len: 0, pr_len: 0,
visible_prs: Vec::new(), visible_prs: Vec::new(),
counts: (0, 0, 0, 0, 0),
cache_key: None,
scroll_handle: VirtualListScrollHandle::new(), scroll_handle: VirtualListScrollHandle::new(),
} }
} }
@@ -202,16 +210,9 @@ impl PullRequestsView {
} }
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement { fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
let store = self.store.read(cx); // Counts of the last list rebuild (`render` rebuilds first when the
let (total, open, closed, draft, merged) = store.pull_requests.iter().fold( // store version or filter changed, so this is never stale).
(0usize, 0usize, 0usize, 0usize, 0usize), let (total, open, closed, draft, merged) = self.counts;
|(total, open, closed, draft, merged), pr| match store.status_of(pr) {
RepoStatus::Open => (total + 1, open + 1, closed, draft, merged),
RepoStatus::Closed => (total + 1, open, closed + 1, draft, merged),
RepoStatus::Draft => (total + 1, open, closed, draft + 1, merged),
RepoStatus::Applied => (total + 1, open, closed, draft, merged + 1),
},
);
h_flex() h_flex()
.px_4() .px_4()
@@ -537,19 +538,31 @@ impl Render for PullRequestsView {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement { fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
let filter = self.filter; let filter = self.filter;
// Indices of the root pull requests matching the active filter // Rebuild the filtered rows and header counts only when the store
// (updates are revisions of the root and are not listed // refreshed or the filter changed; other renders reuse the cache.
// separately); the virtual list renders this filtered slice. let version = self.store.read(cx).version();
self.visible_prs = { if self.cache_key != Some((version, filter)) {
let store = self.store.read(cx); let store = self.store.read(cx);
store let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
self.visible_prs = store
.pull_requests .pull_requests
.iter() .iter()
.enumerate() .enumerate()
.filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr)) .filter_map(|(ix, pr)| {
.map(|(ix, _)| ix) let status = store.status_of(pr);
.collect() counts.0 += 1;
}; match status {
RepoStatus::Open => counts.1 += 1,
RepoStatus::Closed => counts.2 += 1,
RepoStatus::Draft => counts.3 += 1,
RepoStatus::Applied => counts.4 += 1,
}
(pr.kind == Kind::GitPullRequest && filter.matches(status)).then_some(ix)
})
.collect();
self.counts = counts;
self.cache_key = Some((version, filter));
}
let count = self.visible_prs.len(); let count = self.visible_prs.len();
+10
View File
@@ -7,3 +7,13 @@
## Create repository dialog ## Create repository dialog
- [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop). - [ ] Remember the folder picked in the create-repository dialog and default to it next time (currently defaults to Desktop).
## Performance: render path
- [ ] Virtualize issue/PR comment threads (`issue_detail.rs::render_comments`, `pull_request_detail.rs::render_comments`). Harder than the list tabs: comment cards have variable heights and live inside a scrolling page together with the body and the comment form, so this needs either measured item sizes or restructuring the whole discussion tab into one virtual list. (Comment bodies are already cached as `SharedString`, so re-renders are cheap element constructions, not byte copies.)
## Performance: relay/subscription behavior
- [ ] Narrow `RepoStore`'s `BackendEvent::NostrUpdate` relevance filter (`crates/signed_state/src/repo.rs:65-98`): any comment/status/label/deletion from anywhere wakes every open repo store; match only events referencing this repo's roots or coordinate.
- [ ] Reconsider `ban_relay_on_mismatch(true)` (`crates/signed_nostr/src/backend.rs:49`): combined with many short-lived auto-close subscriptions, a late event after EOSE can permanently ban a relay for the session.
- [ ] Relays added for a repo stay in the pool forever and grow unboundedly (`crates/signed_state/src/backend.rs`); consider removing repo relays when the last panel for that repo closes.