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
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:
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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,3 +1,4 @@
|
||||
pub mod explore;
|
||||
pub mod placeholder;
|
||||
pub mod repo;
|
||||
pub mod sidebar;
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user