update repo list
This commit is contained in:
@@ -12,7 +12,7 @@ use gpui::{App, AppContext, Entity};
|
|||||||
pub use nostr_sdk::prelude::Timestamp;
|
pub use nostr_sdk::prelude::Timestamp;
|
||||||
pub use profile::{Profile, ProfileStore};
|
pub use profile::{Profile, ProfileStore};
|
||||||
pub use repo::RepoStore;
|
pub use repo::RepoStore;
|
||||||
pub use repo_list::RepoListStore;
|
pub use repo_list::{RepoActivityCounts, RepoListStore};
|
||||||
use signed_nostr::new_backend;
|
use signed_nostr::new_backend;
|
||||||
pub use utils::shorten_pubkey;
|
pub use utils::shorten_pubkey;
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ struct GlobalRepoListStore(Entity<RepoListStore>);
|
|||||||
|
|
||||||
impl Global for GlobalRepoListStore {}
|
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).
|
/// Store listing repository announcements (global discovery or per-author).
|
||||||
///
|
///
|
||||||
/// The all-repos store (`author: None`) is created at startup by
|
/// The all-repos store (`author: None`) is created at startup by
|
||||||
@@ -31,6 +53,9 @@ pub struct RepoListStore {
|
|||||||
/// Latest known activity timestamp per repository
|
/// Latest known activity timestamp per repository
|
||||||
/// (announcements, state updates, patches, PRs, issues, statuses).
|
/// (announcements, state updates, patches, PRs, issues, statuses).
|
||||||
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
pub last_activity: Arc<HashMap<RepoAddr, Timestamp>>,
|
||||||
|
/// Issues + pull requests + commits per repository, for the Popular
|
||||||
|
/// ranking of the explore list.
|
||||||
|
pub counts: Arc<HashMap<RepoAddr, RepoActivityCounts>>,
|
||||||
author: Option<PublicKey>,
|
author: Option<PublicKey>,
|
||||||
refreshing: bool,
|
refreshing: bool,
|
||||||
refresh_dirty: bool,
|
refresh_dirty: bool,
|
||||||
@@ -88,6 +113,7 @@ impl RepoListStore {
|
|||||||
let mut store = Self {
|
let mut store = Self {
|
||||||
announcements: Arc::new(Vec::new()),
|
announcements: Arc::new(Vec::new()),
|
||||||
last_activity: Arc::new(HashMap::new()),
|
last_activity: Arc::new(HashMap::new()),
|
||||||
|
counts: Arc::new(HashMap::new()),
|
||||||
author,
|
author,
|
||||||
refreshing: false,
|
refreshing: false,
|
||||||
refresh_dirty: 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<RepoAddr, RepoActivityCounts> = 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| {
|
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,
|
Ok(results) => results,
|
||||||
// Database errors are transient; keep the last list.
|
// Database errors are transient; keep the last list.
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
@@ -271,6 +324,7 @@ impl RepoListStore {
|
|||||||
let again = this.update(cx, |this, cx| {
|
let again = this.update(cx, |this, cx| {
|
||||||
this.announcements = Arc::new(announcements);
|
this.announcements = Arc::new(announcements);
|
||||||
this.last_activity = Arc::new(last_activity);
|
this.last_activity = Arc::new(last_activity);
|
||||||
|
this.counts = Arc::new(counts);
|
||||||
cx.notify();
|
cx.notify();
|
||||||
|
|
||||||
this.refreshing = false;
|
this.refreshing = false;
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use gpui::{
|
|||||||
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render,
|
||||||
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
SharedString, Size, Subscription, WeakEntity, Window, div, px, size,
|
||||||
};
|
};
|
||||||
|
use gpui_base::Button as BaseButton;
|
||||||
use gpui_component::avatar::Avatar;
|
use gpui_component::avatar::Avatar;
|
||||||
use gpui_component::scroll::Scrollbar;
|
use gpui_component::scroll::Scrollbar;
|
||||||
use gpui_component::{
|
use gpui_component::{
|
||||||
@@ -21,13 +22,59 @@ use crate::image_cache::{MAX_IMAGES, image_cache};
|
|||||||
const COLUMNS: usize = 2;
|
const COLUMNS: usize = 2;
|
||||||
const CARD_HEIGHT: f32 = 32. + 64. + 48. + 2. + 6.;
|
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<usize> {
|
||||||
|
let announcements = &store.announcements;
|
||||||
|
let mut indices: Vec<usize> = (0..announcements.len()).collect();
|
||||||
|
|
||||||
|
match self {
|
||||||
|
Self::All => {}
|
||||||
|
Self::Recent => indices.truncate(RECENT_COUNT),
|
||||||
|
Self::Popular => {
|
||||||
|
let counts = &store.counts;
|
||||||
|
let scores: Vec<u32> = 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 {
|
pub struct RepoListView {
|
||||||
store: Entity<RepoListStore>,
|
store: Entity<RepoListStore>,
|
||||||
dock_area: WeakEntity<DockArea>,
|
dock_area: WeakEntity<DockArea>,
|
||||||
focus_handle: FocusHandle,
|
focus_handle: FocusHandle,
|
||||||
scroll_handle: VirtualListScrollHandle,
|
scroll_handle: VirtualListScrollHandle,
|
||||||
|
/// Sort selected in the header filter buttons.
|
||||||
|
filter: RepoFilter,
|
||||||
|
/// Per-row heights of the virtual list.
|
||||||
item_sizes: Rc<Vec<Size<Pixels>>>,
|
item_sizes: Rc<Vec<Size<Pixels>>>,
|
||||||
|
/// 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<usize>,
|
||||||
_subscription: Subscription,
|
_subscription: Subscription,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,33 +84,51 @@ impl RepoListView {
|
|||||||
_window: &mut Window,
|
_window: &mut Window,
|
||||||
cx: &mut Context<Self>,
|
cx: &mut Context<Self>,
|
||||||
) -> Self {
|
) -> 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 store = RepoListStore::global(cx);
|
||||||
|
|
||||||
let subscription = cx.observe(&store, |this, store, cx| {
|
// Keep the visible slice and row sizes in sync with the store, so
|
||||||
// Each virtual list row holds `COLUMNS` repo cards.
|
// newly announced repositories appear without waiting for a click.
|
||||||
let rows = store.read(cx).announcements.len().div_ceil(COLUMNS);
|
let subscription = cx.observe(&store, |this, _store, cx| {
|
||||||
|
this.rebuild_rows(cx);
|
||||||
if this.item_sizes.len() != rows {
|
|
||||||
this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); rows]);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// The store may already hold announcements (it loaded before the
|
let mut this = Self {
|
||||||
// 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 {
|
|
||||||
store,
|
store,
|
||||||
dock_area,
|
dock_area,
|
||||||
focus_handle: cx.focus_handle(),
|
focus_handle: cx.focus_handle(),
|
||||||
scroll_handle: VirtualListScrollHandle::new(),
|
scroll_handle: VirtualListScrollHandle::new(),
|
||||||
item_sizes,
|
filter: RepoFilter::default(),
|
||||||
|
item_sizes: Rc::new(Vec::new()),
|
||||||
|
repo_len: 0,
|
||||||
|
visible: Vec::new(),
|
||||||
_subscription: subscription,
|
_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<Self>) {
|
||||||
|
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(
|
fn open_repo(
|
||||||
@@ -186,6 +251,52 @@ impl RepoListView {
|
|||||||
}))
|
}))
|
||||||
.into_any_element()
|
.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<Self>) -> 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<Self>,
|
||||||
|
) -> 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 {
|
impl BasePanel for RepoListView {
|
||||||
@@ -210,29 +321,19 @@ impl Focusable for RepoListView {
|
|||||||
|
|
||||||
impl Render for RepoListView {
|
impl Render for RepoListView {
|
||||||
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 announcements = self.store.read(cx).announcements.clone();
|
let store = self.store.read(cx);
|
||||||
let last_activity = self.store.read(cx).last_activity.clone();
|
let announcements = store.announcements.clone();
|
||||||
let has_announcements = !announcements.is_empty();
|
let last_activity = store.last_activity.clone();
|
||||||
let count = announcements.len();
|
let count = self.visible.len();
|
||||||
|
let has_repos = count > 0;
|
||||||
|
|
||||||
v_flex()
|
v_flex()
|
||||||
.relative()
|
.relative()
|
||||||
.image_cache(image_cache("repos", MAX_IMAGES))
|
.image_cache(image_cache("repos", MAX_IMAGES))
|
||||||
.size_full()
|
.size_full()
|
||||||
.child(
|
.pb_3()
|
||||||
h_flex()
|
.child(self.render_header(cx))
|
||||||
.px_4()
|
.when(!has_repos, |this| {
|
||||||
.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| {
|
|
||||||
this.child(
|
this.child(
|
||||||
v_flex().size_full().items_center().justify_center().child(
|
v_flex().size_full().items_center().justify_center().child(
|
||||||
div()
|
div()
|
||||||
@@ -242,7 +343,7 @@ impl Render for RepoListView {
|
|||||||
),
|
),
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
.when(has_announcements, |this| {
|
.when(has_repos, |this| {
|
||||||
let view = cx.entity().clone();
|
let view = cx.entity().clone();
|
||||||
let sizes = self.item_sizes.clone();
|
let sizes = self.item_sizes.clone();
|
||||||
|
|
||||||
@@ -254,7 +355,9 @@ impl Render for RepoListView {
|
|||||||
let mut row_cards = vec![];
|
let mut row_cards = vec![];
|
||||||
|
|
||||||
for col in 0..COLUMNS {
|
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 {
|
let Some(announcement) = announcements.get(ix) else {
|
||||||
break;
|
break;
|
||||||
};
|
};
|
||||||
@@ -268,8 +371,8 @@ impl Render for RepoListView {
|
|||||||
.w_full()
|
.w_full()
|
||||||
.h_full()
|
.h_full()
|
||||||
.gap_3()
|
.gap_3()
|
||||||
.px_3()
|
.px_4()
|
||||||
.pb_3()
|
.pt_4()
|
||||||
.children(row_cards)
|
.children(row_cards)
|
||||||
.into_any_element(),
|
.into_any_element(),
|
||||||
);
|
);
|
||||||
|
|||||||
Reference in New Issue
Block a user