diff --git a/crates/workspace/src/views/repo_list.rs b/crates/workspace/src/views/repo_list.rs index b15ee8d..251dab1 100644 --- a/crates/workspace/src/views/repo_list.rs +++ b/crates/workspace/src/views/repo_list.rs @@ -1,29 +1,48 @@ +use std::rc::Rc; + use gpui::prelude::*; use gpui::{ - AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Render, SharedString, - Subscription, Window, div, px, uniform_list, + AnyElement, App, Context, Entity, EventEmitter, FocusHandle, Focusable, Pixels, Render, + SharedString, Size, Subscription, Window, div, px, size, }; +use gpui_component::avatar::Avatar; use gpui_component::dock::{Panel, PanelEvent}; -use gpui_component::{ActiveTheme, StyledExt}; +use gpui_component::scroll::Scrollbar; +use gpui_component::{ + ActiveTheme, Sizable, StyledExt, VirtualListScrollHandle, h_flex, v_flex, v_virtual_list, +}; use signed_core::Announcement; use signed_state::{ProfileStore, RepoListStore, Timestamp}; use utils::relative_time; +const CARD_HEIGHT: f32 = 160.; + /// Browse all announced repositories (works anonymously). pub struct RepoListView { store: Entity, focus_handle: FocusHandle, + scroll_handle: VirtualListScrollHandle, + item_sizes: Rc>>, _subscription: Subscription, } impl RepoListView { pub fn new(_window: &mut Window, cx: &mut Context) -> Self { let store = cx.new(|cx| RepoListStore::new(None, cx)); - let subscription = cx.observe(&store, |_this, _store, cx| cx.notify()); + + let subscription = cx.observe(&store, |this, store, cx| { + let count = store.read(cx).announcements.len(); + + if this.item_sizes.len() != count { + this.item_sizes = Rc::new(vec![size(px(0.), px(CARD_HEIGHT)); count]); + } + }); Self { store, focus_handle: cx.focus_handle(), + scroll_handle: VirtualListScrollHandle::new(), + item_sizes: Rc::new(vec![]), _subscription: subscription, } } @@ -34,99 +53,71 @@ impl RepoListView { last_activity: Option, cx: &mut App, ) -> AnyElement { + let owner = ProfileStore::global(cx).read(cx).get(&announcement.owner); + let name = announcement .name .clone() - .unwrap_or_else(|| announcement.id.clone()); - - let owner = ProfileStore::global(cx) - .read(cx) - .get(&announcement.owner) - .name(); + .map(|s| SharedString::from(s.trim())) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); let description = announcement.description.clone().unwrap_or_default(); - let mut hashtags: Vec = announcement - .hashtags - .iter() - .take(3) - .map(|tag| { - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(SharedString::from(format!("#{tag}"))) - .into_any_element() - }) - .collect(); - - let remaining = announcement.hashtags.len().saturating_sub(3); - - if remaining > 0 { - hashtags.push( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(SharedString::from(format!("+{remaining}"))) - .into_any_element(), - ); - } - let activity = last_activity .map(relative_time) .map(|label| SharedString::from(format!("Updated {label}"))) .unwrap_or_default(); - div() - .v_flex() - .h(px(78.)) - .w_full() - .justify_center() - .gap_1() + v_flex() .px_4() + .w_full() .border_b(px(1.)) .border_color(cx.theme().border) .child( - div() - .h_flex() - .gap_2() - .items_center() - .child( - div() - .text_sm() - .font_semibold() - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .child(owner), - ), + h_flex() + .h_12() + .text_sm() + .font_semibold() + .whitespace_nowrap() + .text_ellipsis() + .child(name), ) .child( div() - .text_xs() + .h_16() + .text_sm() .text_color(cx.theme().muted_foreground) - .whitespace_nowrap() - .text_ellipsis() + .line_clamp(2) .child(description), ) .child( - div() - .h_flex() + h_flex() + .h_12() .gap_2() .items_center() .overflow_hidden() - .children(hashtags) .child( - div() + h_flex() + .gap_2() + .items_center() + .child( + Avatar::new() + .name(owner.name()) + .when_some(owner.picture(), |this, url| this.src(url)) + .small() + .border_0(), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().muted_foreground) + .whitespace_nowrap() + .child(owner.name()), + ), + ) + .child( + h_flex() .flex_1() - .h_flex() .justify_end() .text_xs() .text_color(cx.theme().muted_foreground) @@ -163,12 +154,11 @@ impl Render for RepoListView { let has_announcements = !announcements.is_empty(); let count = announcements.len(); - div() - .v_flex() + v_flex() + .relative() .size_full() .child( - div() - .h_flex() + h_flex() .px_4() .py_2() .items_center() @@ -182,25 +172,21 @@ impl Render for RepoListView { ) .when(!has_announcements, |this| { this.child( - div() - .size_full() - .v_flex() - .items_center() - .justify_center() - .child( - div() - .text_sm() - .text_color(cx.theme().muted_foreground) - .child("No repositories found. Waiting for relays..."), - ), + v_flex().size_full().items_center().justify_center().child( + div() + .text_sm() + .text_color(cx.theme().muted_foreground) + .child("No repositories found. Waiting for relays..."), + ), ) }) .when(has_announcements, |this| { this.child( - uniform_list( + v_virtual_list( + cx.entity().clone(), "repos", - count, - cx.processor(move |this, range, _window, cx| { + self.item_sizes.clone(), + move |this, range, _window, cx| { let mut items = vec![]; for ix in range { @@ -210,10 +196,20 @@ impl Render for RepoListView { } 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)), + ) } } diff --git a/crates/workspace/src/workspace.rs b/crates/workspace/src/workspace.rs index b979783..1a109c6 100644 --- a/crates/workspace/src/workspace.rs +++ b/crates/workspace/src/workspace.rs @@ -5,7 +5,7 @@ use gpui::prelude::*; use gpui::{Context, Entity, Render, SharedString, Subscription, Window, div, px}; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::dock::{DockArea, DockItem}; -use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, TitleBar, h_flex, v_flex}; +use gpui_component::{ActiveTheme, Root, Sizable, StyledExt, Theme, TitleBar, h_flex, v_flex}; use signed_state::{Backend, BackendEvent}; use crate::views::SidebarPanel; @@ -15,7 +15,7 @@ use crate::views::sidebar::passphrase_dialog; pub struct Workspace { dock: Entity, status: SharedString, - _subscription: Subscription, + _subscriptions: Vec, _passphrase_subscription: Subscription, } @@ -50,7 +50,13 @@ impl Workspace { "Connecting...".into() }; - let subscription = cx.subscribe(&backend, |this, _backend, event, cx| { + let mut subscriptions = vec![]; + + subscriptions.push(cx.observe_window_appearance(window, |_this, window, cx| { + Theme::sync_system_appearance(Some(window), cx); + })); + + subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { match event { BackendEvent::SyncProgress { total, current } => { this.status = format!("Syncing repositories... {current}/{total}").into() @@ -61,7 +67,7 @@ impl Workspace { _ => return, } cx.notify(); - }); + })); // Ask for the passphrase when the stored identity is NIP-49 // encrypted. Subscribed via the window, since opening a dialog @@ -83,7 +89,7 @@ impl Workspace { Self { dock, status, - _subscription: subscription, + _subscriptions: subscriptions, _passphrase_subscription: passphrase_subscription, } } diff --git a/desktop/src/main.rs b/desktop/src/main.rs index b14c693..52e6a01 100644 --- a/desktop/src/main.rs +++ b/desktop/src/main.rs @@ -2,6 +2,7 @@ use std::sync::Arc; use assets::Assets; use gpui::*; +use gpui_component::theme; use gpui_platform::application; fn main() { @@ -18,6 +19,9 @@ fn main() { // Initialize components gpui_component::init(cx); + // Initialize theme + theme::init(cx); + // Initialize backend and stores (connects relays, restores session) std::fs::create_dir_all(paths::nostr_dir()).ok(); signed_state::init(paths::nostr_dir(), cx);