use std::fmt::Display; use std::rc::Rc; use assets::CustomIconName; use dock::{BasePanel, DockArea, Panel, PanelEvent}; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, SharedString, Size, Subscription, WeakEntity, Window, div, px, relative, size, }; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::scroll::Scrollbar; use gpui_component::{ ActiveTheme, Icon, IconName, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, }; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; use signed_ui::{Avatar, SegmentButton}; use utils::relative_time; use super::{open_repo_panel, panel_avatar, tab_title}; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; const RECENT_COUNT: usize = 10; #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] enum RepoFilter { All, #[default] Popular, Recent, } impl AsRef for RepoFilter { fn as_ref(&self) -> &str { match self { RepoFilter::All => "all", RepoFilter::Popular => "popular", RepoFilter::Recent => "recent", } } } impl Display for RepoFilter { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { write!(f, "{}", self.as_ref()) } } impl RepoFilter { /// Indices into the store's `announcements` this filter includes, in display order. /// /// Narrowed to repositories whose name or id contains `query`. fn visible(self, store: &RepoListStore, query: &str) -> Vec { let announcements = &store.announcements; let mut indices: Vec = (0..announcements.len()).collect(); // Narrow by the search query first. // Recent then limits the matches and Popular ranks them. let query = query.trim().to_lowercase(); if !query.is_empty() { indices.retain(|&ix| { let announcement = &announcements[ix]; let name = announcement.name.as_deref().unwrap_or(&announcement.id); name.to_lowercase().contains(&query) }); } 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 } fn icon_name(self) -> CustomIconName { match self { Self::All => CustomIconName::Grid, Self::Recent => CustomIconName::Recent, Self::Popular => CustomIconName::Trending, } } } pub struct RepoListView { store: Entity, dock_area: WeakEntity, focus_handle: FocusHandle, scroll_handle: VirtualListScrollHandle, filter: RepoFilter, item_sizes: Rc>>, /// Number of rows [`Self::item_sizes`] was built for, the filtered repo count. repo_len: usize, /// Indices matching [`Self::filter`] into the store's `announcements`. visible: Vec, search: Entity, _search_subscription: Subscription, _subscription: Subscription, } impl RepoListView { pub fn new( dock_area: WeakEntity, window: &mut Window, cx: &mut Context, ) -> Self { let store = RepoListStore::global(cx); let search = cx.new(|cx| InputState::new(window, cx).placeholder("Search...")); let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { if matches!(event, InputEvent::Change) { this.rebuild_rows(cx); } }); // Keep the visible slice and row sizes in sync with the store. // Newly announced repositories appear without waiting for a click. let subscription = cx.observe(&store, |this, _store, cx| { this.rebuild_rows(cx); }); cx.defer_in(window, |this, _window, cx| { this.rebuild_rows(cx); }); Self { store, dock_area, focus_handle: cx.focus_handle(), scroll_handle: VirtualListScrollHandle::new(), filter: RepoFilter::default(), item_sizes: Rc::new(Vec::new()), repo_len: 0, visible: Vec::new(), search, _search_subscription: search_subscription, _subscription: subscription, } } fn rebuild_rows(&mut self, cx: &mut Context) { let filter = self.filter; let query = self.search.read(cx).value(); let store = self.store.read(cx); self.visible = filter.visible(store, &query); // 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( &mut self, announcement: &Announcement, window: &mut Window, cx: &mut Context, ) { open_repo_panel( &self.dock_area, &announcement.addr(), Some(announcement), window, cx, ); } fn render_card( &self, ix: usize, announcement: &Announcement, last_activity: Option, cx: &mut Context, ) -> AnyElement { let profile_store = ProfileStore::global(cx); let owner = profile_store.read(cx).get(&announcement.owner); let name = announcement .name .as_deref() .map(SharedString::from) .unwrap_or(SharedString::from(announcement.id.clone())); let description = announcement .description .as_deref() .map(SharedString::from) .unwrap_or(SharedString::from("No description")); let activity = last_activity .map(relative_time) .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); // The fork badge shows the upstream name when its announcement is known locally. // Otherwise it shows the repository id from the `u` tag. let fork_label: Option = announcement.upstream.as_ref().and_then(|upstream| { let addr = upstream.addr.as_ref()?; let name = self .store .read(cx) .announcements .iter() .find(|a| a.addr() == *addr) .map(|a| { a.name .as_deref() .map(SharedString::from) .unwrap_or_else(|| SharedString::from(a.id.clone())) }) .unwrap_or_else(|| SharedString::from(addr.identifier.clone())); Some(SharedString::from(format!("forked from {name}"))) }); v_flex() .id(ix) .flex_1() .h_full() .min_w_0() .px_3() .rounded(cx.theme().radius) .border_1() .border_color(cx.theme().border) .hover(|this| this.bg(cx.theme().list_hover)) .child( h_flex() .h_10() .gap_1p5() .items_center() .child( div() .min_w_0() .text_sm() .font_semibold() .whitespace_nowrap() .text_ellipsis() .child(name), ) .when_some(fork_label, |this, label| { this.child( h_flex() .gap_1() .items_center() .text_xs() .text_color(cx.theme().muted_foreground) .whitespace_nowrap() .child(Icon::new(CustomIconName::GitBranch).small()) .child(label), ) }), ) .child( div() .h_16() .min_w_0() .text_xs() .text_color(cx.theme().muted_foreground) .line_clamp(2) .text_ellipsis() .child(description), ) .child( h_flex() .h_12() .gap_2() .items_center() .overflow_hidden() .child( h_flex() .gap_2() .items_center() .child(Avatar::new(owner.name()).picture(owner.picture())) .child( div() .text_xs() .text_color(cx.theme().muted_foreground) .whitespace_nowrap() .child(owner.name()), ), ) .child( h_flex() .flex_1() .justify_end() .text_xs() .text_color(cx.theme().muted_foreground) .whitespace_nowrap() .child(activity), ), ) .on_click(cx.listener({ let announcement = announcement.clone(); move |this, _ev, window, cx| { this.open_repo(&announcement, window, cx); } })) .into_any_element() } fn render_filter(&self, filter: RepoFilter, label: T, cx: &mut Context) -> AnyElement where T: Into, { let active = self.filter == filter; SegmentButton::new(filter.to_string(), label) .icon(Icon::new(filter.icon_name())) .selected(active) .on_click(cx.listener(move |this, _event, _window, cx| { this.filter = filter; this.rebuild_rows(cx); })) .into_any_element() } fn render_header(&self, count: usize, cx: &mut Context) -> AnyElement { h_flex() .px_4() .py_2() .w_full() .gap_3() .child( v_flex() .gap_0p5() .child( div() .min_w_0() .truncate() .text_ellipsis() .font_semibold() .text_xs() .line_height(relative(1.2)) .child("Repositories"), ) .child( div() .text_size(px(10.)) .text_color(cx.theme().muted_foreground) .line_height(relative(1.2)) .child(SharedString::from(format!("Total: {count}"))), ), ) .child( Input::new(&self.search) .cleanable(true) .w(px(180.)) .text_sm() .border_color(cx.theme().muted) .bg(cx.theme().muted) .prefix(Icon::new(IconName::Search).small()), ) .child(div().flex_1()) .child( h_flex() .gap_1() .child(self.render_filter(RepoFilter::All, "All", cx)) .child(self.render_filter(RepoFilter::Popular, "Popular", cx)) .child(self.render_filter(RepoFilter::Recent, "Recent", cx)), ) .into_any_element() } } impl BasePanel for RepoListView { fn panel_name(&self) -> &'static str { "repo_list" } } impl Panel for RepoListView { fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { tab_title(panel_avatar("explore"), SharedString::from("Explore")) } } impl EventEmitter for RepoListView {} impl Focusable for RepoListView { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for RepoListView { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { 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(gpui::retain_all("repos")) .size_full() .child(self.render_header(count, cx)) .when(!has_repos, |this| { this.child( v_flex().size_full().items_center().justify_center().child( div() .text_sm() .text_color(cx.theme().muted_foreground) .child("No repositories found yet."), ), ) }) .when(has_repos, |this| { let view = cx.entity().clone(); let sizes = self.item_sizes.clone(); this.child( v_virtual_list(view, "repos", sizes, move |this, range, _window, cx| { let mut items = vec![]; for row in range { let mut row_cards = vec![]; for col in 0..COLUMNS { let Some(&ix) = this.visible.get(row * COLUMNS + col) else { break; }; let Some(announcement) = announcements.get(ix) else { break; }; let activity = last_activity.get(&announcement.addr()).copied(); row_cards.push(this.render_card(ix, announcement, activity, cx)); } items.push( h_flex() .id(row) .w_full() .h_full() .gap_3() .px_4() .pt_4() .children(row_cards) .into_any_element(), ); } items }) .track_scroll(&self.scroll_handle) .size_full(), ) }) .child( div() .absolute() .top_0() .left_0() .right_0() .bottom_0() .child(Scrollbar::vertical(&self.scroll_handle)), ) } }