diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 04620ae..7462a1c 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -57,6 +57,7 @@ pub enum CustomIconName { EllipsisVertical, ArrowUp, Trash, + Globe, } impl IconNamed for CustomIconName { @@ -90,6 +91,7 @@ impl IconNamed for CustomIconName { CustomIconName::EllipsisVertical => "icons/ellipsis-vertical.svg", CustomIconName::ArrowUp => "icons/arrow-up.svg", CustomIconName::Trash => "icons/trash.svg", + CustomIconName::Globe => "icons/globe.svg", } .into() } diff --git a/crates/signed_app/src/views/explore.rs b/crates/signed_app/src/views/explore.rs new file mode 100644 index 0000000..f3c6885 --- /dev/null +++ b/crates/signed_app/src/views/explore.rs @@ -0,0 +1,422 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, + Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, + UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::empty_state::EmptyState; +use gpui_native_kit::icons::{Icon, IconRole}; +use gpui_native_kit::input::{Input, InputState, InputStateEvent}; +use gpui_native_kit::layout::dock::{DockState, Panel}; +use gpui_native_kit::scrollbar::Scrollbar; +use gpui_native_kit::segmented::{Segmented, SegmentedOption}; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::prelude::PublicKey; +use signed_core::Announcement; +use signed_state::{RepoListStore, Timestamp}; +use utils::relative_time; + +use crate::views::repo::actions::open_repo_panel; + +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 RepoFilter { + /// Indices into the store's `announcements` this filter includes, in display order. + fn visible(self, store: &RepoListStore, query: &str) -> Vec { + let announcements = &store.announcements; + let mut indices: Vec = (0..announcements.len()).collect(); + 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 + } +} + +pub struct ExploreView { + store: Entity, + dock: WeakEntity, + scroll_handle: UniformListScrollHandle, + filter: RepoFilter, + /// Indices matching [`Self::filter`] into the store's `announcements`. + visible: Vec, + search: Entity, + /// Profiles for the card owners. + directory: Entity, + _search_subscription: Subscription, + _store_subscription: Subscription, +} + +impl ExploreView { + pub fn new(dock: WeakEntity, cx: &mut Context) -> Self { + let store = RepoListStore::global(cx); + let directory = ProfileDirectory::new(cx); + + let search = InputState::new(cx); + let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { + if matches!(event, InputStateEvent::TextChanged) { + this.rebuild_rows(cx); + } + }); + + let store_subscription = cx.observe(&store, |this, _store, cx| { + this.rebuild_rows(cx); + }); + + let mut view = Self { + store, + dock, + scroll_handle: UniformListScrollHandle::new(), + filter: RepoFilter::default(), + visible: Vec::new(), + search, + directory, + _search_subscription: search_subscription, + _store_subscription: store_subscription, + }; + view.rebuild_rows(cx); + view + } + + fn rebuild_rows(&mut self, cx: &mut Context) { + let filter = self.filter; + let query = self.search.read(cx).value().to_string(); + let store = self.store.read(cx); + + self.visible = filter.visible(store, &query); + + let owners: Vec = self + .visible + .iter() + .filter_map(|ix| store.announcements.get(*ix)) + .map(|announcement| announcement.owner) + .collect(); + + self.directory.update(cx, |directory, cx| { + directory.track(owners, cx); + }); + + cx.notify(); + } + + fn render_header(&self, count: usize, cx: &mut Context) -> AnyElement { + let muted = cx.theme().colors.muted_foreground; + let weak = cx.entity().downgrade(); + + h_flex() + .px_4() + .py_2() + .w_full() + .gap_3() + .child( + v_flex() + .gap_1() + .child( + div() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_xs() + .child("Repositories"), + ) + .child( + div() + .text_size(px(10.)) + .text_color(muted) + .child(SharedString::from(format!("Total: {count}"))), + ), + ) + .child( + div().w(px(180.)).child( + Input::new("explore-search", self.search.clone()) + .placeholder("Search...") + .prefix(Icon::named(IconRole::Search).size(px(14.)).color(muted)), + ), + ) + .child(div().flex_1()) + .child( + Segmented::new("explore-filter", self.filter) + .options(vec![ + SegmentedOption::new(RepoFilter::All, "All") + .icon(Icon::named(CustomIconName::Grid).size(px(14.)).color(muted)), + SegmentedOption::new(RepoFilter::Popular, "Popular").icon( + Icon::named(CustomIconName::Trending) + .size(px(14.)) + .color(muted), + ), + SegmentedOption::new(RepoFilter::Recent, "Recent").icon( + Icon::named(CustomIconName::Recent) + .size(px(14.)) + .color(muted), + ), + ]) + .on_change(move |filter, _, cx| { + if let Err(error) = weak.update(cx, |this, cx| { + this.filter = filter; + this.rebuild_rows(cx); + }) { + log::warn!("failed to change explore filter: {error}"); + } + }), + ) + .into_any_element() + } +} + +impl Panel for ExploreView { + fn title(&self, _cx: &App) -> Option { + Some(SharedString::from("Explore")) + } + + fn lead(&self, cx: &App) -> Option { + Some( + Icon::named(CustomIconName::Globe) + .size(px(14.)) + .color(cx.theme().colors.muted_foreground) + .into_any_element(), + ) + } +} + +impl Render for ExploreView { + 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 visible = self.visible.clone(); + let directory = self.directory.clone(); + let dock = self.dock.clone(); + let has_repos = count > 0; + let rows = visible.len().div_ceil(COLUMNS); + + v_flex() + .relative() + .size_full() + .child(self.render_header(count, cx)) + .when(!has_repos, |this| { + this.child( + v_flex() + .size_full() + .items_center() + .justify_center() + .child(EmptyState::new("No repositories match your search")), + ) + }) + .when(has_repos, |this| { + this.child( + uniform_list("explore-repos", rows, move |range, _, cx| { + range + .map(|row| { + let mut cards = Vec::with_capacity(COLUMNS); + + for col in 0..COLUMNS { + let Some(&ix) = visible.get(row * COLUMNS + col) else { + break; + }; + let Some(announcement) = announcements.get(ix) else { + break; + }; + let activity = last_activity.get(&announcement.addr()).copied(); + cards.push(render_card( + ix, + announcement, + activity, + &announcements, + &directory, + &dock, + cx, + )); + } + + h_flex() + .id(row) + .w_full() + .h(px(CARD_HEIGHT)) + .gap_3() + .px_4() + .pt_4() + .children(cards) + .into_any_element() + }) + .collect() + }) + .track_scroll(&self.scroll_handle) + .size_full(), + ) + }) + .child( + div() + .absolute() + .top_0() + .left_0() + .right_0() + .bottom_0() + .child(Scrollbar::vertical( + "explore-scroll", + self.scroll_handle.clone(), + )), + ) + } +} + +fn render_card( + ix: usize, + announcement: &Announcement, + last_activity: Option, + announcements: &[Announcement], + directory: &Entity, + dock: &WeakEntity, + cx: &mut gpui::App, +) -> AnyElement { + let owner = directory.read(cx).profile(&announcement.owner, cx); + let name = announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + let description = announcement + .description + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from("No description")); + let activity = last_activity + .map(relative_time) + .map(|label| SharedString::from(format!("Updated {label}"))) + .unwrap_or_default(); + + let fork_label: Option = announcement.upstream.as_ref().and_then(|upstream| { + let addr = upstream.addr.as_ref()?; + let name = 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().to_owned())); + Some(SharedString::from(format!("forked from {name}"))) + }); + + let owner_name = owner.name(); + let dock = dock.clone(); + let announcement = announcement.clone(); + + v_flex() + .id(ix) + .flex_1() + .h_full() + .min_w_0() + .px_3() + .rounded(cx.theme().radius.control) + .border_1() + .border_color(cx.theme().colors.border) + .hover(|this| this.bg(cx.theme().colors.muted_hover)) + .child( + h_flex() + .h_10() + .gap_2() + .child( + div() + .min_w_0() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when_some(fork_label, |this, label| { + this.child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child( + Icon::named(CustomIconName::GitBranch) + .size(px(12.)) + .color(cx.theme().colors.muted_foreground), + ) + .child(label), + ) + }), + ) + .child( + div() + .h_16() + .min_w_0() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .line_clamp(2) + .child(description), + ) + .child( + h_flex() + .h_12() + .gap_2() + .overflow_hidden() + .child( + h_flex() + .gap_2() + .child( + Avatar::new(announcement.owner) + .name(owner_name.clone()) + .picture(owner.picture()) + .small(), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child(owner_name), + ), + ) + .child( + h_flex() + .flex_1() + .justify_end() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child(activity), + ), + ) + .on_click(move |_, window, cx| { + open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx); + }) + .into_any_element() +} diff --git a/crates/signed_app/src/views/mod.rs b/crates/signed_app/src/views/mod.rs index 812ad52..18c4e51 100644 --- a/crates/signed_app/src/views/mod.rs +++ b/crates/signed_app/src/views/mod.rs @@ -1,3 +1,4 @@ +pub mod explore; pub mod placeholder; pub mod repo; pub mod sidebar; diff --git a/crates/signed_app/src/views/sidebar.rs b/crates/signed_app/src/views/sidebar.rs index 4ad680e..e4babe7 100644 --- a/crates/signed_app/src/views/sidebar.rs +++ b/crates/signed_app/src/views/sidebar.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; +use assets::CustomIconName; use gpui::{ AnyElement, App, AppContext as _, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString, Styled as _, StyledImage as _, Subscription, WeakEntity, @@ -26,6 +27,7 @@ use signed_state::{ }; use crate::dialogs; +use crate::views::explore::ExploreView; pub struct SidebarView { weak: WeakEntity, @@ -221,7 +223,17 @@ impl SidebarView { .transparent(true) .header(self.render_user(&profile, self.scanning, cx)) .entries(move |_, cx| { - let mut entries = vec![SidebarEntry::header("All Repositories")]; + let muted = cx.theme().colors.muted_foreground; + let mut entries = vec![ + SidebarEntry::row("browse", "Browse").prefix( + Icon::named(CustomIconName::Globe) + .size(px(14.)) + .color(muted), + ), + SidebarEntry::row("search", "Search") + .prefix(Icon::named(IconRole::Search).size(px(14.)).color(muted)), + SidebarEntry::header("All Repositories"), + ]; for announcement in announcements.iter() { let owner = directory.read(cx).profile(&announcement.owner, cx); @@ -323,7 +335,16 @@ impl SidebarView { let Some(index) = index else { return; }; - let Some(row) = index.checked_sub(1) else { + + match index { + 0 | 1 => { + self.open_explore(window, cx); + return; + } + _ => {} + } + + let Some(row) = index.checked_sub(3) else { return; }; @@ -405,6 +426,18 @@ impl SidebarView { ); } + fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { + let dock = self.dock.clone(); + let dock_for_build = dock.clone(); + crate::panels::open( + &dock, + "explore", + move |_window, cx| cx.new(|cx| ExploreView::new(dock_for_build.clone(), cx)), + window, + cx, + ); + } + fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context) { if let Err(error) = self .dock