From eda35939821d8b61de0f5ca98d94eb25ff6cadab Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Sun, 30 Aug 2026 10:30:16 +0700 Subject: [PATCH] update repo list --- crates/signed_state/src/lib.rs | 2 +- crates/signed_state/src/repo_list.rs | 58 +++++++- crates/workspace/src/views/repo_list.rs | 183 ++++++++++++++++++------ 3 files changed, 200 insertions(+), 43 deletions(-) diff --git a/crates/signed_state/src/lib.rs b/crates/signed_state/src/lib.rs index ae3690d..2e39ab0 100644 --- a/crates/signed_state/src/lib.rs +++ b/crates/signed_state/src/lib.rs @@ -12,7 +12,7 @@ use gpui::{App, AppContext, Entity}; pub use nostr_sdk::prelude::Timestamp; pub use profile::{Profile, ProfileStore}; pub use repo::RepoStore; -pub use repo_list::RepoListStore; +pub use repo_list::{RepoActivityCounts, RepoListStore}; use signed_nostr::new_backend; pub use utils::shorten_pubkey; diff --git a/crates/signed_state/src/repo_list.rs b/crates/signed_state/src/repo_list.rs index 66cfedd..e547c95 100644 --- a/crates/signed_state/src/repo_list.rs +++ b/crates/signed_state/src/repo_list.rs @@ -20,6 +20,28 @@ struct GlobalRepoListStore(Entity); impl Global for GlobalRepoListStore {} +/// Counts of NIP-34 activity events per repository, used to rank the +/// explore list by popularity. Each patch event is a pushed commit (or a +/// small commit series), which is the closest cross-repository proxy for +/// commit count available from event data alone. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct RepoActivityCounts { + /// Root `30611` issue events addressed to the repository. + pub issues: u32, + /// Root `3063` pull request events addressed to the repository + /// (updates to a PR are not new PRs and don't count). + pub pull_requests: u32, + /// `1617` patch events addressed to the repository. + pub commits: u32, +} + +impl RepoActivityCounts { + /// Total issues + pull requests + commits; the popularity ranking key. + pub fn score(self) -> u32 { + self.issues + self.pull_requests + self.commits + } +} + /// Store listing repository announcements (global discovery or per-author). /// /// The all-repos store (`author: None`) is created at startup by @@ -31,6 +53,9 @@ pub struct RepoListStore { /// Latest known activity timestamp per repository /// (announcements, state updates, patches, PRs, issues, statuses). pub last_activity: Arc>, + /// Issues + pull requests + commits per repository, for the Popular + /// ranking of the explore list. + pub counts: Arc>, author: Option, refreshing: bool, refresh_dirty: bool, @@ -88,6 +113,7 @@ impl RepoListStore { let mut store = Self { announcements: Arc::new(Vec::new()), last_activity: Arc::new(HashMap::new()), + counts: Arc::new(HashMap::new()), author, refreshing: false, refresh_dirty: false, @@ -254,11 +280,38 @@ impl RepoListStore { } } - Ok::<_, Error>((announcements, last_activity)) + // Popularity counts per repository (issues, pull requests and + // patches). Unbounded, unlike the windowed activity query + // above, so totals are exact. + let mut counts: HashMap = HashMap::new(); + let count_filter = + Filter::new().kinds([Kind::GitIssue, Kind::GitPullRequest, Kind::GitPatch]); + for event in client.database().query(count_filter).await? { + if deletions.is_deleted(&event) { + continue; + } + for addr in event.tags.coordinates() { + // Skip events for repos we don't list, so the map can't + // grow beyond the number of announcements. + if addr.kind != Kind::GitRepoAnnouncement || !last_activity.contains_key(&addr) + { + continue; + } + let entry = counts.entry(addr).or_default(); + match event.kind { + Kind::GitIssue => entry.issues += 1, + Kind::GitPullRequest => entry.pull_requests += 1, + Kind::GitPatch => entry.commits += 1, + _ => {} + } + } + } + + Ok::<_, Error>((announcements, last_activity, counts)) }); self.tasks.push(cx.spawn(async move |this, cx| { - let (announcements, last_activity) = match work.await { + let (announcements, last_activity, counts) = match work.await { Ok(results) => results, // Database errors are transient; keep the last list. Err(_) => { @@ -271,6 +324,7 @@ impl RepoListStore { let again = this.update(cx, |this, cx| { this.announcements = Arc::new(announcements); this.last_activity = Arc::new(last_activity); + this.counts = Arc::new(counts); cx.notify(); this.refreshing = false; diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index 2518034..f6a2a1a 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -6,6 +6,7 @@ use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, size, }; +use gpui_base::Button as BaseButton; use gpui_component::avatar::Avatar; use gpui_component::scroll::Scrollbar; use gpui_component::{ @@ -21,13 +22,59 @@ use crate::image_cache::{MAX_IMAGES, image_cache}; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 32. + 64. + 48. + 2. + 6.; -/// Browse all announced repositories (works anonymously). +/// How many of the newest repositories the "Recent" sort shows. +const RECENT_COUNT: usize = 10; + +/// Sort of the explore list, chosen via the header's filter buttons. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +enum RepoFilter { + /// Every repository, newest first (the store's default order). + All, + #[default] + /// Repositories ranked by total issues + pull requests + commits. + Popular, + /// The [`RECENT_COUNT`] newest repositories. + Recent, +} + +impl RepoFilter { + fn visible(self, store: &RepoListStore) -> Vec { + let announcements = &store.announcements; + let mut indices: Vec = (0..announcements.len()).collect(); + + match self { + Self::All => {} + Self::Recent => indices.truncate(RECENT_COUNT), + Self::Popular => { + let counts = &store.counts; + let scores: Vec = announcements + .iter() + .map(|a| counts.get(&a.addr()).map_or(0, |c| c.score())) + .collect(); + indices.sort_by(|&a, &b| scores[b].cmp(&scores[a])); + } + } + + indices + } +} + +/// Browse all announced repositories. pub struct RepoListView { store: Entity, dock_area: WeakEntity, focus_handle: FocusHandle, scroll_handle: VirtualListScrollHandle, + /// Sort selected in the header filter buttons. + filter: RepoFilter, + /// Per-row heights of the virtual list. item_sizes: Rc>>, + /// Number of rows [`Self::item_sizes`] was built for (the filtered repo count). + repo_len: usize, + /// Indices into the store's `announcements` matching [`Self::filter`], + /// in display order; rebuilt when the store changes or the filter is switched. + /// The virtual list renders this slice. + visible: Vec, _subscription: Subscription, } @@ -37,33 +84,51 @@ impl RepoListView { _window: &mut Window, cx: &mut Context, ) -> Self { - // Created at startup by `signed_state::init`: the local database has - // already been queried, so stored repositories appear immediately - // without waiting for relays (which only add to the list). let store = RepoListStore::global(cx); - let subscription = cx.observe(&store, |this, store, cx| { - // Each virtual list row holds `COLUMNS` repo cards. - let rows = store.read(cx).announcements.len().div_ceil(COLUMNS); - - if this.item_sizes.len() != rows { - this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); - } + // Keep the visible slice and row sizes in sync with the store, so + // newly announced repositories appear without waiting for a click. + let subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild_rows(cx); }); - // The store may already hold announcements (it loaded before the - // panel opened), so seed the row sizes right away. - let rows = store.read(cx).announcements.len().div_ceil(COLUMNS); - let item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); - - Self { + let mut this = Self { store, dock_area, focus_handle: cx.focus_handle(), scroll_handle: VirtualListScrollHandle::new(), - item_sizes, + filter: RepoFilter::default(), + item_sizes: Rc::new(Vec::new()), + repo_len: 0, + visible: Vec::new(), _subscription: subscription, + }; + + // Seed the rows right away; the store may already hold announcements + // (it loaded before the panel opened), and the first render must not + // depend on a later store update. + this.rebuild_rows(cx); + + this + } + + /// Rebuild [`Self::visible`] and [`Self::item_sizes`] from the current + /// store contents and [`Self::filter`]. Called when the view is created, + /// when the store changes, and when the filter is switched, + /// so the list is ready before the next render. + fn rebuild_rows(&mut self, cx: &mut Context) { + let filter = self.filter; + let store = self.store.read(cx); + self.visible = filter.visible(store); + + // Each virtual list row holds `COLUMNS` repo cards. + let rows = self.visible.len().div_ceil(COLUMNS); + if self.repo_len != rows { + self.repo_len = rows; + self.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]); } + + cx.notify(); } fn open_repo( @@ -186,6 +251,52 @@ impl RepoListView { })) .into_any_element() } + + /// Header of the explore list: title, the count of visible repositories and the sort filter buttons. + fn render_header(&self, cx: &mut Context) -> AnyElement { + h_flex() + .px_4() + .py_2() + .w_full() + .gap_3() + .items_center() + .child(self.filter_button(RepoFilter::All, "All", cx)) + .child(self.filter_button(RepoFilter::Popular, "Popular", cx)) + .child(self.filter_button(RepoFilter::Recent, "Recent", cx)) + .child(div().flex_1()) + .into_any_element() + } + + /// One segmented filter button of the header, styled like the issues + /// list's status filter buttons. + fn filter_button( + &self, + filter: RepoFilter, + label: &'static str, + cx: &mut Context, + ) -> AnyElement { + BaseButton::new(label) + .flex() + .items_center() + .h_7() + .px_2() + .gap_1() + .child(div().text_sm().child(label)) + .text_color(cx.theme().button_foreground) + .rounded(cx.theme().radius) + .hover(|this| this.bg(cx.theme().button_hover)) + .active(|this| this.bg(cx.theme().primary_active)) + .selected(self.filter == filter) + .when(self.filter == filter, |this| { + this.bg(cx.theme().primary) + .text_color(cx.theme().primary_foreground) + }) + .on_click(cx.listener(move |this, _event, _window, cx| { + this.filter = filter; + this.rebuild_rows(cx); + })) + .into_any_element() + } } impl BasePanel for RepoListView { @@ -210,29 +321,19 @@ impl Focusable for RepoListView { impl Render for RepoListView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let announcements = self.store.read(cx).announcements.clone(); - let last_activity = self.store.read(cx).last_activity.clone(); - let has_announcements = !announcements.is_empty(); - let count = announcements.len(); + let store = self.store.read(cx); + let announcements = store.announcements.clone(); + let last_activity = store.last_activity.clone(); + let count = self.visible.len(); + let has_repos = count > 0; v_flex() .relative() .image_cache(image_cache("repos", MAX_IMAGES)) .size_full() - .child( - h_flex() - .px_4() - .py_2() - .items_center() - .child(div().text_sm().font_semibold().child("Repositories")) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .child(SharedString::from(format!(" ({count})"))), - ), - ) - .when(!has_announcements, |this| { + .pb_3() + .child(self.render_header(cx)) + .when(!has_repos, |this| { this.child( v_flex().size_full().items_center().justify_center().child( div() @@ -242,7 +343,7 @@ impl Render for RepoListView { ), ) }) - .when(has_announcements, |this| { + .when(has_repos, |this| { let view = cx.entity().clone(); let sizes = self.item_sizes.clone(); @@ -254,7 +355,9 @@ impl Render for RepoListView { let mut row_cards = vec![]; for col in 0..COLUMNS { - let ix = row * COLUMNS + col; + let Some(&ix) = this.visible.get(row * COLUMNS + col) else { + break; + }; let Some(announcement) = announcements.get(ix) else { break; }; @@ -268,8 +371,8 @@ impl Render for RepoListView { .w_full() .h_full() .gap_3() - .px_3() - .pb_3() + .px_4() + .pt_4() .children(row_cards) .into_any_element(), );