use std::collections::{HashMap, HashSet}; use std::ops::Range; use std::path::{Path, PathBuf}; use std::sync::Arc; use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; use dock::{ BasePanel, DockArea, Panel, PanelEvent, TAB_BAR_HEIGHT, add_center_panel, panel_handle, }; use gpui::prelude::*; use gpui::{ AnyElement, App, Context, Div, EventEmitter, FocusHandle, Focusable, ObjectFit, Render, SharedString, Subscription, WeakEntity, Window, div, img, px, relative, uniform_list, white, }; use gpui_base::Button as BaseButton; 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, RepoAddr, identifier_from_name}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Profile, ProfileStore, RepoListStore, }; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{RepoDetailView, RepoListView, open_repo_panel}; mod create_repo_dialog; pub(crate) mod grasp_servers; mod import_dialog; mod onboarding_dialog; pub(crate) mod passphrase_dialog; mod settings_dialog; use self::onboarding_dialog::OnboardingState; pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, explore: Option>, /// Artwork for the sign-in screen. banner: SharedString, /// The signed-in user's announced repositories, newest first. announcements: Arc>, /// Local repositories found by the scan that are not announced yet. local_repos: Arc>, /// A local scan is currently running. scanning: bool, /// Unpushed local commits per announced repository, the row badge counts. unpushed: HashMap, _subscriptions: Vec, } impl SidebarPanel { pub fn new(dock_area: WeakEntity, cx: &mut Context) -> Self { let backend = Backend::global(cx); let repos = RepoListStore::global(cx); let local = LocalReposStore::global(cx); let checkouts = CheckoutsStore::global(cx); let mut subscriptions = Vec::new(); // Identity changes swap the whole sidebar between the sign-in screen and the signed-in content. subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| { let signer_changed = matches!(event, BackendEvent::SignerChanged); let signer_required = matches!(event, BackendEvent::SignerRequired); if !signer_changed && !signer_required { return; } if signer_required { this.banner = pick_banner(); } if this.refresh(cx) || signer_required { cx.notify(); } })); // The merged list re-derives when announcements or the local scan change. subscriptions.push(cx.observe(&repos, |this, _repos, cx| { if this.refresh(cx) { cx.notify(); } })); // The local scan re-derives when announcements or the local scan change. subscriptions.push(cx.observe(&local, |this, _local, cx| { if this.refresh(cx) { cx.notify(); } })); // Push statuses are recomputed in the background; only the badge counts change. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_unpushed(cx) { cx.notify(); } })); let mut this = Self { focus_handle: cx.focus_handle(), dock_area, explore: None, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), scanning: false, unpushed: HashMap::new(), _subscriptions: subscriptions, }; // Seed the snapshot right away. // The stores may already hold data from before the panel opened. // The first render must not depend on a later store update. this.refresh(cx); this } /// The sidebar renders only its own derived fields, never the stores /// directly. Because the panel is a cached view, a store update alone does /// not re-render it: the observers notify this panel, which re-runs /// `render` over the fresh snapshot. /// /// Returns `true` when a rendered field changed. fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); let user = backend.read(cx).current_user(); let repo_list = RepoListStore::global(cx); let announcements = user .as_ref() .map(|user| repo_list.read(cx).announcements_of(user)) .unwrap_or_default(); // A scanned repository is dropped from the local list // once the user announces it, so it is not listed twice. let local = LocalReposStore::global(cx); let scanning = local.read(cx).scanning; let local_repos = { let ids: HashSet = announcements.iter().map(|a| a.id.clone()).collect(); local .read(cx) .repos .iter() .filter(|path| { let Some(name) = path.file_name() else { return true; }; !ids.contains(&identifier_from_name(&name.to_string_lossy())) }) .cloned() .collect() }; let announcements_changed = *self.announcements != announcements; let local_changed = *self.local_repos != local_repos; let scanning_changed = self.scanning != scanning; self.announcements = Arc::new(announcements); self.local_repos = Arc::new(local_repos); self.scanning = scanning; if announcements_changed { self.request_push_watches(cx); self.unpushed.clear(); } announcements_changed || local_changed || scanning_changed } /// Recompute the badge counts from the global checkouts store's ready-to-push statuses fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { let checkouts = CheckoutsStore::global(cx).read(cx); let mut unpushed = HashMap::with_capacity(self.announcements.len()); for announcement in self.announcements.iter() { let addr = announcement.addr(); let count = checkouts.unpushed(&addr); if count > 0 { unpushed.insert(addr, count); } } if unpushed == self.unpushed { return false; } self.unpushed = unpushed; true } /// Keep the `ready to push` statuses of the announced repositories current. fn request_push_watches(&self, cx: &mut Context) { let checkouts = CheckoutsStore::global(cx); checkouts.update(cx, |checkouts, cx| { for announcement in self.announcements.iter() { checkouts.request_push_statuses(&announcement.addr(), cx); } }); } /// Open the Explore repository list panel in the dock area's center. 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| { add_center_panel(dock_area, panel_handle(panel), 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, ) { open_repo_panel(&self.dock_area, announcement, window, &mut *cx); } /// Open a local repository's detail view in the dock's center. /// /// The detail view offers to publish it to NIP-34. fn open_local_repo(&mut self, path: PathBuf, window: &mut Window, cx: &mut Context) { let detail = cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, window, cx)); self.dock_area .update(cx, |dock_area, cx| { add_center_panel(dock_area, panel_handle(detail), window, cx); }) .ok(); } fn render_repos(&self, cx: &mut Context) -> impl IntoElement { let announcements = self.announcements.clone(); let local_repos = self.local_repos.clone(); let scanning = self.scanning; 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( h_flex() .gap_1() .child( Button::new("rescan") .icon(CustomIconName::Refresh) .small() .ghost() .tooltip("Rescan for local repositories") .on_click(cx.listener(|_this, _ev, _window, cx| { let local_repos = LocalReposStore::global(cx); local_repos.update(cx, |store, cx| store.rescan(cx)); })), ) .child( Button::new("add") .icon(IconName::Plus) .small() .ghost() .on_click(cx.listener(|this, _ev, window, cx| { this.open_create_repo(window, cx); })), ), ), ) .map(|this| { // Merged list, the user's NIP-34 repositories and local repositories discovered. let total = announcements.len() + local_repos.len(); if total == 0 { this.child( div() .flex_1() .px_2() .py_1() .text_xs() .text_color(cx.theme().muted_foreground) .map(|this| { if scanning { this.child("Scanning for local repositories…") } else { this.child("No repositories yet") } }), ) } else { this.child( uniform_list( "repos", total, cx.processor(move |this, range: Range, _, cx| { range .map(|ix| { this.render_repo_at(&announcements, &local_repos, ix, cx) .into_any_element() }) .collect() }), ) .flex_1() .min_h_0(), ) } }) } /// One row of the merged sidebar list, a NIP-34 or a local repository. fn render_repo_at( &self, announcements: &[Announcement], local_repos: &[PathBuf], ix: usize, cx: &mut Context, ) -> AnyElement { if ix < announcements.len() { return self .render_repo_row(&announcements[ix], cx) .into_any_element(); } let local_ix = ix - announcements.len(); let path = &local_repos[local_ix]; self.render_local_row(path, cx).into_any_element() } fn render_repo_row( &self, announcement: &Announcement, cx: &mut Context, ) -> impl IntoElement { let name = announcement.name().map(SharedString::from); let avatar = PixelAvatar::new(format!("{}:{}", announcement.owner, announcement.id)); let announcement = announcement.clone(); // Badge with the unpushed commit count of the repository's local checkouts. let unpushed = self .unpushed .get(&announcement.addr()) .copied() .unwrap_or(0); let mut row = NavItem::new(format!("repo:{}", announcement.id), name, avatar); if unpushed > 0 { row = row.suffix( v_flex() .flex_shrink_0() .size_4() .items_center() .justify_center() .rounded_full() .line_height(relative(1.)) .bg(cx.theme().red_light) .text_color(white()) .text_size(px(8.)) .child(SharedString::from(unpushed.to_string())), ); } row.on_click( cx.listener(move |this, _ev, window, cx| this.open_repo(&announcement, window, cx)), ) } /// One local repository row. /// /// The directory name and a warning suffix, the repo is not yet set up for NIP-34. fn render_local_row(&self, path: &Path, cx: &mut Context) -> impl IntoElement { let name = path .file_name() .map(|name| name.to_string_lossy().into_owned()) .unwrap_or("Untitled".into()); let path = path.to_path_buf(); let avatar = PixelAvatar::new(path.to_string_lossy()); NavItem::new(format!("local-repo:{}", path.display()), name, avatar) .suffix( Icon::new(IconName::TriangleAlert) .small() .text_color(cx.theme().warning), ) .on_click(cx.listener(move |this, _ev, window, cx| { this.open_local_repo(path.clone(), 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, inside the 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(UserAvatar::new(name.clone()).picture(picture)) .child(div().text_xs().font_semibold().child(name)), ), ), ), window, cx, ) } /// Sign-in placeholder shown while logged out. 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 { 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)); if profile.is_none() { return self.render_sign_in(window, cx); } v_flex() .size_full() .justify_between() .image_cache(gpui::retain_all("sidebar")) .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_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(|_, _ev, window, cx| { settings_dialog::open(window, cx); })), ), ) } }