use std::ops::Range; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; use dock::{ BasePanel, DockArea, DockPlacement, Panel, PanelEvent, TAB_BAR_HEIGHT, panel_handle, title_bar_drag_handlers, }; use gpui::prelude::*; use gpui::{ AnyElement, App, ClickEvent, Context, Div, ElementId, Entity, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, SharedString, StyleRefinement, Subscription, WeakEntity, Window, div, img, px, uniform_list, }; use gpui_base::Button as BaseButton; use gpui_component::avatar::Avatar; use gpui_component::button::{Button, ButtonVariants}; use gpui_component::input::InputState; use gpui_component::{ActiveTheme, Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; use signed_core::Announcement; use signed_state::{Backend, BackendEvent, Profile, ProfileStore, RepoListStore}; use super::{RepoDetailView, RepoListView}; use crate::image_cache::{MAX_IMAGES, image_cache}; use crate::pixel_avatar::PixelAvatar; mod create_repo_dialog; mod import_dialog; mod onboarding_dialog; pub(crate) mod passphrase_dialog; use self::onboarding_dialog::OnboardingState; /// Left-dock panel with navigation entries. Entries open content panels in /// the dock area. pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, /// Repositories announced by the current user, listed under /// "All Repositories". Recreated when the signer changes. my_repos: Option>, /// Observes the current user's repo store so the list re-renders. my_repos_subscription: Option, logged_in: bool, /// Banner artwork shown behind the sign-in screen, /// picked at random from the bundled `backgrounds/` assets. banner: SharedString, _subscription: Subscription, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let logged_in = backend.read(cx).current_user().is_some(); let subscription = cx.subscribe(&backend, |this, backend, event, cx| { match event { BackendEvent::SignerChanged => { this.logged_in = backend.read(cx).current_user().is_some(); this.refresh_my_repos(cx); } BackendEvent::SignerRequired => { this.logged_in = false; this.banner = pick_banner(); this.my_repos = None; this.my_repos_subscription = None; } _ => return, } cx.notify(); }); let mut panel = Self { focus_handle: cx.focus_handle(), dock_area, explore: None, my_repos: None, my_repos_subscription: None, logged_in, banner: pick_banner(), _subscription: subscription, }; if logged_in { panel.refresh_my_repos(cx); } panel } /// (Re)create the store listing the current user's repositories. fn refresh_my_repos(&mut self, cx: &mut Context) { self.my_repos_subscription = None; let author = Backend::global(cx).read(cx).current_user(); self.my_repos = author.map(|author| cx.new(|cx| RepoListStore::new(Some(author), cx))); if let Some(store) = self.my_repos.as_ref() { self.my_repos_subscription = Some(cx.observe(store, |_, _, cx| cx.notify())); } } /// Open the Explore (repository list) panel in the center of the dock /// area. No-op if it's already open. pub fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { if self .explore .as_ref() .and_then(WeakEntity::upgrade) .is_some() { return; } let panel = cx.new(|cx| RepoListView::new(self.dock_area.clone(), window, cx)); self.explore = Some(panel.downgrade()); let _ = self.dock_area.update(cx, |dock_area, cx| { dock_area.add_panel_view(panel_handle(panel), DockPlacement::Center, None, window, cx); }); } /// Show the Onboarding dialog. fn open_onboarding(&mut self, window: &mut Window, cx: &mut Context) { let name_input = cx.new(|cx| InputState::new(window, cx).placeholder("Enter desired name")); let pass_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Passphrase to protect your keys") .masked(true) }); let repass_input = cx.new(|cx| { InputState::new(window, cx) .placeholder("Repeat passphrase") .masked(true) }); let state = cx.new(|_| OnboardingState::default()); onboarding_dialog::open(name_input, pass_input, repass_input, state, window, cx); } /// Show the Create Repository dialog. fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { create_repo_dialog::open(self.dock_area.clone(), window, cx); } /// Open a repository's detail view in the dock's center. fn open_repo( &mut self, announcement: &Announcement, window: &mut Window, cx: &mut Context, ) { let detail = cx.new(|cx| { RepoDetailView::new(self.dock_area.clone(), announcement.clone(), window, cx) }); let _ = self.dock_area.update(cx, |dock_area, cx| { dock_area.add_panel_view( panel_handle(detail), DockPlacement::Center, None, window, cx, ); }); } /// The "All Repositories" section: header with the create button and /// the current user's repositories below it, lazily rendered through a /// [`uniform_list`]. fn render_my_repos(&self, cx: &mut Context) -> impl IntoElement { let store = self.my_repos.as_ref(); v_flex() .px_2() .flex_1() .min_h_0() .w_full() .child( h_flex() .h_10() .w_full() .flex_shrink_0() .justify_between() .items_center() .child( h_flex() .px_2() .gap_2() .text_color(cx.theme().muted_foreground) .child(Icon::new(CustomIconName::Filter).small()) .child(div().text_xs().font_semibold().child("All Repositories")), ) .child( Button::new("add") .icon(IconName::Plus) .small() .ghost() .on_click(cx.listener(|this, _ev, window, cx| { this.open_create_repo(window, cx); })), ), ) .when_some(store, |builder, store| { let announcements = store.read(cx).announcements.clone(); if announcements.is_empty() { builder.child( div() .flex_1() .px_2() .py_1() .text_xs() .text_color(cx.theme().muted_foreground) .child("No repositories yet"), ) } else { builder.child( uniform_list( "my-repos-list", announcements.len(), cx.processor(move |this, range: Range, _window, cx| { range .map(|ix| { this.render_repo_row(&announcements[ix], cx) .into_any_element() }) .collect() }), ) .flex_1() .min_h_0(), ) } }) } /// One repository row in the sidebar, styled like the nav items: a /// deterministic pixel avatar and the repo name. fn render_repo_row( &self, announcement: &Announcement, cx: &mut Context, ) -> impl IntoElement { let name = announcement .name .clone() .unwrap_or_else(|| SharedString::from(announcement.id.clone())); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let announcement = announcement.clone(); NavItem::new(format!("my-repo:{}", announcement.id), name, avatar).on_click( cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)), ) } /// Show the Import Identity dialog. fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); } /// Render the user avatar and name in the sidebar, wrapped in the window titlebar drag area. fn render_user( &self, profile: &Profile, window: &mut Window, cx: &mut Context, ) -> impl IntoElement { let name = profile.name(); let picture = profile.picture(); title_bar_drag_handlers( h_flex() .id("user") .h(TAB_BAR_HEIGHT) .when(cfg!(target_os = "macos"), |this| this.pl(px(80.))) .child( div().child( Button::new("user").text().dropdown_caret(true).child( h_flex() .gap_1() .child( Avatar::new() .name(name.clone()) .when_some(picture, |this, url| this.src(url)) .rounded(cx.theme().radius) .small(), ) .child(div().text_xs().font_semibold().child(name)), ), ), ), window, cx, ) } /// Sign-in placeholder shown while logged out: the banner artwork fills the /// panel behind a scrim that ends in a solid black band, keeping the CTA /// buttons readable on a clean dark surface in both themes. fn render_sign_in(&self, window: &mut Window, cx: &mut Context) -> Div { v_flex() .size_full() .relative() .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child(title_bar_drag_handlers( div() .id("onboarding-drag") .absolute() .h_12() .w_full() .top_0() .left_0(), window, cx, )) .child( div().absolute().inset_0().child( img(self.banner.clone()) .size_full() .object_fit(ObjectFit::Cover), ), ) .child( v_flex() .size_full() .justify_end() .p_4() .mb_4() .gap_4() .child(img("backgrounds/headline.png").max_w_48()) .child( v_flex() .gap_1() .w_full() .child( BaseButton::new("onboarding") .h_flex() .h_8() .px_2() .bg(cx.theme().primary) .hover(|this| this.bg(cx.theme().primary_hover)) .active(|this| this.bg(cx.theme().primary_active)) .text_color(cx.theme().primary_foreground) .child(div().text_sm().font_semibold().child("Join now")) .on_click(cx.listener(|this, _ev, window, cx| { this.open_onboarding(window, cx) })), ) .child( BaseButton::new("onboarding") .h_flex() .h_8() .px_2() .text_color(gpui::white()) .bg(gpui::white().opacity(0.1)) .hover(|this| this.bg(gpui::white().opacity(0.2))) .active(|this| this.bg(gpui::white().opacity(0.4))) .child(div().text_sm().child("Import identity")) .on_click(cx.listener(|this, _ev, window, cx| { this.open_import(window, cx) })), ), ), ) } } fn pick_banner() -> SharedString { let num = SystemTime::now() .duration_since(UNIX_EPOCH) .expect("system clock before unix epoch") .subsec_nanos() % 3 + 1; format!("backgrounds/banner{num}.jpg").into() } impl BasePanel for SidebarPanel { fn panel_name(&self) -> &'static str { "sidebar" } fn closable(&self, _cx: &App) -> bool { false } } impl Panel for SidebarPanel { fn title(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() } } impl EventEmitter for SidebarPanel {} impl Focusable for SidebarPanel { fn focus_handle(&self, _cx: &App) -> FocusHandle { self.focus_handle.clone() } } impl Render for SidebarPanel { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { if !self.logged_in { return self.render_sign_in(window, cx); } let backend = Backend::global(cx); let profile_store = ProfileStore::global(cx); let profile = backend .read(cx) .current_user() .map(|public_key| profile_store.read(cx).get(&public_key)); v_flex() .size_full() .justify_between() .image_cache(image_cache("sidebar", MAX_IMAGES)) .bg(cx.theme().sidebar) .text_color(cx.theme().sidebar_foreground) .child( v_flex() .flex_1() .min_h_0() .when_some(profile.as_ref(), |this, profile| { this.child(self.render_user(profile, window, cx)) }) .child( v_flex() .px_2() .gap_1() .items_start() .justify_start() .child( NavItem::new("inbox", "Inbox", Icon::new(IconName::Inbox).small()) .on_click(cx.listener(|this, _ev, window, cx| { this.open_explore(window, cx) })), ) .child( NavItem::new( "explore", "Browse", Icon::new(IconName::Globe).small(), ) .on_click(cx.listener( |this, _ev, window, cx| this.open_explore(window, cx), )), ) .child( NavItem::new( "search", "Search", Icon::new(IconName::Search).small(), ) .on_click(cx.listener( |this, _ev, window, cx| this.open_explore(window, cx), )), ), ) .child(self.render_my_repos(cx)), ) .child( v_flex() .p_2() .flex_shrink_0() .gap_1() .items_start() .justify_start() .child( NavItem::new("guide", "Guide", Icon::new(IconName::Info).small()).on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), ), ) .child( NavItem::new( "settings", "Settings", Icon::new(IconName::Settings).small(), ) .on_click( cx.listener(|this, _ev, window, cx| this.open_explore(window, cx)), ), ), ) } } /// A single navigation entry in the sidebar: an arbitrary leading element /// (an icon, avatar, ...) and a text label with a hover highlight and an /// optional click handler. #[allow(clippy::type_complexity)] #[derive(IntoElement)] struct NavItem { id: ElementId, style: StyleRefinement, icon: AnyElement, label: SharedString, on_click: Option>, } impl NavItem { fn new(id: I, label: L, icon: N) -> Self where I: Into, L: Into, N: IntoElement, { Self { id: id.into(), icon: icon.into_any_element(), label: label.into(), style: StyleRefinement::default(), on_click: None, } } fn on_click(mut self, listener: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static) -> Self { self.on_click = Some(Box::new(listener)); self } } impl RenderOnce for NavItem { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { h_flex() .id(self.id) .refine_style(&self.style) .px_2() .py_1() .w_full() .gap_2() .rounded(cx.theme().radius) .child(self.icon) .child( div() .flex_1() .min_w_0() .text_sm() .whitespace_nowrap() .text_ellipsis() .child(self.label), ) .hover(|this| this.bg(cx.theme().list_hover)) .when_some(self.on_click, |this, listener| this.on_click(listener)) } }