wire up app shell with dock, sidebar, and dialogs
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:
2026-11-11 19:41:38 +07:00
parent c32030cf21
commit 1f65a0111f
12 changed files with 1297 additions and 8 deletions
+369
View File
@@ -0,0 +1,369 @@
use std::collections::HashMap;
use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};
use gpui::{
App, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString,
Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px,
};
use gpui_native_kit::avatar::Avatar;
use gpui_native_kit::button::{Button, ButtonVariant};
use gpui_native_kit::common::{h_flex, v_flex};
use gpui_native_kit::data::sidebar::Sidebar;
use gpui_native_kit::icons::{Icon, IconRole};
use gpui_native_kit::layout::dock::DockState;
use gpui_native_kit::menu::{Menu, MenuItem};
use gpui_native_kit::theme::ActiveTheme as _;
use metadata::{Profile, ProfileDirectory};
use nostr::prelude::PublicKey;
use signed_core::{Announcement, RepoAddr};
use signed_state::{
Backend, BackendEvent, CheckoutsStore, LocalReposStore, RepoListStore, ResolvedLocalRepo,
SyncStatusDirectory,
};
use crate::dialogs;
pub struct SidebarView {
weak: WeakEntity<Self>,
dock: WeakEntity<DockState>,
banner: SharedString,
scanning: bool,
/// User's announced repositories.
announcements: Arc<Vec<Announcement>>,
/// Local repositories found by the scan that are not announced yet.
local_repos: Arc<Vec<ResolvedLocalRepo>>,
/// Unpushed commit counts per announced repository.
unpushed: HashMap<RepoAddr, usize>,
/// Profiles rendered by the sidebar.
directory: gpui::Entity<ProfileDirectory>,
/// Sync statuses for the announced repositories.
sync_directory: gpui::Entity<SyncStatusDirectory>,
user_menu_open: bool,
_subscriptions: Vec<Subscription>,
}
impl SidebarView {
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> 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_required {
this.banner = pick_banner();
cx.notify();
}
if this.refresh(cx) || signer_changed {
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 {
weak: cx.entity().downgrade(),
dock,
banner: pick_banner(),
announcements: Arc::new(Vec::new()),
local_repos: Arc::new(Vec::new()),
scanning: false,
unpushed: HashMap::new(),
directory: ProfileDirectory::new(cx),
sync_directory: SyncStatusDirectory::new(cx),
user_menu_open: false,
_subscriptions: subscriptions,
}
}
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
let backend = Backend::global(cx);
let user = backend.read(cx).current_user();
let (announcements, local_repos, scanning) = {
let repo_list = RepoListStore::global(cx).read(cx);
let announcements = user
.as_ref()
.map(|user| repo_list.announcements_of(user))
.unwrap_or_default();
let local = LocalReposStore::global(cx).read(cx);
let local_repos =
LocalReposStore::resolve(&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;
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
let owners: Vec<PublicKey> = self
.local_repos
.iter()
.filter_map(|entry| {
entry
.nip34
.as_ref()
.and_then(|binding| binding.owner)
.filter(|owner| !profiles.contains(owner))
})
.collect();
profiles.extend(owners);
self.directory.update(cx, |directory, cx| {
directory.track(profiles, cx);
});
let addrs: Vec<RepoAddr> = self
.announcements
.iter()
.map(|announcement| announcement.addr())
.collect();
self.sync_directory.update(cx, |directory, cx| {
directory.track(addrs, cx);
});
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<Self>) -> 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<Self>) {
let checkouts = CheckoutsStore::global(cx);
checkouts.update(cx, |checkouts, cx| {
for announcement in self.announcements.iter() {
checkouts.request_push_statuses(&announcement.addr(), cx);
}
});
}
/// Renders the sidebar for the current identity state.
pub fn build(&self, cx: &App) -> Sidebar {
let backend = Backend::global(cx);
let profile = backend
.read(cx)
.current_user()
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
let Some(profile) = profile else {
return Sidebar::new("signed-sidebar").content(self.render_sign_in(cx));
};
let weak = self.weak.clone();
Sidebar::new("signed-sidebar")
.transparent(true)
.header(self.render_user(&profile, cx))
.entries(|_, _| Vec::new())
.on_change(move |index, window, cx| {
let _ = weak.update(cx, |this, cx| this.activate(index, window, cx));
})
}
fn activate(&mut self, _index: Option<usize>, _window: &mut Window, _cx: &mut Context<Self>) {
// Repository and navigation rows arrive with their screens.
}
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
if let Err(error) = self
.dock
.update(cx, |dock, cx| dock.open_panel(id, window, cx))
{
log::warn!("failed to open panel {id}: {error}");
}
}
fn render_user(&self, profile: &Profile, cx: &App) -> impl IntoElement {
let name = profile.name();
let picture = profile.picture();
let public_key = profile.public_key();
let weak = self.weak.clone();
let weak_for_open = self.weak.clone();
let muted = cx.theme().colors.muted_foreground;
Menu::new("user-menu")
.open(self.user_menu_open)
.on_change({
let weak = weak_for_open.clone();
move |open, _, cx| {
if let Err(error) = weak.update(cx, |this, cx| {
this.user_menu_open = open;
cx.notify();
}) {
log::warn!("failed to update user menu: {error}");
}
}
})
.trigger({
let name = name.clone();
let picture = picture.clone();
move |_, _| {
h_flex()
.gap_1()
.items_center()
.child(
Avatar::new(public_key)
.name(name.clone())
.picture(picture.clone()),
)
.child(
div()
.text_xs()
.font_weight(FontWeight::SEMIBOLD)
.child(name.clone()),
)
.child(
Icon::named(IconRole::ChevronDown)
.size(px(12.))
.color(muted),
)
.into_any_element()
}
})
.items(move |_, _| {
let profile_weak = weak.clone();
let relays_weak = weak.clone();
vec![
MenuItem::new("View Profile").handler(move |window, cx| {
if let Err(error) = profile_weak.update(cx, |this, cx| {
this.open_panel("profile", window, cx);
}) {
log::warn!("failed to open profile: {error}");
}
}),
MenuItem::new("View Relays").handler(move |window, cx| {
if let Err(error) = relays_weak.update(cx, |this, cx| {
this.open_panel("relays", window, cx);
}) {
log::warn!("failed to open relays: {error}");
}
}),
MenuItem::separator(),
MenuItem::new("Exit")
.destructive(true)
.handler(|_, cx| dialogs::exit::open(cx)),
]
})
}
/// Shown while no identity is signed in.
fn render_sign_in(&self, _cx: &App) -> impl IntoElement {
let weak = self.weak.clone();
let import_weak = self.weak.clone();
v_flex()
.size_full()
.relative()
.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()
.gap_4()
.child(img("backgrounds/headline.png").max_w_48())
.child(
v_flex()
.gap_1()
.w_full()
.child(Button::new("onboarding").label("Join now").on_click(
move |_, window, cx| {
if let Err(error) = weak.update(cx, |this, cx| {
this.open_panel("welcome", window, cx);
crate::dialogs::open_onboarding(cx);
}) {
log::warn!("failed to open onboarding: {error}");
}
},
))
.child(
Button::new("import")
.label("Import identity")
.variant(ButtonVariant::Secondary)
.on_click(move |_, _, cx| {
if let Err(error) = import_weak
.update(cx, |_, cx| crate::dialogs::open_import(cx))
{
log::warn!("failed to open import: {error}");
}
}),
),
),
)
}
}
fn pick_banner() -> SharedString {
let num = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|duration| duration.subsec_nanos())
.unwrap_or(1)
% 3
+ 1;
format!("backgrounds/banner{num}.jpg").into()
}