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
@@ -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();
+62 -25
View File
@@ -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();