use std::collections::HashMap; use std::ops::Range; use std::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, Entity, 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 nostr::prelude::RelayUrl; use signed_core::{Announcement, RepoAddr}; use signed_state::{ Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, Profile, ProfileStore, RepoListStore, ResolvedLocalRepo, resolve_local_repos, }; use signed_ui::{NavItem, PixelAvatar, UserAvatar, title_bar_drag_handlers}; use super::{InboxView, 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; /// The platform that bound a repository, `"nak"` or `"ngit"`. fn local_platform(binding: &Nip34Binding) -> Option<&'static str> { let signals = &binding.signals; if signals.nip34_json || signals.nip34_grasp_remote || signals.nip34_state_refs { Some("nak") } else if signals.nostr_repo_config { Some("ngit") } else { None } } pub struct SidebarPanel { focus_handle: FocusHandle, dock_area: WeakEntity, inbox: Option>, explore: Option>, banner: SharedString, /// User's announced repositories. announcements: Arc>, /// Local repositories found by the scan that are not announced yet. local_repos: Arc>, scanning: bool, /// Unpushed commit counts per announced repository. 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(); cx.notify(); } if this.refresh(cx) || signer_required { cx.notify(); } })); subscriptions.push(cx.observe(&repos, |this, _repos, cx| { if this.refresh(cx) { cx.notify(); } })); subscriptions.push(cx.observe(&local, |this, _local, cx| { if this.refresh(cx) { cx.notify(); } })); // Push statuses are recomputed in the background, so only the badge counts change. subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| { if this.refresh_unpushed(cx) { cx.notify(); } })); Self { focus_handle: cx.focus_handle(), dock_area, inbox: None, explore: None, banner: pick_banner(), announcements: Arc::new(Vec::new()), local_repos: Arc::new(Vec::new()), scanning: false, unpushed: HashMap::new(), _subscriptions: subscriptions, } } fn refresh(&mut self, cx: &mut Context) -> bool { let backend = Backend::global(cx); let user = backend.read(cx).current_user(); let (announcements, local_repos, scanning) = { let repo_list = RepoListStore::global(cx); let repo_list = repo_list.read(cx); let announcements = user .as_ref() .map(|user| repo_list.announcements_of(user)) .unwrap_or_default(); let local = LocalReposStore::global(cx); let local = local.read(cx); let local_repos = resolve_local_repos(&local.repos, &repo_list.announcements, &announcements); (announcements, local_repos, local.scanning) }; 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 } fn refresh_unpushed(&mut self, cx: &mut Context) -> bool { let checkouts = CheckoutsStore::global(cx); let mut unpushed = HashMap::with_capacity(self.announcements.len()); for announcement in self.announcements.iter() { let addr = announcement.addr(); let count = checkouts.read(cx).unpushed(&addr); if count > 0 { unpushed.insert(addr, count); } } if unpushed == self.unpushed { return false; } self.unpushed = unpushed; true } 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); } }); } pub fn open_inbox(&mut self, window: &mut Window, cx: &mut Context) { if self.inbox.as_ref().and_then(WeakEntity::upgrade).is_some() { return; } let panel = cx.new(|cx| InboxView::new(self.dock_area.clone(), cx)); self.inbox = Some(panel.downgrade()); self.dock_area .update(cx, |dock_area, cx| { add_center_panel(dock_area, panel_handle(panel), window, cx); }) .ok(); } 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()); self.dock_area .update(cx, |dock_area, cx| { add_center_panel(dock_area, panel_handle(panel), window, cx); }) .ok(); } 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); } fn open_create_repo(&mut self, window: &mut Window, cx: &mut Context) { create_repo_dialog::open(self.dock_area.clone(), window, cx); } fn open_repo( &mut self, announcement: &Announcement, window: &mut Window, cx: &mut Context, ) { open_repo_panel( &self.dock_area, &announcement.addr(), Some(announcement), window, &mut *cx, ); } /// The detail view offers to publish it to NIP-34. fn open_local_repo( &mut self, path: PathBuf, nip34: Option, window: &mut Window, cx: &mut Context, ) { let detail = cx.new(|cx| RepoDetailView::new_local(self.dock_area.clone(), path, nip34, window, cx)); self.add_detail_panel(detail, window, cx); } /// A local repository whose binding matches an announcement opens as the announced repository. fn open_local_announced( &mut self, announcement: Announcement, path: PathBuf, window: &mut Window, cx: &mut Context, ) { let detail = cx.new(|cx| { RepoDetailView::new_local_announced( self.dock_area.clone(), announcement, path, window, cx, ) }); self.add_detail_panel(detail, window, cx); } /// A local repository opens as the announced repository /// when its binding matches one, and as a local-only repository otherwise. fn open_local_entry( &mut self, entry: ResolvedLocalRepo, window: &mut Window, cx: &mut Context, ) { let ResolvedLocalRepo { path, nip34, announcement, } = entry; if let Some(announcement) = announcement { self.open_local_announced(announcement, path, window, cx); return; } self.open_local_repo(path, nip34, window, cx); } fn add_detail_panel( &mut self, detail: Entity, window: &mut Window, cx: &mut Context, ) { 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| { // The merged list: NIP-34 repositories first, then discovered local repositories. 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(), ) } }) } fn render_repo_at( &self, announcements: &[Announcement], local_repos: &[ResolvedLocalRepo], 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 entry = &local_repos[local_ix]; self.render_local_row(entry, 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(); 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)), ) } fn render_local_row( &self, entry: &ResolvedLocalRepo, cx: &mut Context, ) -> impl IntoElement { let name = entry.name(); let avatar = local_avatar(entry, cx); let id = format!("local-repo:{}", entry.path.display()); let entry = entry.clone(); let suffix: AnyElement = match entry.nip34.as_ref() { Some(binding) => { let (label, tooltip) = match binding.kind { Nip34Kind::Initialized => { let platform = local_platform(binding).unwrap_or("Grasp"); let tooltip = match platform { "nak" => "Initialized with nak", "ngit" => "Initialized with ngit", _ => "Initialized for NIP-34", }; (platform, tooltip) } Nip34Kind::Cloned => ("Cloned", "Cloned buts not initialized"), Nip34Kind::ToolingOnly => ("Tooling", "Grasp tooling only"), }; Button::new(id.clone()) .xsmall() .child(div().text_size(px(10.)).child(label)) .tooltip(tooltip) .secondary() .into_any_element() } None => Button::new(id.clone()) .xsmall() .icon(IconName::TriangleAlert) .tooltip("Not published yet") .ghost() .into_any_element(), }; NavItem::new(id, name, avatar) .suffix(suffix) .on_click(cx.listener(move |this, _ev, window, cx| { this.open_local_entry(entry.clone(), window, cx); })) } fn open_import(&mut self, window: &mut Window, cx: &mut Context) { import_dialog::open(window, cx); } /// The user avatar and name, wired into 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, ) } /// Shown while no identity is signed in. 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) })), ), ), ) } } /// Normalize a user-typed grasp server, adding a `wss://` scheme when none is given. /// /// Returns the text to store and the parsed relay URL, or `None` when it is not a valid relay URL. pub(super) fn normalize_server(input: &str) -> Option<(String, RelayUrl)> { let text = if input.contains("://") { input.to_owned() } else { format!("wss://{input}") }; RelayUrl::parse(&text).ok().map(|relay| (text, relay)) } /// The host of a relay URL, which is what the server lists show; the scheme is implied. pub(super) fn server_host(relay: &RelayUrl) -> SharedString { relay .domain() .map(SharedString::from) .unwrap_or_else(|| SharedString::from(relay.to_string())) } /// The repository's pixel avatar, with the bound owner's avatar at its bottom right. fn local_avatar(entry: &ResolvedLocalRepo, cx: &App) -> AnyElement { let avatar = PixelAvatar::new(entry.path.to_string_lossy()); let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else { return avatar.into_any_element(); }; let store = ProfileStore::global(cx); let profile = store.read(cx).get(&owner); div() .relative() .child(avatar) .child( div().absolute().bottom_neg_0p5().right_neg_0p5().child( UserAvatar::new(profile.name()) .picture(profile.picture()) .size(px(14.)), ), ) .into_any_element() } 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() .image_cache(gpui::retain_all("sidebar")) .size_full() .justify_between() .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_inbox(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); })), ), ) } }