add explore panel with repo browsing and search
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-12 04:45:29 +07:00
parent 4465bbf4eb
commit 44b530f11d
4 changed files with 460 additions and 2 deletions
+2
View File
@@ -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()
}
+422
View File
@@ -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<usize> {
let announcements = &store.announcements;
let mut indices: Vec<usize> = (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<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
}
}
pub struct ExploreView {
store: Entity<RepoListStore>,
dock: WeakEntity<DockState>,
scroll_handle: UniformListScrollHandle,
filter: RepoFilter,
/// Indices matching [`Self::filter`] into the store's `announcements`.
visible: Vec<usize>,
search: Entity<InputState>,
/// Profiles for the card owners.
directory: Entity<ProfileDirectory>,
_search_subscription: Subscription,
_store_subscription: Subscription,
}
impl ExploreView {
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> 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<Self>) {
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<PublicKey> = 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<Self>) -> 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<SharedString> {
Some(SharedString::from("Explore"))
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
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<Self>) -> 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<Timestamp>,
announcements: &[Announcement],
directory: &Entity<ProfileDirectory>,
dock: &WeakEntity<DockState>,
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<SharedString> = 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()
}
+1
View File
@@ -1,3 +1,4 @@
pub mod explore;
pub mod placeholder;
pub mod repo;
pub mod sidebar;
+35 -2
View File
@@ -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<Self>,
@@ -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<Self>) {
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<Self>) {
if let Err(error) = self
.dock