fix performance
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use nostr::prelude::*;
|
||||
|
||||
use crate::RepoAddr;
|
||||
@@ -40,8 +42,10 @@ pub fn activity(addr: &RepoAddr) -> Filter {
|
||||
Filter::new().kinds(ACTIVITY_KINDS).coordinate(addr)
|
||||
}
|
||||
|
||||
/// Status events (`1630..=1633`) referencing a specific root event (`#e` tag).
|
||||
pub fn statuses_for(root: EventId) -> Filter {
|
||||
/// Status events (`1630..=1633`) referencing any of the given root events
|
||||
/// (`#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()
|
||||
.kinds([
|
||||
Kind::GitStatusOpen,
|
||||
@@ -49,16 +53,17 @@ pub fn statuses_for(root: EventId) -> Filter {
|
||||
Kind::GitStatusClosed,
|
||||
Kind::GitStatusDraft,
|
||||
])
|
||||
.event(root)
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing a
|
||||
/// specific root event (`#e` tag), fetched per root like comments and
|
||||
/// statuses because they carry no repository `a` tag.
|
||||
pub fn annotations_for(root: EventId) -> Filter {
|
||||
/// Cover notes (kind 1624) and NIP-32 label events (kind 1985) referencing
|
||||
/// any of the given root events (`#e` tag), fetched per root like comments
|
||||
/// and statuses because they carry no repository `a` tag. Batched, like
|
||||
/// [`statuses_for`].
|
||||
pub fn annotations_for(roots: impl IntoIterator<Item = EventId>) -> Filter {
|
||||
Filter::new()
|
||||
.kinds([crate::COVER_NOTE_KIND, Kind::Label])
|
||||
.event(root)
|
||||
.events(roots)
|
||||
}
|
||||
|
||||
/// A user's grasp list (kind `10317`).
|
||||
@@ -109,12 +114,28 @@ pub fn all_announcements() -> Filter {
|
||||
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
|
||||
/// before any other event can be shown.
|
||||
/// A deletion request can only target events created before it, and NIP-34
|
||||
/// 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 {
|
||||
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
|
||||
|
||||
@@ -18,6 +18,7 @@ bitcoin_hashes = "1"
|
||||
|
||||
gpui.workspace = true
|
||||
flume.workspace = true
|
||||
futures.workspace = true
|
||||
anyhow.workspace = true
|
||||
log.workspace = true
|
||||
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
use std::collections::HashMap;
|
||||
use std::collections::hash_map::DefaultHasher;
|
||||
use std::hash::{Hash, Hasher};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::str::FromStr;
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Error, anyhow, bail};
|
||||
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).
|
||||
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)]
|
||||
pub enum BackendEvent {
|
||||
/// User has no signer configured.
|
||||
@@ -84,6 +92,10 @@ pub struct Backend {
|
||||
/// Whether the stored credential is NIP-49 encrypted and a passphrase
|
||||
/// is still needed to resume the session.
|
||||
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>>>,
|
||||
}
|
||||
|
||||
@@ -134,6 +146,7 @@ impl Backend {
|
||||
connected: false,
|
||||
sync_progress: None,
|
||||
passphrase_required: false,
|
||||
recent_fetches: HashMap::new(),
|
||||
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
|
||||
/// fetch its events from them: a one-shot auto-closing subscription for
|
||||
/// `filters`, plus a negentropy sync so issues, patches and PRs stored
|
||||
/// 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
|
||||
/// the pool, so later publishes for this repository also reach them.
|
||||
pub fn connect_repo_relays(
|
||||
@@ -1095,11 +1125,23 @@ impl Backend {
|
||||
filters: Vec<Filter>,
|
||||
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();
|
||||
|
||||
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 {
|
||||
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(())
|
||||
}));
|
||||
@@ -1127,7 +1169,17 @@ impl Backend {
|
||||
/// reconciles the local database with the relays in both directions.
|
||||
/// Emits [`BackendEvent::SyncProgress`] while running (throttled to
|
||||
/// 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>) {
|
||||
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();
|
||||
|
||||
self.sync_progress = Some((0, 0));
|
||||
@@ -1185,6 +1237,8 @@ impl Backend {
|
||||
Err(e) => {
|
||||
this.update(cx, |this, cx| {
|
||||
this.sync_progress = None;
|
||||
// Allow an immediate retry after a failure.
|
||||
this.recent_fetches.remove(&fingerprint);
|
||||
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
|
||||
/// subscription (auto-closing after EOSE) plus a negentropy sync per filter
|
||||
/// 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(());
|
||||
}
|
||||
|
||||
let mut added = false;
|
||||
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()
|
||||
.exit_policy(ReqExitPolicy::ExitOnEOSE)
|
||||
@@ -1332,17 +1405,21 @@ async fn connect_repo_relays_only(
|
||||
.collect();
|
||||
client.subscribe(target).close_on(opts).await?;
|
||||
|
||||
for filter in filters {
|
||||
let sync_opts = SyncOptions::default().initial_timeout(Duration::from_secs(5));
|
||||
if let Err(e) = client
|
||||
.sync(filter)
|
||||
.with(relays.iter())
|
||||
.opts(sync_opts)
|
||||
.await
|
||||
{
|
||||
log::warn!("repo relay negentropy sync failed: {e}");
|
||||
// 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 syncs = filters.into_iter().map(|filter| {
|
||||
let client = &client;
|
||||
let relays = &relays;
|
||||
let sync_opts = sync_opts.clone();
|
||||
async move {
|
||||
if let Err(e) = client.sync(filter).with(relays.iter()).opts(sync_opts).await {
|
||||
log::warn!("repo relay negentropy sync failed: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
futures::future::join_all(syncs).await;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
+121
-47
@@ -1,5 +1,5 @@
|
||||
use std::borrow::Cow;
|
||||
use std::collections::HashSet;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::Error;
|
||||
@@ -33,11 +33,21 @@ pub struct RepoStore {
|
||||
pub pull_requests: Vec<Event>,
|
||||
/// Comments on issues / PRs, oldest first.
|
||||
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
|
||||
/// repository's roots (ngit / GitWorkshop extensions).
|
||||
cover_notes: 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.
|
||||
pub last_error: Option<String>,
|
||||
/// Relays announced by this repository (NIP-34 `relays` tag) that we
|
||||
@@ -111,9 +121,12 @@ impl RepoStore {
|
||||
patches: Vec::new(),
|
||||
pull_requests: 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(),
|
||||
labels: Vec::new(),
|
||||
version: 0,
|
||||
last_error: None,
|
||||
repo_relays: HashSet::new(),
|
||||
root_fetches: HashSet::new(),
|
||||
@@ -141,8 +154,13 @@ impl RepoStore {
|
||||
/// deletions targeting it.
|
||||
fn repo_filters(addr: &RepoAddr) -> Vec<Filter> {
|
||||
let mut filters = vec![
|
||||
filters::announcement(addr),
|
||||
filters::state(addr),
|
||||
// Announcement and state share author and identifier, so they
|
||||
// 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),
|
||||
];
|
||||
// Deletion requests (NIP-09/62) must be known before any event of
|
||||
@@ -293,7 +311,7 @@ impl RepoStore {
|
||||
.chain(&pull_requests)
|
||||
.map(|e| e.id);
|
||||
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) {
|
||||
statuses.push(event);
|
||||
}
|
||||
@@ -312,7 +330,7 @@ impl RepoStore {
|
||||
.chain(&pull_requests)
|
||||
.map(|e| e.id);
|
||||
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) {
|
||||
continue;
|
||||
}
|
||||
@@ -331,13 +349,36 @@ impl RepoStore {
|
||||
sort_newest_first(&mut cover_notes);
|
||||
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>((
|
||||
announcement,
|
||||
state,
|
||||
issues,
|
||||
patches,
|
||||
pull_requests,
|
||||
statuses,
|
||||
status_by_root,
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
@@ -353,7 +394,9 @@ impl RepoStore {
|
||||
issues,
|
||||
patches,
|
||||
pull_requests,
|
||||
statuses,
|
||||
status_by_root,
|
||||
open_issue_count,
|
||||
open_pr_count,
|
||||
comments,
|
||||
cover_notes,
|
||||
labels,
|
||||
@@ -389,9 +432,12 @@ impl RepoStore {
|
||||
this.patches = patches;
|
||||
this.pull_requests = pull_requests;
|
||||
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.labels = labels;
|
||||
this.version = this.version.wrapping_add(1);
|
||||
|
||||
// Comments, statuses without an `a` tag, cover notes and
|
||||
// labels are not addressed to the repository, so fetch them
|
||||
@@ -411,25 +457,18 @@ impl RepoStore {
|
||||
.collect();
|
||||
if !new_roots.is_empty() {
|
||||
this.root_fetches.extend(new_roots.iter().copied());
|
||||
let comment_filters = filters::comments_for(new_roots.clone());
|
||||
let status_filters: Vec<Filter> = new_roots
|
||||
.iter()
|
||||
.copied()
|
||||
.map(filters::statuses_for)
|
||||
.collect();
|
||||
let annotation_filters: Vec<Filter> = new_roots
|
||||
.into_iter()
|
||||
.map(filters::annotations_for)
|
||||
.collect();
|
||||
// Batch the per-root filters: one statuses filter and one
|
||||
// annotations filter covering all new roots, instead of
|
||||
// one filter per root (each filter is a separate
|
||||
// negentropy reconciliation per relay).
|
||||
let mut root_filters = filters::comments_for(new_roots.clone());
|
||||
root_filters.push(filters::statuses_for(new_roots.iter().copied()));
|
||||
root_filters.push(filters::annotations_for(new_roots));
|
||||
let announced: Vec<RelayUrl> = this.repo_relays.iter().cloned().collect();
|
||||
let backend = Backend::global(cx);
|
||||
backend.update(cx, |backend, cx| {
|
||||
backend.subscribe_bootstrap(comment_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced.clone(), comment_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);
|
||||
backend.subscribe_bootstrap(root_filters.clone(), cx);
|
||||
backend.connect_repo_relays(announced, root_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 {
|
||||
let maintainers = self
|
||||
.announcement
|
||||
.as_ref()
|
||||
.map(Announcement::effective_maintainers)
|
||||
.unwrap_or_default();
|
||||
status_of(&self.status_by_root, root)
|
||||
}
|
||||
|
||||
let events = self
|
||||
.statuses
|
||||
.iter()
|
||||
.filter(|e| signed_core::references_root(e, &root.id));
|
||||
|
||||
signed_core::resolve_status(events, &root.pubkey, &maintainers)
|
||||
/// Refresh generation, incremented on every applied refresh. Views use
|
||||
/// it to key their derived-data caches (filtered lists, counts) so
|
||||
/// renders that change nothing stay O(1).
|
||||
pub fn version(&self) -> u64 {
|
||||
self.version
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// [`RepoStatus::Open`] (issues without status events default to open).
|
||||
/// Cached on the last refresh.
|
||||
pub fn issue_count(&self) -> usize {
|
||||
self.issues
|
||||
.iter()
|
||||
.filter(|issue| self.status_of(issue) == RepoStatus::Open)
|
||||
.count()
|
||||
self.open_issue_count
|
||||
}
|
||||
|
||||
/// Number of open pull requests: root PR events (not PR updates, whose
|
||||
/// 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 {
|
||||
self.pull_requests
|
||||
.iter()
|
||||
.filter(|pr| pr.kind == Kind::GitPullRequest && self.status_of(pr) == RepoStatus::Open)
|
||||
.count()
|
||||
self.open_pr_count
|
||||
}
|
||||
|
||||
/// 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)
|
||||
}
|
||||
|
||||
/// 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]) {
|
||||
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use assets::CustomIconName;
|
||||
use dock::{BasePanel, Panel, PanelEvent};
|
||||
use gpui::prelude::*;
|
||||
@@ -27,6 +29,10 @@ pub struct IssueDetailView {
|
||||
issue_id: EventId,
|
||||
/// Input state of the "leave a comment" textarea.
|
||||
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 {
|
||||
@@ -44,6 +50,7 @@ impl IssueDetailView {
|
||||
store,
|
||||
issue_id,
|
||||
comment_input,
|
||||
contents: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -142,6 +149,13 @@ impl IssueDetailView {
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
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()
|
||||
.gap_1()
|
||||
@@ -176,11 +190,7 @@ impl IssueDetailView {
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.child(SharedString::from(comment.content.clone())),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -284,6 +294,11 @@ impl Render for IssueDetailView {
|
||||
let (title, author, picture, status, age, issue_id, content) = {
|
||||
let profile_store = ProfileStore::global(cx);
|
||||
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),
|
||||
@@ -292,7 +307,7 @@ impl Render for IssueDetailView {
|
||||
store.status_of(issue),
|
||||
relative_time(issue.created_at),
|
||||
issue.id,
|
||||
issue.content.clone(),
|
||||
content,
|
||||
)
|
||||
};
|
||||
|
||||
@@ -358,7 +373,7 @@ impl Render for IssueDetailView {
|
||||
.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_form(&issue_id, cx)),
|
||||
|
||||
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
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_state::{ProfileStore, RepoStore};
|
||||
use utils::relative_time;
|
||||
@@ -43,15 +43,12 @@ enum IssueFilter {
|
||||
}
|
||||
|
||||
impl IssueFilter {
|
||||
/// Whether `issue` (of `store`) is included by this filter.
|
||||
fn matches(self, store: &RepoStore, issue: &Event) -> bool {
|
||||
/// Whether an issue with `status` is included by this filter.
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => store.status_of(issue) == RepoStatus::Open,
|
||||
Self::Closed => matches!(
|
||||
store.status_of(issue),
|
||||
RepoStatus::Closed | RepoStatus::Applied
|
||||
),
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -70,9 +67,15 @@ pub struct IssuesView {
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Number of rows [`Self::item_sizes`] was built for (the filtered issue count).
|
||||
issue_len: usize,
|
||||
/// Indices into the store's `issues` matching [`Self::filter`], rebuilt
|
||||
/// every render; the virtual list renders this slice.
|
||||
/// Indices into the store's `issues` matching [`Self::filter`]; the
|
||||
/// virtual list renders this slice. Rebuilt only when the store
|
||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
||||
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.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
@@ -94,6 +97,8 @@ impl IssuesView {
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
issue_len: 0,
|
||||
visible_issues: Vec::new(),
|
||||
counts: (0, 0, 0),
|
||||
cache_key: None,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
@@ -186,19 +191,9 @@ impl IssuesView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let (total, open, closed) =
|
||||
store
|
||||
.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),
|
||||
},
|
||||
);
|
||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
||||
// store version or filter changed, so this is never stale).
|
||||
let (total, open, closed) = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -431,18 +426,30 @@ impl Render for IssuesView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Indices of the issues matching the active filter; the virtual
|
||||
// list renders this filtered slice.
|
||||
self.visible_issues = {
|
||||
// Rebuild the filtered rows and header counts only when the store
|
||||
// refreshed or the filter changed; other renders reuse the cache.
|
||||
let version = self.store.read(cx).version();
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
store
|
||||
let mut counts = (0usize, 0usize, 0usize);
|
||||
self.visible_issues = store
|
||||
.issues
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, issue)| filter.matches(store, issue))
|
||||
.map(|(ix, _)| ix)
|
||||
.collect()
|
||||
};
|
||||
.filter_map(|(ix, issue)| {
|
||||
let status = store.status_of(issue);
|
||||
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();
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ use gpui_component::{
|
||||
ActiveTheme, Colorize, Icon, IconName, Sizable, StyledExt, ThemeStyled,
|
||||
VirtualListScrollHandle, h_flex, v_flex,
|
||||
};
|
||||
use nostr::prelude::{RelayUrl, ToBech32};
|
||||
use nostr::prelude::{EventId, RelayUrl, ToBech32};
|
||||
use signed_core::Announcement;
|
||||
use signed_git::{CommitList, FileCommit};
|
||||
use signed_state::{Backend, GitStore, LocalReposStore, ProfileStore, RepoStore};
|
||||
@@ -98,6 +98,20 @@ struct RepoData {
|
||||
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
|
||||
/// preview (cloned from the announcement's `clone` URLs), and metadata.
|
||||
pub struct RepoDetailView {
|
||||
@@ -170,6 +184,10 @@ pub struct RepoDetailView {
|
||||
/// Bumped on every branch/tag switch; in-flight loads tagged with an
|
||||
/// older generation are discarded when they complete.
|
||||
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
|
||||
/// stays bounded by the number of concurrent loads.
|
||||
tasks: Vec<Task<Result<(), Error>>>,
|
||||
@@ -298,6 +316,7 @@ impl RepoDetailView {
|
||||
tag_select,
|
||||
switching_ref: false,
|
||||
ref_generation: 0,
|
||||
header_cache: None,
|
||||
focus_handle: cx.focus_handle(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
@@ -1193,7 +1212,7 @@ impl RepoDetailView {
|
||||
/// The NIP-34 header (actions, issues/PR counts) or, for a local
|
||||
/// repository that hasn't been published yet, the local header with an
|
||||
/// 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() {
|
||||
return self.render_local_header(cx);
|
||||
}
|
||||
@@ -1202,26 +1221,49 @@ impl RepoDetailView {
|
||||
return div().into_any_element();
|
||||
};
|
||||
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 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 description = announcement.description();
|
||||
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()
|
||||
.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
|
||||
/// NIP-05 identifier when known (npub otherwise), the first announced relay
|
||||
/// as a hint, and the repository identifier.
|
||||
fn nostr_clone_url(announcement: &Announcement, cx: &App) -> SharedString {
|
||||
/// as a hint, and the repository identifier. `nip05` is the owner's
|
||||
/// 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 user = ProfileStore::global(cx)
|
||||
.read(cx)
|
||||
.get(&owner)
|
||||
.metadata()
|
||||
.nip05
|
||||
.as_deref()
|
||||
.filter(|nip05| !nip05.trim().is_empty())
|
||||
let user = nip05
|
||||
.map(str::to_owned)
|
||||
.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::rc::Rc;
|
||||
|
||||
@@ -38,6 +39,10 @@ use crate::image_cache::{MAX_IMAGES, image_cache};
|
||||
/// Width of the changed-files column.
|
||||
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.
|
||||
pub struct PullRequestDetailView {
|
||||
focus_handle: FocusHandle,
|
||||
@@ -77,6 +82,15 @@ pub struct PullRequestDetailView {
|
||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||
/// Virtual list state of the diff rows.
|
||||
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
|
||||
/// stays bounded by the number of concurrent loads.
|
||||
tasks: Vec<Task<Result<(), anyhow::Error>>>,
|
||||
@@ -137,6 +151,9 @@ impl PullRequestDetailView {
|
||||
rows: Vec::new(),
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
commit_item_sizes: Rc::new(Vec::new()),
|
||||
commit_scroll_handle: VirtualListScrollHandle::new(),
|
||||
contents: HashMap::new(),
|
||||
tasks: Vec::new(),
|
||||
_subscriptions: subscriptions,
|
||||
}
|
||||
@@ -262,6 +279,8 @@ impl PullRequestDetailView {
|
||||
this.loading = false;
|
||||
this.worktree = worktree;
|
||||
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;
|
||||
match diff {
|
||||
Ok(diff) => {
|
||||
@@ -798,15 +817,32 @@ impl PullRequestDetailView {
|
||||
}
|
||||
|
||||
v_flex()
|
||||
.relative()
|
||||
.flex_1()
|
||||
.w_full()
|
||||
.min_h_0()
|
||||
.overflow_y_scrollbar()
|
||||
.children(
|
||||
self.commits
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(ix, commit)| self.render_commit_row(ix, commit, cx)),
|
||||
.child(
|
||||
v_virtual_list(
|
||||
cx.entity().clone(),
|
||||
"pr-commits",
|
||||
self.commit_item_sizes.clone(),
|
||||
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()
|
||||
}
|
||||
@@ -825,7 +861,7 @@ impl PullRequestDetailView {
|
||||
h_flex()
|
||||
.id(ix)
|
||||
.px_4()
|
||||
.py_2()
|
||||
.h(px(PR_COMMIT_ROW_HEIGHT))
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.text_sm()
|
||||
@@ -880,6 +916,13 @@ impl PullRequestDetailView {
|
||||
let author = profile.name();
|
||||
let picture = profile.picture();
|
||||
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()
|
||||
.gap_1()
|
||||
@@ -914,11 +957,7 @@ impl PullRequestDetailView {
|
||||
.child(SharedString::from(age)),
|
||||
),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.child(SharedString::from(comment.content.clone())),
|
||||
)
|
||||
.child(div().text_sm().child(content))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ use gpui_component::scroll::Scrollbar;
|
||||
use gpui_component::{
|
||||
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_state::{ProfileStore, RepoStore};
|
||||
use utils::relative_time;
|
||||
@@ -48,14 +48,14 @@ enum PullRequestFilter {
|
||||
}
|
||||
|
||||
impl PullRequestFilter {
|
||||
/// Whether `pr` (of `store`) is included by this filter.
|
||||
fn matches(self, store: &RepoStore, pr: &Event) -> bool {
|
||||
/// Whether a pull request with `status` is included by this filter.
|
||||
fn matches(self, status: RepoStatus) -> bool {
|
||||
match self {
|
||||
Self::All => true,
|
||||
Self::Open => store.status_of(pr) == RepoStatus::Open,
|
||||
Self::Closed => store.status_of(pr) == RepoStatus::Closed,
|
||||
Self::Draft => store.status_of(pr) == RepoStatus::Draft,
|
||||
Self::Merged => store.status_of(pr) == RepoStatus::Applied,
|
||||
Self::Open => status == RepoStatus::Open,
|
||||
Self::Closed => status == RepoStatus::Closed,
|
||||
Self::Draft => status == RepoStatus::Draft,
|
||||
Self::Merged => status == RepoStatus::Applied,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -76,9 +76,15 @@ pub struct PullRequestsView {
|
||||
/// pull request count); rebuilt on change.
|
||||
pr_len: usize,
|
||||
/// Indices into the store's `pull_requests` matching [`Self::filter`]
|
||||
/// (root PR events only; updates are revisions of the root), rebuilt
|
||||
/// every render; the virtual list renders this slice.
|
||||
/// (root PR events only; updates are revisions of the root); the
|
||||
/// virtual list renders this slice. Rebuilt only when the store
|
||||
/// version or the filter changes, keyed by [`Self::cache_key`].
|
||||
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.
|
||||
scroll_handle: VirtualListScrollHandle,
|
||||
}
|
||||
@@ -100,6 +106,8 @@ impl PullRequestsView {
|
||||
item_sizes: Rc::new(Vec::new()),
|
||||
pr_len: 0,
|
||||
visible_prs: Vec::new(),
|
||||
counts: (0, 0, 0, 0, 0),
|
||||
cache_key: None,
|
||||
scroll_handle: VirtualListScrollHandle::new(),
|
||||
}
|
||||
}
|
||||
@@ -202,16 +210,9 @@ impl PullRequestsView {
|
||||
}
|
||||
|
||||
fn render_header(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let store = self.store.read(cx);
|
||||
let (total, open, closed, draft, merged) = store.pull_requests.iter().fold(
|
||||
(0usize, 0usize, 0usize, 0usize, 0usize),
|
||||
|(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),
|
||||
},
|
||||
);
|
||||
// Counts of the last list rebuild (`render` rebuilds first when the
|
||||
// store version or filter changed, so this is never stale).
|
||||
let (total, open, closed, draft, merged) = self.counts;
|
||||
|
||||
h_flex()
|
||||
.px_4()
|
||||
@@ -537,19 +538,31 @@ impl Render for PullRequestsView {
|
||||
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||
let filter = self.filter;
|
||||
|
||||
// Indices of the root pull requests matching the active filter
|
||||
// (updates are revisions of the root and are not listed
|
||||
// separately); the virtual list renders this filtered slice.
|
||||
self.visible_prs = {
|
||||
// Rebuild the filtered rows and header counts only when the store
|
||||
// refreshed or the filter changed; other renders reuse the cache.
|
||||
let version = self.store.read(cx).version();
|
||||
if self.cache_key != Some((version, filter)) {
|
||||
let store = self.store.read(cx);
|
||||
store
|
||||
let mut counts = (0usize, 0usize, 0usize, 0usize, 0usize);
|
||||
self.visible_prs = store
|
||||
.pull_requests
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, pr)| pr.kind == Kind::GitPullRequest && filter.matches(store, pr))
|
||||
.map(|(ix, _)| ix)
|
||||
.collect()
|
||||
};
|
||||
.filter_map(|(ix, pr)| {
|
||||
let status = store.status_of(pr);
|
||||
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();
|
||||
|
||||
|
||||
Reference in New Issue
Block a user