From 530be762ff8911bb5600d40aeb6b9c8b7b4b0c1c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Thu, 12 Nov 2026 07:53:23 +0700 Subject: [PATCH] restructure signed_app into flat modules and bump gpui-native-kit --- Cargo.lock | 16 +- Cargo.toml | 2 +- crates/assets/src/lib.rs | 1 + crates/signed_app/src/app/helpers.rs | 12 + crates/signed_app/src/{app.rs => app/mod.rs} | 29 +- .../src/{views => app}/placeholder.rs | 0 crates/signed_app/src/components.rs | 32 -- crates/signed_app/src/explore/helpers.rs | 191 +++++++ .../src/{views/explore.rs => explore/mod.rs} | 201 +------ .../src/{dialogs => identity}/exit.rs | 10 +- crates/signed_app/src/identity/helpers.rs | 23 + .../src/{dialogs => identity}/import.rs | 16 +- crates/signed_app/src/identity/mod.rs | 5 + .../src/{dialogs => identity}/onboarding.rs | 8 +- .../src/{dialogs => identity}/passphrase.rs | 12 +- .../src/{views => }/issues/detail.rs | 10 +- .../discussion.rs => issues/helpers.rs} | 83 ++- .../signed_app/src/{views => }/issues/mod.rs | 35 +- .../src/{dialogs => issues}/new_issue.rs | 2 +- crates/signed_app/src/lib.rs | 13 +- .../src/{views => }/repo/actions.rs | 4 +- .../src/{views => }/repo/banners.rs | 0 .../src/{views => }/repo/commit_diff.rs | 145 +---- .../src/{ => repo}/dialogs/about.rs | 2 +- .../create_repo.rs => repo/dialogs/create.rs} | 6 +- .../src/{ => repo}/dialogs/grasp_servers.rs | 0 .../init_repo.rs => repo/dialogs/init.rs} | 6 +- crates/signed_app/src/repo/dialogs/mod.rs | 5 + .../reset_remote.rs => repo/dialogs/reset.rs} | 2 +- .../signed_app/src/{views => }/repo/files.rs | 89 +-- crates/signed_app/src/repo/helpers.rs | 514 ++++++++++++++++++ .../src/{views => }/repo/history.rs | 87 +-- crates/signed_app/src/{views => }/repo/mod.rs | 305 +---------- .../signed_app/src/{views => }/repo/refs.rs | 40 +- .../src/{dialogs/mod.rs => shared/dialogs.rs} | 22 - crates/signed_app/src/shared/mod.rs | 5 + crates/signed_app/src/{ => shared}/panels.rs | 0 .../src/{dialogs => shared}/progress.rs | 0 .../signed_app/src/{views => shared}/tree.rs | 0 crates/signed_app/src/shared/widgets.rs | 98 ++++ crates/signed_app/src/sidebar/helpers.rs | 51 ++ .../src/{views/sidebar.rs => sidebar/mod.rs} | 75 +-- crates/signed_app/src/views/mod.rs | 8 - crates/signed_app/src/views/status_list.rs | 45 -- 44 files changed, 1132 insertions(+), 1078 deletions(-) create mode 100644 crates/signed_app/src/app/helpers.rs rename crates/signed_app/src/{app.rs => app/mod.rs} (79%) rename crates/signed_app/src/{views => app}/placeholder.rs (100%) delete mode 100644 crates/signed_app/src/components.rs create mode 100644 crates/signed_app/src/explore/helpers.rs rename crates/signed_app/src/{views/explore.rs => explore/mod.rs} (58%) rename crates/signed_app/src/{dialogs => identity}/exit.rs (87%) create mode 100644 crates/signed_app/src/identity/helpers.rs rename crates/signed_app/src/{dialogs => identity}/import.rs (99%) create mode 100644 crates/signed_app/src/identity/mod.rs rename crates/signed_app/src/{dialogs => identity}/onboarding.rs (97%) rename crates/signed_app/src/{dialogs => identity}/passphrase.rs (94%) rename crates/signed_app/src/{views => }/issues/detail.rs (96%) rename crates/signed_app/src/{views/discussion.rs => issues/helpers.rs} (79%) rename crates/signed_app/src/{views => }/issues/mod.rs (95%) rename crates/signed_app/src/{dialogs => issues}/new_issue.rs (98%) rename crates/signed_app/src/{views => }/repo/actions.rs (93%) rename crates/signed_app/src/{views => }/repo/banners.rs (100%) rename crates/signed_app/src/{views => }/repo/commit_diff.rs (82%) rename crates/signed_app/src/{ => repo}/dialogs/about.rs (99%) rename crates/signed_app/src/{dialogs/create_repo.rs => repo/dialogs/create.rs} (98%) rename crates/signed_app/src/{ => repo}/dialogs/grasp_servers.rs (100%) rename crates/signed_app/src/{dialogs/init_repo.rs => repo/dialogs/init.rs} (98%) create mode 100644 crates/signed_app/src/repo/dialogs/mod.rs rename crates/signed_app/src/{dialogs/reset_remote.rs => repo/dialogs/reset.rs} (98%) rename crates/signed_app/src/{views => }/repo/files.rs (90%) create mode 100644 crates/signed_app/src/repo/helpers.rs rename crates/signed_app/src/{views => }/repo/history.rs (69%) rename crates/signed_app/src/{views => }/repo/mod.rs (87%) rename crates/signed_app/src/{views => }/repo/refs.rs (76%) rename crates/signed_app/src/{dialogs/mod.rs => shared/dialogs.rs} (76%) create mode 100644 crates/signed_app/src/shared/mod.rs rename crates/signed_app/src/{ => shared}/panels.rs (100%) rename crates/signed_app/src/{dialogs => shared}/progress.rs (100%) rename crates/signed_app/src/{views => shared}/tree.rs (100%) create mode 100644 crates/signed_app/src/shared/widgets.rs create mode 100644 crates/signed_app/src/sidebar/helpers.rs rename crates/signed_app/src/{views/sidebar.rs => sidebar/mod.rs} (91%) delete mode 100644 crates/signed_app/src/views/mod.rs delete mode 100644 crates/signed_app/src/views/status_list.rs diff --git a/Cargo.lock b/Cargo.lock index 87c88d7..f24a715 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3059,7 +3059,7 @@ dependencies = [ [[package]] name = "gpui-native-core" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "cpal", "flume 0.12.0", @@ -3074,7 +3074,7 @@ dependencies = [ [[package]] name = "gpui-native-data" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "gpui", "gpui-native-core", @@ -3086,7 +3086,7 @@ dependencies = [ [[package]] name = "gpui-native-editor" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "gpui", "gpui-native-core", @@ -3105,7 +3105,7 @@ dependencies = [ [[package]] name = "gpui-native-icons" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "anyhow", "gpui", @@ -3114,7 +3114,7 @@ dependencies = [ [[package]] name = "gpui-native-kit" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "gpui", "gpui-native-core", @@ -3128,7 +3128,7 @@ dependencies = [ [[package]] name = "gpui-native-layout" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "gpui", "gpui-native-core", @@ -3141,7 +3141,7 @@ dependencies = [ [[package]] name = "gpui-native-theme" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "anyhow", "async-channel", @@ -3162,7 +3162,7 @@ dependencies = [ [[package]] name = "gpui-native-utils" version = "0.1.0" -source = "git+https://git.reya.info/reya/gpui-native-kit?rev=97106b7a3a54ec929c47feb70cdab4bed044d20e#97106b7a3a54ec929c47feb70cdab4bed044d20e" +source = "git+https://git.reya.info/reya/gpui-native-kit?rev=72118bdd6c6c6a90f17f92f767c6ad342c1516b8#72118bdd6c6c6a90f17f92f767c6ad342c1516b8" dependencies = [ "gpui", "gpui-native-theme", diff --git a/Cargo.toml b/Cargo.toml index bb5465e..79dde44 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,7 +14,7 @@ gpui = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503de gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc", features = ["font-kit"] } reqwest_client = { git = "https://github.com/zed-industries/zed", rev = "f8cfc7ed9e57503def9479138894f3139f5400cc" } -gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "97106b7a3a54ec929c47feb70cdab4bed044d20e", features = ["layout", "editor"] } +gpui-native-kit = { git = "https://git.reya.info/reya/gpui-native-kit", rev = "72118bdd6c6c6a90f17f92f767c6ad342c1516b8", features = ["layout", "editor"] } settings = { path = "crates/settings" } signed_app = { path = "crates/signed_app" } diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 7462a1c..bee031e 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -96,3 +96,4 @@ impl IconNamed for CustomIconName { .into() } } + diff --git a/crates/signed_app/src/app/helpers.rs b/crates/signed_app/src/app/helpers.rs new file mode 100644 index 0000000..b9f3ec8 --- /dev/null +++ b/crates/signed_app/src/app/helpers.rs @@ -0,0 +1,12 @@ +use gpui::App; +use gpui_native_kit::theme::{ThemeVariant, follow_system, set_mode_override}; +use settings::{AppearanceMode, Settings}; + +/// Applies the persisted light/dark/system preference to the kit theme. +pub fn apply_appearance(settings: &Settings, cx: &mut App) { + match settings.appearance { + AppearanceMode::System => follow_system(cx), + AppearanceMode::Light => set_mode_override(Some(ThemeVariant::Light), cx), + AppearanceMode::Dark => set_mode_override(Some(ThemeVariant::Dark), cx), + } +} diff --git a/crates/signed_app/src/app.rs b/crates/signed_app/src/app/mod.rs similarity index 79% rename from crates/signed_app/src/app.rs rename to crates/signed_app/src/app/mod.rs index 23b6278..7b6fdaa 100644 --- a/crates/signed_app/src/app.rs +++ b/crates/signed_app/src/app/mod.rs @@ -1,18 +1,23 @@ +pub mod helpers; +mod placeholder; + use gpui::{ - App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _, Subscription, Window, div, }; use gpui_native_kit::icons::IconRole; use gpui_native_kit::layout::dock::{Dock, DockState}; use gpui_native_kit::layout::shell::{LayoutState, Shell, SidebarToggles}; -use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override}; +use gpui_native_kit::theme::ActiveTheme as _; use gpui_native_kit::toast::Toast; -use settings::{AppearanceMode, Settings, SettingsStore}; +use helpers::apply_appearance; +use settings::SettingsStore; use signed_state::{Backend, BackendEvent}; -use crate::dialogs::{self, DialogHost}; -use crate::views::placeholder::PlaceholderView; -use crate::views::sidebar::SidebarView; +use crate::app::placeholder::PlaceholderView; +use crate::identity; +use crate::shared::dialogs::DialogHost; +use crate::sidebar::SidebarView; pub struct AppRoot { layout: Entity, @@ -53,7 +58,7 @@ impl AppRoot { subscriptions.push( cx.subscribe(&backend, |this, _backend, event, cx| match event { - BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx), + BackendEvent::PassphraseRequired => identity::helpers::open_passphrase(cx), BackendEvent::Error(message) => { this.layout.update(cx, |layout, cx| { layout.push_toast(Toast::new(message.clone()).icon(IconRole::Warning), cx); @@ -70,7 +75,7 @@ impl AppRoot { window.defer(cx, move |_window, cx| { if backend.read(cx).passphrase_required() { - dialogs::passphrase::open(cx); + identity::helpers::open_passphrase(cx); } }); @@ -104,11 +109,3 @@ impl Render for AppRoot { .child(self.dialogs.clone()) } } - -pub fn apply_appearance(settings: &Settings, cx: &mut App) { - match settings.appearance { - AppearanceMode::System => follow_system(cx), - AppearanceMode::Light => set_mode_override(Some(ThemeVariant::Light), cx), - AppearanceMode::Dark => set_mode_override(Some(ThemeVariant::Dark), cx), - } -} diff --git a/crates/signed_app/src/views/placeholder.rs b/crates/signed_app/src/app/placeholder.rs similarity index 100% rename from crates/signed_app/src/views/placeholder.rs rename to crates/signed_app/src/app/placeholder.rs diff --git a/crates/signed_app/src/components.rs b/crates/signed_app/src/components.rs deleted file mode 100644 index d047876..0000000 --- a/crates/signed_app/src/components.rs +++ /dev/null @@ -1,32 +0,0 @@ -use assets::CustomIconName; -use gpui_native_kit::badge::{Badge, BadgeVariant}; -use gpui_native_kit::icons::Icon; -use gpui_native_kit::tooltip::Tooltip; -use signed_core::RepoStatus; - -/// The status pill shown on issue and pull-request rows. -pub fn status_badge(status: RepoStatus) -> Badge { - let (label, variant, icon) = match status { - RepoStatus::Open => ("Open", BadgeVariant::Neutral, CustomIconName::GitIssueOpen), - RepoStatus::Closed => ( - "Closed", - BadgeVariant::Danger, - CustomIconName::GitIssueClosed, - ), - RepoStatus::Draft => ( - "Draft", - BadgeVariant::Warning, - CustomIconName::GitIssueOngoing, - ), - RepoStatus::Applied => ( - "Applied", - BadgeVariant::Success, - CustomIconName::GitIssueDone, - ), - }; - - Badge::new(format!("status-{label}"), label) - .variant(variant) - .icon(Icon::named(icon)) - .tooltip(Tooltip::text(label)) -} diff --git a/crates/signed_app/src/explore/helpers.rs b/crates/signed_app/src/explore/helpers.rs new file mode 100644 index 0000000..a455284 --- /dev/null +++ b/crates/signed_app/src/explore/helpers.rs @@ -0,0 +1,191 @@ +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, Entity, InteractiveElement as _, IntoElement, ParentElement as _, SharedString, + StatefulInteractiveElement as _, Styled as _, WeakEntity, div, px, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::layout::dock::DockState; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use signed_core::Announcement; +use signed_state::{RepoListStore, Timestamp}; +use utils::relative_time; + +use crate::repo::actions::open_repo_panel; + +const RECENT_COUNT: usize = 10; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(super) enum RepoFilter { + All, + #[default] + Popular, + Recent, +} + +impl RepoFilter { + /// Indices into the store's `announcements` this filter includes, in display order. + pub(super) fn visible(self, store: &RepoListStore, query: &str) -> Vec { + let announcements = &store.announcements; + let mut indices: Vec = (0..announcements.len()).collect(); + let query = query.trim().to_lowercase(); + + if !query.is_empty() { + indices.retain(|&ix| { + let announcement = &announcements[ix]; + let name = announcement.name.as_deref().unwrap_or(&announcement.id); + name.to_lowercase().contains(&query) + }); + } + + match self { + Self::All => {} + Self::Recent => indices.truncate(RECENT_COUNT), + Self::Popular => { + let counts = &store.counts; + let scores: Vec = announcements + .iter() + .map(|a| counts.get(&a.addr()).map_or(0, |c| c.score())) + .collect(); + indices.sort_by(|&a, &b| scores[b].cmp(&scores[a])); + } + } + + indices + } +} + +pub(super) fn render_card( + ix: usize, + announcement: &Announcement, + last_activity: Option, + announcements: &[Announcement], + directory: &Entity, + dock: &WeakEntity, + cx: &mut gpui::App, +) -> AnyElement { + let owner = directory.read(cx).profile(&announcement.owner, cx); + let owner_name = owner.name(); + let dock = dock.clone(); + let announcement = announcement.clone(); + + let name = announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())); + + let description = announcement + .description + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from("No description")); + + let activity = last_activity + .map(relative_time) + .map(|label| SharedString::from(format!("Updated {label}"))) + .unwrap_or_default(); + + let fork_label: Option = announcement.upstream.as_ref().and_then(|upstream| { + let addr = upstream.addr.as_ref()?; + let name = announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); + Some(SharedString::from(format!("forked from {name}"))) + }); + + v_flex() + .id(ix) + .flex_1() + .h_full() + .min_w_0() + .px_3() + .rounded(cx.theme().radius.control) + .border_1() + .border_color(cx.theme().colors.border) + .hover(|this| this.bg(cx.theme().colors.muted_hover)) + .child( + h_flex() + .h_10() + .gap_2() + .child( + div() + .min_w_0() + .text_sm() + .font_weight(gpui::FontWeight::SEMIBOLD) + .whitespace_nowrap() + .text_ellipsis() + .child(name), + ) + .when_some(fork_label, |this, label| { + this.child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child( + Icon::named(CustomIconName::GitBranch) + .size(px(12.)) + .color(cx.theme().colors.muted_foreground), + ) + .child(label), + ) + }), + ) + .child( + div() + .h_16() + .min_w_0() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .line_clamp(2) + .child(description), + ) + .child( + h_flex() + .h_12() + .gap_2() + .overflow_hidden() + .child( + h_flex() + .gap_2() + .child( + Avatar::new(announcement.owner) + .name(owner_name.clone()) + .picture(owner.picture()) + .small(), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child(owner_name), + ), + ) + .child( + h_flex() + .flex_1() + .justify_end() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .whitespace_nowrap() + .child(activity), + ), + ) + .on_click(move |_, window, cx| { + open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx); + }) + .into_any_element() +} diff --git a/crates/signed_app/src/views/explore.rs b/crates/signed_app/src/explore/mod.rs similarity index 58% rename from crates/signed_app/src/views/explore.rs rename to crates/signed_app/src/explore/mod.rs index f3c6885..7990a97 100644 --- a/crates/signed_app/src/views/explore.rs +++ b/crates/signed_app/src/explore/mod.rs @@ -1,12 +1,13 @@ +mod helpers; + use assets::CustomIconName; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, - Render, SharedString, StatefulInteractiveElement as _, Styled as _, Subscription, - UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, + Render, SharedString, Styled as _, Subscription, UniformListScrollHandle, WeakEntity, Window, + div, px, uniform_list, }; -use gpui_native_kit::avatar::Avatar; -use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::common::{h_flex, v_flex}; use gpui_native_kit::empty_state::EmptyState; use gpui_native_kit::icons::{Icon, IconRole}; use gpui_native_kit::input::{Input, InputState, InputStateEvent}; @@ -14,57 +15,13 @@ use gpui_native_kit::layout::dock::{DockState, Panel}; use gpui_native_kit::scrollbar::Scrollbar; use gpui_native_kit::segmented::{Segmented, SegmentedOption}; use gpui_native_kit::theme::ActiveTheme as _; +use helpers::{RepoFilter, render_card}; use metadata::ProfileDirectory; use nostr::prelude::PublicKey; -use signed_core::Announcement; -use signed_state::{RepoListStore, Timestamp}; -use utils::relative_time; - -use crate::views::repo::actions::open_repo_panel; +use signed_state::RepoListStore; const COLUMNS: usize = 2; const CARD_HEIGHT: f32 = 40. + 64. + 48. + 2. + 6.; -const RECENT_COUNT: usize = 10; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -enum RepoFilter { - All, - #[default] - Popular, - Recent, -} - -impl RepoFilter { - /// Indices into the store's `announcements` this filter includes, in display order. - fn visible(self, store: &RepoListStore, query: &str) -> Vec { - let announcements = &store.announcements; - let mut indices: Vec = (0..announcements.len()).collect(); - let query = query.trim().to_lowercase(); - - if !query.is_empty() { - indices.retain(|&ix| { - let announcement = &announcements[ix]; - let name = announcement.name.as_deref().unwrap_or(&announcement.id); - name.to_lowercase().contains(&query) - }); - } - - match self { - Self::All => {} - Self::Recent => indices.truncate(RECENT_COUNT), - Self::Popular => { - let counts = &store.counts; - let scores: Vec = announcements - .iter() - .map(|a| counts.get(&a.addr()).map_or(0, |c| c.score())) - .collect(); - indices.sort_by(|&a, &b| scores[b].cmp(&scores[a])); - } - } - - indices - } -} pub struct ExploreView { store: Entity, @@ -85,7 +42,9 @@ impl ExploreView { let store = RepoListStore::global(cx); let directory = ProfileDirectory::new(cx); + let entity = cx.entity(); let search = InputState::new(cx); + let search_subscription = cx.subscribe(&search, |this, _search, event, cx| { if matches!(event, InputStateEvent::TextChanged) { this.rebuild_rows(cx); @@ -96,7 +55,13 @@ impl ExploreView { this.rebuild_rows(cx); }); - let mut view = Self { + cx.defer(move |cx| { + entity.update(cx, |this, cx| { + this.rebuild_rows(cx); + }); + }); + + Self { store, dock, scroll_handle: UniformListScrollHandle::new(), @@ -106,9 +71,7 @@ impl ExploreView { directory, _search_subscription: search_subscription, _store_subscription: store_subscription, - }; - view.rebuild_rows(cx); - view + } } fn rebuild_rows(&mut self, cx: &mut Context) { @@ -290,133 +253,3 @@ impl Render for ExploreView { ) } } - -fn render_card( - ix: usize, - announcement: &Announcement, - last_activity: Option, - announcements: &[Announcement], - directory: &Entity, - dock: &WeakEntity, - cx: &mut gpui::App, -) -> AnyElement { - let owner = directory.read(cx).profile(&announcement.owner, cx); - let name = announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())); - let description = announcement - .description - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from("No description")); - let activity = last_activity - .map(relative_time) - .map(|label| SharedString::from(format!("Updated {label}"))) - .unwrap_or_default(); - - let fork_label: Option = announcement.upstream.as_ref().and_then(|upstream| { - let addr = upstream.addr.as_ref()?; - let name = announcements - .iter() - .find(|a| a.addr() == *addr) - .map(|a| { - a.name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(a.id.clone())) - }) - .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); - Some(SharedString::from(format!("forked from {name}"))) - }); - - let owner_name = owner.name(); - let dock = dock.clone(); - let announcement = announcement.clone(); - - v_flex() - .id(ix) - .flex_1() - .h_full() - .min_w_0() - .px_3() - .rounded(cx.theme().radius.control) - .border_1() - .border_color(cx.theme().colors.border) - .hover(|this| this.bg(cx.theme().colors.muted_hover)) - .child( - h_flex() - .h_10() - .gap_2() - .child( - div() - .min_w_0() - .text_sm() - .font_weight(gpui::FontWeight::SEMIBOLD) - .whitespace_nowrap() - .text_ellipsis() - .child(name), - ) - .when_some(fork_label, |this, label| { - this.child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .whitespace_nowrap() - .child( - Icon::named(CustomIconName::GitBranch) - .size(px(12.)) - .color(cx.theme().colors.muted_foreground), - ) - .child(label), - ) - }), - ) - .child( - div() - .h_16() - .min_w_0() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .line_clamp(2) - .child(description), - ) - .child( - h_flex() - .h_12() - .gap_2() - .overflow_hidden() - .child( - h_flex() - .gap_2() - .child( - Avatar::new(announcement.owner) - .name(owner_name.clone()) - .picture(owner.picture()) - .small(), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .whitespace_nowrap() - .child(owner_name), - ), - ) - .child( - h_flex() - .flex_1() - .justify_end() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .whitespace_nowrap() - .child(activity), - ), - ) - .on_click(move |_, window, cx| { - open_repo_panel(&dock, &announcement.addr(), Some(&announcement), window, cx); - }) - .into_any_element() -} diff --git a/crates/signed_app/src/dialogs/exit.rs b/crates/signed_app/src/identity/exit.rs similarity index 87% rename from crates/signed_app/src/dialogs/exit.rs rename to crates/signed_app/src/identity/exit.rs index 47f5d7d..ef6bd29 100644 --- a/crates/signed_app/src/dialogs/exit.rs +++ b/crates/signed_app/src/identity/exit.rs @@ -1,6 +1,5 @@ use gpui::{ - AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, Render, - Styled as _, Window, div, + AnyElement, Context, IntoElement, ParentElement as _, Render, Styled as _, Window, div, }; use gpui_native_kit::button::{Button, ButtonVariant}; use gpui_native_kit::common::v_flex; @@ -8,7 +7,7 @@ use gpui_native_kit::dialog::Dialog; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::Backend; -use super::{close_global, open_global}; +use crate::shared::dialogs::close_global; pub struct ExitDialog; @@ -55,8 +54,3 @@ impl Render for ExitDialog { }) } } - -/// Opens the sign-out confirmation. -pub fn open(cx: &mut App) { - open_global(cx.new(|_| ExitDialog), cx); -} diff --git a/crates/signed_app/src/identity/helpers.rs b/crates/signed_app/src/identity/helpers.rs new file mode 100644 index 0000000..9e7adf6 --- /dev/null +++ b/crates/signed_app/src/identity/helpers.rs @@ -0,0 +1,23 @@ +use gpui::{App, AppContext as _}; + +use super::exit::ExitDialog; +use super::import::ImportDialog; +use super::onboarding::OnboardingDialog; +use super::passphrase::PassphraseDialog; +use crate::shared::dialogs::open_global; + +pub fn open_passphrase(cx: &mut App) { + open_global(PassphraseDialog::new(cx), cx); +} + +pub fn open_onboarding(cx: &mut App) { + open_global(OnboardingDialog::new(cx), cx); +} + +pub fn open_import(cx: &mut App) { + open_global(ImportDialog::new(cx), cx); +} + +pub fn open_exit(cx: &mut App) { + open_global(cx.new(|_| ExitDialog), cx); +} diff --git a/crates/signed_app/src/dialogs/import.rs b/crates/signed_app/src/identity/import.rs similarity index 99% rename from crates/signed_app/src/dialogs/import.rs rename to crates/signed_app/src/identity/import.rs index d1c4f20..d2e6663 100644 --- a/crates/signed_app/src/dialogs/import.rs +++ b/crates/signed_app/src/identity/import.rs @@ -11,8 +11,8 @@ use gpui_native_kit::input::{Input, InputState, InputStateEvent}; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::Backend; -use super::close_global; -use super::progress::{DialogProgress, error_row}; +use crate::shared::dialogs::close_global; +use crate::shared::progress::{DialogProgress, error_row}; #[derive(Clone, Copy, Default, PartialEq, Eq)] enum Stage { @@ -65,12 +65,14 @@ pub struct ImportDialog { impl ImportDialog { pub fn new(cx: &mut App) -> Entity { + let state = cx.new(|_| ImportState::default()); let secret_input = InputState::new(cx); + let pass_input = InputState::new(cx); pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let repass_input = InputState::new(cx); repass_input.update(cx, |state, cx| state.set_password(true, cx)); - let state = cx.new(|_| ImportState::default()); cx.new(|cx| { let mut subscriptions = Vec::new(); @@ -101,12 +103,15 @@ impl ImportDialog { impl Render for ImportDialog { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + let state = self.state.clone(); + let secret_input = self.secret_input.clone(); let pass_input = self.pass_input.clone(); let repass_input = self.repass_input.clone(); - let state = self.state.clone(); + let action_state = state.clone(); let action_secret = secret_input.clone(); + let action_pass = pass_input.clone(); let action_repass = repass_input.clone(); @@ -122,6 +127,7 @@ impl Render for ImportDialog { .content(move |_window, cx| { let state = state.read(cx); let stage = state.stage; + let description = match stage { Stage::Credential => { "Paste the secret key or bunker URI of an existing identity." @@ -168,11 +174,13 @@ impl Render for ImportDialog { .actions(move |_, cx| { let state = action_state.read(cx); let busy = state.progress.busy; + let label = match state.stage { Stage::Credential => "Continue", Stage::Encrypt => "Import identity", Stage::Decrypt => "Unlock", }; + let secret = action_secret.clone(); let pass = action_pass.clone(); let repass = action_repass.clone(); diff --git a/crates/signed_app/src/identity/mod.rs b/crates/signed_app/src/identity/mod.rs new file mode 100644 index 0000000..e0e4b43 --- /dev/null +++ b/crates/signed_app/src/identity/mod.rs @@ -0,0 +1,5 @@ +pub mod exit; +pub mod helpers; +pub mod import; +pub mod onboarding; +pub mod passphrase; diff --git a/crates/signed_app/src/dialogs/onboarding.rs b/crates/signed_app/src/identity/onboarding.rs similarity index 97% rename from crates/signed_app/src/dialogs/onboarding.rs rename to crates/signed_app/src/identity/onboarding.rs index 9ab8b47..9b6fccb 100644 --- a/crates/signed_app/src/dialogs/onboarding.rs +++ b/crates/signed_app/src/identity/onboarding.rs @@ -11,8 +11,8 @@ use gpui_native_kit::input::{Input, InputState}; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::Backend; -use super::close_global; -use super::progress::{DialogProgress, error_row}; +use crate::shared::dialogs::close_global; +use crate::shared::progress::{DialogProgress, error_row}; pub struct OnboardingDialog { name_input: Entity, @@ -23,12 +23,14 @@ pub struct OnboardingDialog { impl OnboardingDialog { pub fn new(cx: &mut App) -> Entity { + let state = cx.new(|_| DialogProgress::default()); let name_input = InputState::new(cx); + let pass_input = InputState::new(cx); pass_input.update(cx, |state, cx| state.set_password(true, cx)); + let repass_input = InputState::new(cx); repass_input.update(cx, |state, cx| state.set_password(true, cx)); - let state = cx.new(|_| DialogProgress::default()); cx.new(|_| Self { name_input, diff --git a/crates/signed_app/src/dialogs/passphrase.rs b/crates/signed_app/src/identity/passphrase.rs similarity index 94% rename from crates/signed_app/src/dialogs/passphrase.rs rename to crates/signed_app/src/identity/passphrase.rs index 4bb22aa..e9cd270 100644 --- a/crates/signed_app/src/dialogs/passphrase.rs +++ b/crates/signed_app/src/identity/passphrase.rs @@ -13,8 +13,8 @@ use gpui_native_kit::input::{Input, InputState, InputStateEvent}; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::Backend; -use super::progress::{DialogProgress, error_row, push_steps_row}; -use super::{close_global, open_global}; +use crate::shared::dialogs::close_global; +use crate::shared::progress::{DialogProgress, error_row, push_steps_row}; pub struct PassphraseDialog { pass_input: Entity, @@ -24,9 +24,10 @@ pub struct PassphraseDialog { impl PassphraseDialog { pub fn new(cx: &mut App) -> Entity { + let state = cx.new(|_| DialogProgress::default()); + let pass_input = InputState::new(cx); pass_input.update(cx, |state, cx| state.set_password(true, cx)); - let state = cx.new(|_| DialogProgress::default()); cx.new(|cx| { let enter_subscription = @@ -132,8 +133,3 @@ impl Render for PassphraseDialog { }) } } - -/// Opens the dialog; used by the backend passphrase event and the startup fallback. -pub fn open(cx: &mut App) { - open_global(PassphraseDialog::new(cx), cx); -} diff --git a/crates/signed_app/src/views/issues/detail.rs b/crates/signed_app/src/issues/detail.rs similarity index 96% rename from crates/signed_app/src/views/issues/detail.rs rename to crates/signed_app/src/issues/detail.rs index fae1a50..8274498 100644 --- a/crates/signed_app/src/views/issues/detail.rs +++ b/crates/signed_app/src/issues/detail.rs @@ -15,8 +15,8 @@ use signed_core::GitEvent; use signed_state::RepoStore; use utils::relative_time; -use crate::components::status_badge; -use crate::views::discussion::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::issues::helpers::{comment_form, comments_section, issue_roots, sidebar_section}; +use crate::shared::widgets::{repo_tab_avatar, status_badge}; pub struct IssueDetailView { store: Entity, @@ -97,11 +97,7 @@ impl Panel for IssueDetailView { } fn lead(&self, cx: &gpui::App) -> Option { - Some(crate::views::repo::repo_tab_avatar( - self.store.read(cx), - &self.directory, - cx, - )) + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) } } diff --git a/crates/signed_app/src/views/discussion.rs b/crates/signed_app/src/issues/helpers.rs similarity index 79% rename from crates/signed_app/src/views/discussion.rs rename to crates/signed_app/src/issues/helpers.rs index 1f37e72..a1992a9 100644 --- a/crates/signed_app/src/views/discussion.rs +++ b/crates/signed_app/src/issues/helpers.rs @@ -13,23 +13,75 @@ use gpui_native_kit::theme::ActiveTheme as _; use gpui_native_kit::tooltip::Tooltip; use metadata::ProfileDirectory; use nostr::prelude::{Event, EventId, PublicKey}; +use signed_core::RepoStatus; use signed_state::RepoStore; use utils::relative_time; -pub fn issue_roots(store: &RepoStore) -> &[Event] { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum IssueFilter { + All, + Open, + Closed, +} + +impl IssueFilter { + pub(super) fn matches(self, status: RepoStatus) -> bool { + match self { + Self::All => true, + Self::Open => status == RepoStatus::Open, + Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied), + } + } +} + +/// Root events counted by their resolved status. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub(super) struct StatusCounts { + pub(super) total: usize, + pub(super) open: usize, + pub(super) closed: usize, + pub(super) draft: usize, + pub(super) applied: usize, +} + +impl StatusCounts { + fn record(&mut self, status: RepoStatus) { + self.total += 1; + match status { + RepoStatus::Open => self.open += 1, + RepoStatus::Closed => self.closed += 1, + RepoStatus::Draft => self.draft += 1, + RepoStatus::Applied => self.applied += 1, + } + } +} + +/// Indices of `roots` whose status `keep` accepts, counting every root's status. +pub(super) fn filter_by_status<'a>( + roots: impl IntoIterator, + status_of: impl Fn(&Event) -> RepoStatus, + keep: impl Fn(RepoStatus) -> bool, +) -> (Vec, StatusCounts) { + let mut counts = StatusCounts::default(); + + let visible = roots + .into_iter() + .enumerate() + .filter_map(|(index, root)| { + let status = status_of(root); + counts.record(status); + keep(status).then_some(index) + }) + .collect(); + + (visible, counts) +} + +pub(super) fn issue_roots(store: &RepoStore) -> &[Event] { &store.issues } -fn sidebar_title(text: &str, cx: &App) -> AnyElement { - div() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .text_color(cx.theme().colors.muted_foreground) - .child(text.to_string()) - .into_any_element() -} - -pub fn sidebar_section( +pub(super) fn sidebar_section( store: &Entity, id: EventId, roots: fn(&RepoStore) -> &[Event], @@ -61,7 +113,7 @@ pub fn sidebar_section( v_flex() .when(top_gap, |this| this.mt_4()) .gap_2() - .child(sidebar_title("Participants", cx)) + .child(crate::shared::widgets::section_label("Participants", cx)) .children(participants.iter().map(|pubkey| { let profile = directory.read(cx).profile(pubkey, cx); let name = profile.name(); @@ -81,7 +133,7 @@ pub fn sidebar_section( .child( v_flex() .gap_2() - .child(sidebar_title("Labels", cx)) + .child(crate::shared::widgets::section_label("Labels", cx)) .map(|this| { if labels.is_empty() { this.child( @@ -101,7 +153,7 @@ pub fn sidebar_section( .into_any_element() } -pub fn comments_section( +pub(super) fn comments_section( store: &Entity, root: EventId, directory: &Entity, @@ -162,8 +214,7 @@ pub fn comments_section( .into_any_element() } -/// `roots` selects the root's list within the store, issues or pull requests. -pub fn comment_form( +pub(super) fn comment_form( store: &Entity, root: EventId, roots: fn(&RepoStore) -> &[Event], diff --git a/crates/signed_app/src/views/issues/mod.rs b/crates/signed_app/src/issues/mod.rs similarity index 95% rename from crates/signed_app/src/views/issues/mod.rs rename to crates/signed_app/src/issues/mod.rs index 91a5374..0be8ab8 100644 --- a/crates/signed_app/src/views/issues/mod.rs +++ b/crates/signed_app/src/issues/mod.rs @@ -16,37 +16,22 @@ use gpui_native_kit::segmented::{Segmented, SegmentedOption}; use gpui_native_kit::theme::ActiveTheme as _; use metadata::ProfileDirectory; use nostr::prelude::PublicKey; -use signed_core::{GitEvent, RepoStatus}; +use signed_core::GitEvent; use signed_state::RepoStore; use utils::relative_time; use self::detail::IssueDetailView; -use super::status_list::{StatusCounts, filter_by_status}; -use crate::components::status_badge; -use crate::dialogs::new_issue; -use crate::panels; +use helpers::{IssueFilter, StatusCounts, filter_by_status}; + +use crate::shared::panels; +use crate::shared::widgets::{repo_tab_avatar, status_badge}; pub mod detail; +mod helpers; +pub mod new_issue; const ISSUE_ROW_HEIGHT: f32 = 73.; -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum IssueFilter { - All, - Open, - Closed, -} - -impl IssueFilter { - fn matches(self, status: RepoStatus) -> bool { - match self { - Self::All => true, - Self::Open => status == RepoStatus::Open, - Self::Closed => matches!(status, RepoStatus::Closed | RepoStatus::Applied), - } - } -} - pub struct IssuesView { dock: WeakEntity, store: Entity, @@ -185,11 +170,7 @@ impl Panel for IssuesView { } fn lead(&self, cx: &App) -> Option { - Some(super::repo::repo_tab_avatar( - self.store.read(cx), - &self.directory, - cx, - )) + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) } } diff --git a/crates/signed_app/src/dialogs/new_issue.rs b/crates/signed_app/src/issues/new_issue.rs similarity index 98% rename from crates/signed_app/src/dialogs/new_issue.rs rename to crates/signed_app/src/issues/new_issue.rs index ccf2423..257ef16 100644 --- a/crates/signed_app/src/dialogs/new_issue.rs +++ b/crates/signed_app/src/issues/new_issue.rs @@ -10,7 +10,7 @@ use gpui_native_kit::input::{Input, InputState}; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::RepoStore; -use super::{close_global, open_global}; +use crate::shared::dialogs::{close_global, open_global}; pub struct NewIssueDialog { store: Entity, diff --git a/crates/signed_app/src/lib.rs b/crates/signed_app/src/lib.rs index 3062bb9..586bea3 100644 --- a/crates/signed_app/src/lib.rs +++ b/crates/signed_app/src/lib.rs @@ -1,10 +1,13 @@ mod app; -pub mod components; -mod dialogs; -mod panels; -mod views; +mod explore; +mod identity; +mod issues; +mod repo; +mod shared; +mod sidebar; -pub use app::{AppRoot, apply_appearance}; +pub use app::AppRoot; +pub use app::helpers::apply_appearance; use gpui::{App, AppContext as _, Entity, Window}; diff --git a/crates/signed_app/src/views/repo/actions.rs b/crates/signed_app/src/repo/actions.rs similarity index 93% rename from crates/signed_app/src/views/repo/actions.rs rename to crates/signed_app/src/repo/actions.rs index 1486851..63951af 100644 --- a/crates/signed_app/src/views/repo/actions.rs +++ b/crates/signed_app/src/repo/actions.rs @@ -2,8 +2,8 @@ use gpui::{App, AppContext as _, SharedString, WeakEntity, Window}; use gpui_native_kit::layout::dock::DockState; use signed_core::{Announcement, RepoAddr}; -use crate::panels; -use crate::views::repo::RepoDetailView; +use crate::repo::RepoDetailView; +use crate::shared::panels; /// Opens a repository as a dock panel, keyed by its address. pub fn open_repo_panel( diff --git a/crates/signed_app/src/views/repo/banners.rs b/crates/signed_app/src/repo/banners.rs similarity index 100% rename from crates/signed_app/src/views/repo/banners.rs rename to crates/signed_app/src/repo/banners.rs diff --git a/crates/signed_app/src/views/repo/commit_diff.rs b/crates/signed_app/src/repo/commit_diff.rs similarity index 82% rename from crates/signed_app/src/views/repo/commit_diff.rs rename to crates/signed_app/src/repo/commit_diff.rs index 8959e84..a69e4ac 100644 --- a/crates/signed_app/src/views/repo/commit_diff.rs +++ b/crates/signed_app/src/repo/commit_diff.rs @@ -11,7 +11,6 @@ use gpui_native_kit::badge::{Badge, BadgeVariant}; use gpui_native_kit::clipboard::ClipboardButton; use gpui_native_kit::common::{h_flex, v_flex}; use gpui_native_kit::data::tree::{Tree, TreeItem}; -use gpui_native_kit::empty_state::EmptyState; use gpui_native_kit::icons::{Icon, IconRole}; use gpui_native_kit::layout::dock::Panel; use gpui_native_kit::layout::resizable::{Resizable, ResizableState}; @@ -19,17 +18,17 @@ use gpui_native_kit::scrollbar::Scrollbar; use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; use metadata::ProfileDirectory; -use signed_git::{ - CommitDiff, DiffHunk, DiffLine, DiffLineKind, DiffStatus, FileCommit, FileDiff, Repo, -}; +use signed_git::{CommitDiff, DiffStatus, FileCommit, FileDiff, Repo}; use signed_state::RepoStore; use utils::relative_time_secs; -use crate::views::tree::{TreeRow, build_tree_items, collect_expanded, flatten}; +use super::helpers::{DiffRow, diff_rows, render_diff_row}; +use crate::shared::tree::{TreeItemSeed, TreeRow, build_tree_items, collect_expanded, flatten}; +use crate::shared::widgets::{empty_state, repo_tab_avatar}; pub struct DiffPane { diff: Option, - seeds: Vec, + seeds: Vec, expanded: HashSet, rows: Vec, selected_file: Option, @@ -195,17 +194,17 @@ impl DiffPane { fn render_detail_column(&self, cx: &mut Context) -> AnyElement { let Some(diff) = self.diff.as_ref() else { - return placeholder("No changes"); + return empty_state("No changes"); }; let Some(path) = self.selected_file.clone() else { return if diff.files.is_empty() { - placeholder("No files changed") + empty_state("No files changed") } else { - placeholder("Select a file") + empty_state("Select a file") }; }; let Some(file) = diff.files.iter().find(|file| file.path == path.as_ref()) else { - return placeholder("File not found"); + return empty_state("File not found"); }; self.render_file_diff(file, cx) } @@ -230,9 +229,9 @@ impl DiffPane { }; let body: AnyElement = if file.binary { - placeholder("Diff not available") + empty_state("Diff not available") } else if file.hunks.is_empty() { - placeholder("No content changes") + empty_state("No content changes") } else { let hunks = file.hunks.clone(); let rows = self.diff_rows.clone(); @@ -515,11 +514,7 @@ impl Panel for CommitDiffView { } fn lead(&self, cx: &App) -> Option { - Some(super::repo_tab_avatar( - self.store.read(cx), - &self.directory, - cx, - )) + Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx)) } } @@ -533,7 +528,7 @@ impl Render for CommitDiffView { .child(Spinner::new("commit-diff-loading")) .into_any_element() } else if let Some(error) = self.error.clone() { - placeholder(&error) + empty_state(error) } else { self.pane.clone().into_any_element() }; @@ -545,117 +540,3 @@ impl Render for CommitDiffView { .child(div().flex_1().min_h_0().child(body)) } } - -const GUTTER_WIDTH: f32 = 44.; -pub const DIFF_ROW_HEIGHT: f32 = 20.; -pub const COMMIT_ROW_HEIGHT: f32 = 56.; - -#[derive(Clone, Copy)] -enum DiffRow { - Hunk { - old_start: u32, - old_lines: u32, - new_start: u32, - new_lines: u32, - }, - Line { - hunk: usize, - line: usize, - }, -} - -fn diff_rows(file: &FileDiff) -> Vec { - let mut rows = Vec::new(); - for (hunk_ix, hunk) in file.hunks.iter().enumerate() { - rows.push(DiffRow::Hunk { - old_start: hunk.old_start, - old_lines: hunk.old_lines, - new_start: hunk.new_start, - new_lines: hunk.new_lines, - }); - rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { - hunk: hunk_ix, - line, - })); - } - rows -} - -fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { - match row { - DiffRow::Hunk { - old_start, - old_lines, - new_start, - new_lines, - } => div() - .px_2() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .font_family(cx.theme().mono_font) - .text_xs() - .bg(cx.theme().colors.muted) - .border_y_1() - .border_color(cx.theme().colors.border) - .text_color(cx.theme().colors.muted_foreground) - .child(SharedString::from(format!( - "@@ -{},{} +{},{} @@", - old_start, old_lines, new_start, new_lines - ))) - .into_any_element(), - DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), - } -} - -fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { - let bg = match line.kind { - DiffLineKind::Addition => Some(cx.theme().ramps.success.base.opacity(0.2)), - DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)), - DiffLineKind::Context => None, - }; - let gutter = cx.theme().colors.muted_foreground; - - h_flex() - .w_full() - .h(px(DIFF_ROW_HEIGHT)) - .font_family(cx.theme().mono_font) - .text_xs() - .when_some(bg, |this, bg| this.bg(bg)) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.old.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .w(px(GUTTER_WIDTH)) - .flex_none() - .pr_2() - .text_right() - .text_color(gutter) - .child(line.new.map(|n| n.to_string()).unwrap_or_default()), - ) - .child( - div() - .flex_1() - .min_w_0() - .overflow_hidden() - .whitespace_nowrap() - .text_color(cx.theme().colors.foreground) - .child(line.text.clone()), - ) - .into_any_element() -} - -fn placeholder(message: impl Into) -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(EmptyState::new(message)) - .into_any_element() -} diff --git a/crates/signed_app/src/dialogs/about.rs b/crates/signed_app/src/repo/dialogs/about.rs similarity index 99% rename from crates/signed_app/src/dialogs/about.rs rename to crates/signed_app/src/repo/dialogs/about.rs index 6311239..5ff0cbc 100644 --- a/crates/signed_app/src/dialogs/about.rs +++ b/crates/signed_app/src/repo/dialogs/about.rs @@ -12,7 +12,7 @@ use nostr::prelude::PublicKey; use signed_core::Announcement; use utils::middle_truncate; -use super::{close_global, open_global}; +use crate::shared::dialogs::{close_global, open_global}; pub struct AboutDialog { announcement: Announcement, diff --git a/crates/signed_app/src/dialogs/create_repo.rs b/crates/signed_app/src/repo/dialogs/create.rs similarity index 98% rename from crates/signed_app/src/dialogs/create_repo.rs rename to crates/signed_app/src/repo/dialogs/create.rs index ff2c37f..1b674a1 100644 --- a/crates/signed_app/src/dialogs/create_repo.rs +++ b/crates/signed_app/src/repo/dialogs/create.rs @@ -18,9 +18,9 @@ use settings::SettingsStore; use signed_state::{Backend, CheckoutsStore}; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; -use super::progress::{DialogProgress, error_row, push_steps_row}; -use super::{close_global, open_global}; -use crate::views::repo::actions::open_repo_panel; +use crate::repo::actions::open_repo_panel; +use crate::shared::dialogs::{close_global, open_global}; +use crate::shared::progress::{DialogProgress, error_row, push_steps_row}; /// Progress of the create-repository flow, so async results can be rendered. pub struct CreateRepoDialog { diff --git a/crates/signed_app/src/dialogs/grasp_servers.rs b/crates/signed_app/src/repo/dialogs/grasp_servers.rs similarity index 100% rename from crates/signed_app/src/dialogs/grasp_servers.rs rename to crates/signed_app/src/repo/dialogs/grasp_servers.rs diff --git a/crates/signed_app/src/dialogs/init_repo.rs b/crates/signed_app/src/repo/dialogs/init.rs similarity index 98% rename from crates/signed_app/src/dialogs/init_repo.rs rename to crates/signed_app/src/repo/dialogs/init.rs index 2759ee1..30aa0db 100644 --- a/crates/signed_app/src/dialogs/init_repo.rs +++ b/crates/signed_app/src/repo/dialogs/init.rs @@ -19,9 +19,9 @@ use signed_git::Repo; use signed_state::Backend; use super::grasp_servers::{GraspServersState, grasp_servers_field, load_user_grasp_servers}; -use super::progress::{DialogProgress, error_row, push_steps_row}; -use super::{close_global, open_global}; -use crate::views::repo::RepoDetailView; +use crate::repo::RepoDetailView; +use crate::shared::dialogs::{close_global, open_global}; +use crate::shared::progress::{DialogProgress, error_row, push_steps_row}; pub struct InitRepoDialog { local_path: PathBuf, diff --git a/crates/signed_app/src/repo/dialogs/mod.rs b/crates/signed_app/src/repo/dialogs/mod.rs new file mode 100644 index 0000000..aa6ee24 --- /dev/null +++ b/crates/signed_app/src/repo/dialogs/mod.rs @@ -0,0 +1,5 @@ +pub mod about; +pub mod create; +pub mod grasp_servers; +pub mod init; +pub mod reset; diff --git a/crates/signed_app/src/dialogs/reset_remote.rs b/crates/signed_app/src/repo/dialogs/reset.rs similarity index 98% rename from crates/signed_app/src/dialogs/reset_remote.rs rename to crates/signed_app/src/repo/dialogs/reset.rs index cca20d4..b265dfb 100644 --- a/crates/signed_app/src/dialogs/reset_remote.rs +++ b/crates/signed_app/src/repo/dialogs/reset.rs @@ -8,7 +8,7 @@ use gpui_native_kit::dialog::Dialog; use gpui_native_kit::theme::ActiveTheme as _; use signed_state::RepoStore; -use super::{close_global, open_global}; +use crate::shared::dialogs::{close_global, open_global}; pub struct ResetRemoteDialog { store: Entity, diff --git a/crates/signed_app/src/views/repo/files.rs b/crates/signed_app/src/repo/files.rs similarity index 90% rename from crates/signed_app/src/views/repo/files.rs rename to crates/signed_app/src/repo/files.rs index becd657..f53eae2 100644 --- a/crates/signed_app/src/views/repo/files.rs +++ b/crates/signed_app/src/repo/files.rs @@ -10,15 +10,14 @@ use gpui::{ use gpui_native_kit::common::{h_flex, v_flex}; use gpui_native_kit::data::tree::{Tree, TreeItem}; use gpui_native_kit::editor::code::CodeViewState; -use gpui_native_kit::editor::highlight::Language; use gpui_native_kit::editor::markdown::MarkdownViewState; -use gpui_native_kit::empty_state::EmptyState; use gpui_native_kit::icons::{Icon, IconRole}; -use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; use signed_git::{FileCommit, Repo, WorktreeSnapshot}; -use crate::views::tree::{TreeItemSeed, TreeRow, flatten}; +use super::helpers::{code_language, is_markdown_path, preview_spinner, source_hash}; +use crate::shared::tree::{TreeItemSeed, TreeRow, collect_expanded, flatten}; +use crate::shared::widgets::empty_state; const TREE_WIDTH: f32 = 240.; const MAX_PREVIEW_BYTES: usize = 1024 * 1024; @@ -106,7 +105,7 @@ impl RepoFilesView { ) { self.worktree_paths = paths; self.seeds = tree; - let folder_ids = folder_ids(&self.seeds); + let folder_ids = collect_expanded(&self.seeds); self.expanded.retain(|id| folder_ids.contains(id)); self.rebuild_rows(); cx.notify(); @@ -323,15 +322,15 @@ impl RepoFilesView { self.code_element(path.as_ref()) } } - Some(FileContent::Binary) => placeholder("Binary file - preview not supported"), - Some(FileContent::TooLarge) => placeholder("File is too large to preview"), - Some(FileContent::Failed(message)) => placeholder(message), + Some(FileContent::Binary) => empty_state("Binary file - preview not supported"), + Some(FileContent::TooLarge) => empty_state("File is too large to preview"), + Some(FileContent::Failed(message)) => empty_state(message), None => preview_spinner(), } } else if self.readme_name.is_some() { self.markdown_element(None) } else { - placeholder("No README found") + empty_state("No README found") }; let commit = match &self.selected_file { @@ -735,75 +734,3 @@ impl Render for RepoFilesView { .child(self.render_content_column(pane_title, cx)) } } - -fn folder_ids(seeds: &[TreeItemSeed]) -> HashSet { - fn push(seed: &TreeItemSeed, ids: &mut HashSet) { - if !seed.children.is_empty() { - ids.insert(seed.id.clone()); - for child in &seed.children { - push(child, ids); - } - } - } - - let mut ids = HashSet::new(); - for seed in seeds { - push(seed, &mut ids); - } - ids -} - -fn source_hash(text: &str) -> u64 { - use std::hash::{Hash, Hasher}; - - let mut hasher = std::collections::hash_map::DefaultHasher::new(); - text.hash(&mut hasher); - hasher.finish() -} - -fn preview_spinner() -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(Spinner::new("repo-preview-spinner")) - .into_any_element() -} - -fn placeholder(message: impl Into) -> AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(EmptyState::new(message)) - .into_any_element() -} - -/// The kit grammar for a path; only four languages are supported. -fn code_language(path: &str) -> Language { - match Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .map(|ext| ext.to_ascii_lowercase()) - .as_deref() - { - Some("rs") => Language::Rust, - Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript, - Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript, - Some("py") => Language::Python, - _ => Language::Plain, - } -} - -/// Whether a file path has a markdown extension. -fn is_markdown_path(path: &str) -> bool { - Path::new(path) - .extension() - .and_then(|ext| ext.to_str()) - .is_some_and(|ext| { - matches!( - ext.to_ascii_lowercase().as_str(), - "md" | "markdown" | "mdown" | "mkdn" - ) - }) -} diff --git a/crates/signed_app/src/repo/helpers.rs b/crates/signed_app/src/repo/helpers.rs new file mode 100644 index 0000000..fbd0fc8 --- /dev/null +++ b/crates/signed_app/src/repo/helpers.rs @@ -0,0 +1,514 @@ +use std::path::{Path, PathBuf}; + +use anyhow::Error; +use assets::CustomIconName; +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ClipboardItem, Context, Entity, InteractiveElement as _, IntoElement, + ParentElement as _, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, + px, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::clipboard::ClipboardButton; +use gpui_native_kit::combobox::ComboboxState; +use gpui_native_kit::common::{Sizable as _, h_flex, v_flex}; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::menu::MenuItem; +use gpui_native_kit::theme::ActiveTheme as _; +use metadata::ProfileDirectory; +use nostr::nips::nip19::Nip19Coordinate; +use nostr::prelude::{RelayUrl, ToBech32}; +use signed_core::Announcement; +use signed_git::{DiffHunk, DiffLine, DiffLineKind, FileCommit, FileDiff, Repo}; +use signed_state::{Nip34Binding, RepoListStore, RepoStore}; +use utils::{middle_truncate, relative_time_secs}; + +use super::RepoDetailView; +use crate::shared::tree::{TreeItemSeed, build_tree_items}; + +pub(super) struct RepoData { + pub(super) tree: Vec, + pub(super) entries: Vec, + pub(super) readme_path: Option, + pub(super) readme: Option>, + pub(super) worktree: Option, + pub(super) branches: Vec, + pub(super) tags: Vec, + pub(super) current_branch: Option, + pub(super) head_commit: Option, +} + +pub(super) fn clone_group(label: &str, id: &'static str, command: &SharedString) -> AnyElement { + v_flex() + .gap_1() + .child(div().text_xs().child(SharedString::from(label))) + .child(ClipboardButton::new(id, command.clone())) + .into_any_element() +} + +pub(super) fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { + let owner = announcement.owner; + let user = nip05 + .map(str::to_owned) + .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_string())); + + let mut url = format!("nostr://{user}"); + if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { + url.push('/'); + url.push_str(hint); + } + url.push('/'); + url.push_str(&announcement.id); + + SharedString::from(url) +} + +pub(super) fn fork_row( + announcement: &Announcement, + cx: &mut Context, +) -> Option { + let upstream = announcement.upstream.as_ref()?; + + let (label, clickable) = match &upstream.addr { + Some(addr) => { + let name = RepoListStore::global(cx) + .read(cx) + .announcements + .iter() + .find(|a| a.addr() == *addr) + .map(|a| { + a.name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(a.id.clone())) + }) + .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); + (SharedString::from(format!("Forked from {name}")), true) + } + None => (SharedString::from(upstream.display().as_str()), false), + }; + + let row = h_flex() + .gap_1() + .min_w_0() + .text_sm() + .text_color(cx.theme().colors.muted_foreground) + .child(Icon::named(CustomIconName::GitBranch).size(px(14.))) + .child(div().whitespace_nowrap().text_ellipsis().child(label)); + + Some(if clickable { + row.id("fork-upstream") + .cursor_pointer() + .on_click(cx.listener(|this, _, window, cx| this.open_upstream(window, cx))) + .into_any_element() + } else { + row.into_any_element() + }) +} + +#[derive(Clone)] +pub(super) struct ShareTargets { + /// NIP-19 `naddr1...` of the announcement, with its announced relays. + pub(super) naddr: String, + /// Hex ID of the announcement event itself. + event_id: String, + /// NIP-34 coordinate `30617::`. + coordinate: String, + gitworkshop: String, + ditto: String, +} + +impl ShareTargets { + pub(super) fn from_announcement(announcement: &Announcement) -> Self { + let addr = announcement.addr(); + let coordinate = addr.to_string(); + let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) + .to_bech32() + .unwrap_or_else(|_| coordinate.clone()); + + Self { + naddr: naddr.clone(), + event_id: announcement + .event_id + .to_bech32() + .unwrap_or_else(|_| announcement.event_id.to_string()), + coordinate, + gitworkshop: format!("https://gitworkshop.dev/{naddr}"), + ditto: format!("https://ditto.pub/{naddr}"), + } + } + + pub(super) fn menu(&self) -> Vec { + let items = [ + ("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)), + ("Ditto", truncate_naddr_link(&self.ditto, 4)), + ("Event ID", middle_truncate(&self.event_id, 10, 10)), + ("Coordinate", middle_truncate(&self.coordinate, 10, 10)), + ]; + + let values = [ + self.gitworkshop.clone(), + self.ditto.clone(), + self.event_id.clone(), + self.coordinate.clone(), + ]; + + items + .into_iter() + .zip(values) + .map(|((label, detail), value)| { + MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler( + move |_, cx| { + cx.write_to_clipboard(ClipboardItem::new_string(value.clone())); + }, + ) + }) + .collect() + } +} + +pub(super) fn truncate_naddr_link(url: &str, tail: usize) -> String { + let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { + return url.to_string(); + }; + if url.len() - end <= tail + 3 { + return url.to_string(); + } + format!("{}...{}", &url[..end], &url[url.len() - tail..]) +} + +pub(super) fn load_repo_data(repo: &Repo) -> Result { + let snapshot = repo.snapshot()?; + let worktree = repo.workdir().map(Path::to_path_buf); + let tree = build_tree_items(&snapshot.entries); + + Ok(RepoData { + tree, + entries: snapshot.entries, + readme_path: snapshot.readme_path, + readme: snapshot.readme, + worktree, + branches: snapshot.branches, + tags: snapshot.tags, + current_branch: snapshot.current_branch, + head_commit: snapshot.head_commit, + }) +} + +/// The announcement's name or ID for announced repositories, the directory name for local ones. +pub fn repo_display_name(store: &RepoStore) -> SharedString { + if store.addr().is_none() { + return store + .path + .as_ref() + .map(|path| { + SharedString::from( + path.file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| path.display().to_string()), + ) + }) + .unwrap_or_default(); + } + + store + .announcement + .as_ref() + .map(|announcement| { + announcement + .name + .as_deref() + .map(SharedString::from) + .unwrap_or_else(|| SharedString::from(announcement.id.clone())) + }) + .unwrap_or_default() +} + +/// The owner and identifier a local repository is already bound to. +pub(super) fn bound_repo_label( + binding: &Nip34Binding, + directory: &Entity, + cx: &App, +) -> AnyElement { + let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx)); + + h_flex() + .flex_shrink_0() + .gap_2() + .text_sm() + .child("Initialized by") + .text_color(cx.theme().colors.muted_foreground) + .when_some(profile, |this, profile| { + let name = profile.name(); + this.child( + h_flex() + .gap_1() + .text_color(cx.theme().colors.foreground) + .child( + Avatar::new(profile.public_key()) + .name(name.clone()) + .picture(profile.picture()) + .small(), + ) + .child(name), + ) + }) + .when_some(binding.identifier.as_deref(), |this, ident| { + this.child( + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .child(SharedString::from(format!("/{ident}"))), + ) + }) + .into_any_element() +} + +/// Computes a source hash from the given text. +pub(super) fn source_hash(text: &str) -> u64 { + use std::hash::{Hash, Hasher}; + + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + text.hash(&mut hasher); + hasher.finish() +} + +pub(super) fn preview_spinner() -> AnyElement { + v_flex() + .size_full() + .items_center() + .justify_center() + .child(gpui_native_kit::spinner::Spinner::new( + "repo-preview-spinner", + )) + .into_any_element() +} + +/// The kit grammar for a path; only four languages are supported. +pub(super) fn code_language(path: &str) -> gpui_native_kit::editor::highlight::Language { + use gpui_native_kit::editor::highlight::Language; + match Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .map(|ext| ext.to_ascii_lowercase()) + .as_deref() + { + Some("rs") => Language::Rust, + Some("ts" | "mts" | "cts" | "tsx") => Language::TypeScript, + Some("js" | "mjs" | "cjs" | "jsx") => Language::JavaScript, + Some("py") => Language::Python, + _ => Language::Plain, + } +} + +/// Whether a file path has a markdown extension. +pub(super) fn is_markdown_path(path: &str) -> bool { + Path::new(path) + .extension() + .and_then(|ext| ext.to_str()) + .is_some_and(|ext| { + matches!( + ext.to_ascii_lowercase().as_str(), + "md" | "markdown" | "mdown" | "mkdn" + ) + }) +} + +const GUTTER_WIDTH: f32 = 44.; +pub const DIFF_ROW_HEIGHT: f32 = 20.; +pub const COMMIT_ROW_HEIGHT: f32 = 56.; + +#[derive(Clone, Copy)] +pub(super) enum DiffRow { + Hunk { + old_start: u32, + old_lines: u32, + new_start: u32, + new_lines: u32, + }, + Line { + hunk: usize, + line: usize, + }, +} + +pub(super) fn diff_rows(file: &FileDiff) -> Vec { + let mut rows = Vec::new(); + for (hunk_ix, hunk) in file.hunks.iter().enumerate() { + rows.push(DiffRow::Hunk { + old_start: hunk.old_start, + old_lines: hunk.old_lines, + new_start: hunk.new_start, + new_lines: hunk.new_lines, + }); + rows.extend((0..hunk.lines.len()).map(|line| DiffRow::Line { + hunk: hunk_ix, + line, + })); + } + rows +} + +pub(super) fn render_diff_row(hunks: &[DiffHunk], row: DiffRow, cx: &App) -> AnyElement { + match row { + DiffRow::Hunk { + old_start, + old_lines, + new_start, + new_lines, + } => div() + .px_2() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font) + .text_xs() + .bg(cx.theme().colors.muted) + .border_y_1() + .border_color(cx.theme().colors.border) + .text_color(cx.theme().colors.muted_foreground) + .child(SharedString::from(format!( + "@@ -{},{} +{},{} @@", + old_start, old_lines, new_start, new_lines + ))) + .into_any_element(), + DiffRow::Line { hunk, line } => render_diff_line(&hunks[hunk].lines[line], cx), + } +} + +fn render_diff_line(line: &DiffLine, cx: &App) -> AnyElement { + let bg = match line.kind { + DiffLineKind::Addition => Some(cx.theme().ramps.success.base.opacity(0.2)), + DiffLineKind::Deletion => Some(cx.theme().ramps.destructive.base.opacity(0.2)), + DiffLineKind::Context => None, + }; + let gutter = cx.theme().colors.muted_foreground; + + h_flex() + .w_full() + .h(px(DIFF_ROW_HEIGHT)) + .font_family(cx.theme().mono_font) + .text_xs() + .when_some(bg, |this, bg| this.bg(bg)) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.old.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .w(px(GUTTER_WIDTH)) + .flex_none() + .pr_2() + .text_right() + .text_color(gutter) + .child(line.new.map(|n| n.to_string()).unwrap_or_default()), + ) + .child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .whitespace_nowrap() + .text_color(cx.theme().colors.foreground) + .child(line.text.clone()), + ) + .into_any_element() +} + +pub(super) fn commit_row( + ix: usize, + commit: &FileCommit, + on_click: impl Fn(&mut Window, &mut gpui::App) + 'static, + cx: &gpui::App, +) -> gpui::AnyElement { + h_flex() + .id(ix) + .px_4() + .h(px(COMMIT_ROW_HEIGHT)) + .w_full() + .gap_3() + .border_b_1() + .border_color(cx.theme().colors.border) + .hover(|this| this.bg(cx.theme().colors.muted_hover)) + .child( + v_flex() + .flex_1() + .min_w_0() + .gap_1() + .justify_center() + .child( + h_flex() + .gap_2() + .overflow_hidden() + .child( + div() + .font_family(cx.theme().mono_font) + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(commit.id.clone()), + ) + .child( + div() + .flex_1() + .min_w_0() + .text_sm() + .text_ellipsis() + .whitespace_nowrap() + .child(commit.summary.clone()), + ), + ) + .child( + h_flex() + .gap_2() + .text_xs() + .text_color(cx.theme().colors.muted_foreground) + .child(commit.author.clone()) + .child(relative_time_secs(commit.time)), + ), + ) + .on_click(move |_, window, cx| on_click(window, cx)) + .into_any_element() +} + +pub(super) fn sync_selector( + select: &Entity>, + cached: &mut Vec, + items: Vec, + selected: Option, + window: &mut Window, + cx: &mut App, +) -> bool { + let items_changed = *cached != items; + + let selection_changed = selected + .as_ref() + .is_some_and(|value| select.read(cx).selected() != Some(value)); + + if !items_changed && !selection_changed { + return false; + } + + select.update(cx, |state, cx| { + if items_changed { + state.set_options( + items + .iter() + .map(|item| (item.clone(), item.clone())) + .collect(), + cx, + ); + } + if let Some(value) = selected + && (items_changed || selection_changed) + { + state.set_selected(Some(value), window, cx); + } + }); + *cached = items; + + true +} diff --git a/crates/signed_app/src/views/repo/history.rs b/crates/signed_app/src/repo/history.rs similarity index 69% rename from crates/signed_app/src/views/repo/history.rs rename to crates/signed_app/src/repo/history.rs index 198a9bb..6638a82 100644 --- a/crates/signed_app/src/views/repo/history.rs +++ b/crates/signed_app/src/repo/history.rs @@ -3,23 +3,21 @@ use std::path::PathBuf; use anyhow::Error; use gpui::prelude::FluentBuilder as _; use gpui::{ - AppContext as _, Context, Entity, InteractiveElement as _, IntoElement, ParentElement as _, - Render, SharedString, StatefulInteractiveElement as _, Styled as _, Task, - UniformListScrollHandle, WeakEntity, Window, div, px, uniform_list, + AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, SharedString, + Styled as _, Task, UniformListScrollHandle, WeakEntity, Window, div, uniform_list, }; -use gpui_native_kit::common::{h_flex, v_flex}; -use gpui_native_kit::empty_state::EmptyState; +use gpui_native_kit::common::v_flex; use gpui_native_kit::layout::dock::DockState; use gpui_native_kit::scrollbar::Scrollbar; use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; -use signed_git::{CommitList, FileCommit, Repo}; +use signed_git::{CommitList, Repo}; use signed_state::RepoStore; -use utils::relative_time_secs; -use super::commit_diff::{COMMIT_ROW_HEIGHT, CommitDiffView}; -use super::repo_display_name; -use crate::panels; +use super::commit_diff::CommitDiffView; +use super::helpers::commit_row; +use super::helpers::repo_display_name; +use crate::shared::panels; pub struct RepoHistoryView { store: Entity, @@ -144,12 +142,12 @@ impl Render for RepoHistoryView { .child(Spinner::new("commits-loading")) .into_any_element() } else { - empty_state("Failed to load commits") + crate::shared::widgets::empty_state("Failed to load commits") }; }; if list.commits.is_empty() { - return empty_state("No commits found"); + return crate::shared::widgets::empty_state("No commits found"); } let commits = list.commits.clone(); @@ -212,68 +210,3 @@ impl Render for RepoHistoryView { .into_any_element() } } - -fn empty_state(message: &'static str) -> gpui::AnyElement { - v_flex() - .size_full() - .items_center() - .justify_center() - .child(EmptyState::new(message)) - .into_any_element() -} - -/// One commit row shared by the repository history and pull-request commit list. -pub fn commit_row( - ix: usize, - commit: &FileCommit, - on_click: impl Fn(&mut Window, &mut gpui::App) + 'static, - cx: &gpui::App, -) -> gpui::AnyElement { - h_flex() - .id(ix) - .px_4() - .h(px(COMMIT_ROW_HEIGHT)) - .w_full() - .gap_3() - .border_b_1() - .border_color(cx.theme().colors.border) - .hover(|this| this.bg(cx.theme().colors.muted_hover)) - .child( - v_flex() - .flex_1() - .min_w_0() - .gap_1() - .justify_center() - .child( - h_flex() - .gap_2() - .overflow_hidden() - .child( - div() - .font_family(cx.theme().mono_font) - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .child(commit.id.clone()), - ) - .child( - div() - .flex_1() - .min_w_0() - .text_sm() - .text_ellipsis() - .whitespace_nowrap() - .child(commit.summary.clone()), - ), - ) - .child( - h_flex() - .gap_2() - .text_xs() - .text_color(cx.theme().colors.muted_foreground) - .child(commit.author.clone()) - .child(relative_time_secs(commit.time)), - ), - ) - .on_click(move |_, window, cx| on_click(window, cx)) - .into_any_element() -} diff --git a/crates/signed_app/src/views/repo/mod.rs b/crates/signed_app/src/repo/mod.rs similarity index 87% rename from crates/signed_app/src/views/repo/mod.rs rename to crates/signed_app/src/repo/mod.rs index d6a17f0..2f1442b 100644 --- a/crates/signed_app/src/views/repo/mod.rs +++ b/crates/signed_app/src/repo/mod.rs @@ -1,13 +1,13 @@ use std::collections::HashSet; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use anyhow::Error; use assets::CustomIconName; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, AppContext as _, ClipboardItem, Context, Entity, InteractiveElement as _, - IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, - StatefulInteractiveElement as _, Styled as _, Subscription, Task, WeakEntity, Window, div, px, + IntoElement, ParentElement as _, PathPromptOptions, Render, SharedString, Styled as _, + Subscription, Task, WeakEntity, Window, div, px, }; use gpui_native_kit::alert::{Alert, AlertSeverity}; use gpui_native_kit::avatar::Avatar; @@ -25,28 +25,32 @@ use gpui_native_kit::spinner::Spinner; use gpui_native_kit::theme::ActiveTheme as _; use gpui_native_kit::tooltip::Tooltip; use metadata::ProfileDirectory; -use nostr::nips::nip19::Nip19Coordinate; -use nostr::prelude::{PublicKey, RelayUrl, ToBech32, Url}; +use nostr::prelude::{PublicKey, Url}; use signed_core::{Announcement, RepoAddr}; use signed_git::{FileCommit, GitCache, Repo}; use signed_state::{ Backend, CheckoutStatus, CheckoutSyncStatus, CheckoutsStore, LocalReposStore, Mirrors, - Nip34Binding, Nip34Kind, PushStepStatus, RepoListStore, RepoStore, SyncStatusDirectory, + Nip34Binding, Nip34Kind, PushStepStatus, RepoStore, SyncStatusDirectory, }; -use utils::middle_truncate; pub mod actions; pub mod banners; pub mod commit_diff; +pub mod dialogs; pub mod files; +pub mod helpers; pub mod history; pub mod refs; -use super::tree::{TreeItemSeed, build_tree_items, sorted_worktree_paths}; -use crate::dialogs; -use crate::views::issues::IssuesView; +use crate::issues::IssuesView; +use crate::shared::tree::{build_tree_items, sorted_worktree_paths}; +use crate::shared::widgets::repo_tab_avatar; use banners::Banners; use files::RepoFilesView; +use helpers::{ + RepoData, ShareTargets, bound_repo_label, clone_group, fork_row, load_repo_data, + nostr_clone_url, repo_display_name, +}; use history::RepoHistoryView; use refs::RefSwitcher; @@ -88,18 +92,6 @@ pub struct RepoDetailView { _subscriptions: Vec, } -struct RepoData { - tree: Vec, - entries: Vec, - readme_path: Option, - readme: Option>, - worktree: Option, - branches: Vec, - tags: Vec, - current_branch: Option, - head_commit: Option, -} - impl RepoDetailView { pub fn new( dock: WeakEntity, @@ -795,7 +787,7 @@ impl RepoDetailView { let deleted = probe.await; this.update_in(cx, |this, _window, cx| match deleted { - Ok(deleted) => dialogs::reset_remote::open(store, deleted, cx), + Ok(deleted) => dialogs::reset::open(store, deleted, cx), Err(error) => { this.error = Some(error.to_string().into()); cx.notify(); @@ -816,7 +808,7 @@ impl RepoDetailView { } /// Open the upstream repository, the `u` tag of this fork's announcement. - fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { + pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context) { let Some(addr) = self .announcement(cx) .and_then(|announcement| announcement.upstream.as_ref()) @@ -833,7 +825,7 @@ impl RepoDetailView { let Some(local_path) = self.store.read(cx).path.clone() else { return; }; - dialogs::init_repo::open(local_path, cx.entity().downgrade(), window, cx); + dialogs::init::open(local_path, cx.entity().downgrade(), window, cx); } fn open_issues(&mut self, window: &mut Window, cx: &mut Context) { @@ -845,7 +837,7 @@ impl RepoDetailView { let dock_for_build = dock.clone(); let store = self.store.clone(); - crate::panels::open( + crate::shared::panels::open( &dock, id, move |_, cx| cx.new(|cx| IssuesView::new(dock_for_build.clone(), store.clone(), cx)), @@ -984,7 +976,7 @@ impl RepoDetailView { vec![MenuItem::new("New issue").handler({ let store = store_for_issues.clone(); move |_, cx| { - dialogs::new_issue::open(store.clone(), cx); + crate::issues::new_issue::open(store.clone(), cx); } })] }) @@ -1684,262 +1676,3 @@ impl Render for RepoDetailView { }) } } - -fn clone_group(label: &str, id: &'static str, command: &SharedString) -> AnyElement { - v_flex() - .gap_1() - .child(div().text_xs().child(SharedString::from(label))) - .child(ClipboardButton::new(id, command.clone())) - .into_any_element() -} - -fn nostr_clone_url(announcement: &Announcement, nip05: Option<&str>) -> SharedString { - let owner = announcement.owner; - let user = nip05 - .map(str::to_owned) - .unwrap_or_else(|| owner.to_bech32().unwrap_or_else(|_| owner.to_string())); - - let mut url = format!("nostr://{user}"); - if let Some(hint) = announcement.relays.first().and_then(RelayUrl::domain) { - url.push('/'); - url.push_str(hint); - } - url.push('/'); - url.push_str(&announcement.id); - - SharedString::from(url) -} - -fn fork_row(announcement: &Announcement, cx: &mut Context) -> Option { - let upstream = announcement.upstream.as_ref()?; - - let (label, clickable) = match &upstream.addr { - Some(addr) => { - let name = RepoListStore::global(cx) - .read(cx) - .announcements - .iter() - .find(|a| a.addr() == *addr) - .map(|a| { - a.name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(a.id.clone())) - }) - .unwrap_or_else(|| SharedString::from(addr.identifier().to_owned())); - (SharedString::from(format!("Forked from {name}")), true) - } - None => (SharedString::from(upstream.display().as_str()), false), - }; - - let row = h_flex() - .gap_1() - .min_w_0() - .text_sm() - .text_color(cx.theme().colors.muted_foreground) - .child(Icon::named(CustomIconName::GitBranch).size(px(14.))) - .child(div().whitespace_nowrap().text_ellipsis().child(label)); - - Some(if clickable { - row.id("fork-upstream") - .cursor_pointer() - .on_click(cx.listener(|this, _, window, cx| this.open_upstream(window, cx))) - .into_any_element() - } else { - row.into_any_element() - }) -} - -#[derive(Clone)] -struct ShareTargets { - /// NIP-19 `naddr1...` of the announcement, with its announced relays. - naddr: String, - /// Hex ID of the announcement event itself. - event_id: String, - /// NIP-34 coordinate `30617::`. - coordinate: String, - gitworkshop: String, - ditto: String, -} - -impl ShareTargets { - fn from_announcement(announcement: &Announcement) -> Self { - let addr = announcement.addr(); - let coordinate = addr.to_string(); - let naddr = Nip19Coordinate::new(addr.into(), announcement.relays.iter().cloned()) - .to_bech32() - .unwrap_or_else(|_| coordinate.clone()); - - Self { - naddr: naddr.clone(), - event_id: announcement - .event_id - .to_bech32() - .unwrap_or_else(|_| announcement.event_id.to_string()), - coordinate, - gitworkshop: format!("https://gitworkshop.dev/{naddr}"), - ditto: format!("https://ditto.pub/{naddr}"), - } - } - - fn menu(&self) -> Vec { - let items = [ - ("GitWorkshop", truncate_naddr_link(&self.gitworkshop, 4)), - ("Ditto", truncate_naddr_link(&self.ditto, 4)), - ("Event ID", middle_truncate(&self.event_id, 10, 10)), - ("Coordinate", middle_truncate(&self.coordinate, 10, 10)), - ]; - - let values = [ - self.gitworkshop.clone(), - self.ditto.clone(), - self.event_id.clone(), - self.coordinate.clone(), - ]; - - items - .into_iter() - .zip(values) - .map(|((label, detail), value)| { - MenuItem::new(SharedString::from(format!("{label} {detail}"))).handler( - move |_, cx| { - cx.write_to_clipboard(ClipboardItem::new_string(value.clone())); - }, - ) - }) - .collect() - } -} - -fn truncate_naddr_link(url: &str, tail: usize) -> String { - let Some(end) = url.find("naddr1").map(|i| i + "naddr1".len()) else { - return url.to_string(); - }; - if url.len() - end <= tail + 3 { - return url.to_string(); - } - format!("{}...{}", &url[..end], &url[url.len() - tail..]) -} - -fn load_repo_data(repo: &Repo) -> Result { - let snapshot = repo.snapshot()?; - let worktree = repo.workdir().map(Path::to_path_buf); - let tree = build_tree_items(&snapshot.entries); - - Ok(RepoData { - tree, - entries: snapshot.entries, - readme_path: snapshot.readme_path, - readme: snapshot.readme, - worktree, - branches: snapshot.branches, - tags: snapshot.tags, - current_branch: snapshot.current_branch, - head_commit: snapshot.head_commit, - }) -} - -/// The announcement's name or ID for announced repositories, the directory name for local ones. -pub fn repo_display_name(store: &RepoStore) -> SharedString { - if store.addr().is_none() { - return store - .path - .as_ref() - .map(|path| { - SharedString::from( - path.file_name() - .map(|name| name.to_string_lossy().into_owned()) - .unwrap_or_else(|| path.display().to_string()), - ) - }) - .unwrap_or_default(); - } - - store - .announcement - .as_ref() - .map(|announcement| { - announcement - .name - .as_deref() - .map(SharedString::from) - .unwrap_or_else(|| SharedString::from(announcement.id.clone())) - }) - .unwrap_or_default() -} - -/// The owner and identifier a local repository is already bound to. -fn bound_repo_label( - binding: &Nip34Binding, - directory: &Entity, - cx: &App, -) -> AnyElement { - let profile = binding.owner.map(|pk| directory.read(cx).profile(&pk, cx)); - - h_flex() - .flex_shrink_0() - .gap_2() - .text_sm() - .child("Initialized by") - .text_color(cx.theme().colors.muted_foreground) - .when_some(profile, |this, profile| { - let name = profile.name(); - this.child( - h_flex() - .gap_1() - .text_color(cx.theme().colors.foreground) - .child( - Avatar::new(profile.public_key()) - .name(name.clone()) - .picture(profile.picture()) - .small(), - ) - .child(name), - ) - }) - .when_some(binding.identifier.as_deref(), |this, ident| { - this.child( - div() - .text_xs() - .font_weight(gpui::FontWeight::SEMIBOLD) - .child(SharedString::from(format!("/{ident}"))), - ) - }) - .into_any_element() -} - -/// The profile avatar shown on the repository tab. -pub(crate) fn repo_tab_avatar( - store: &RepoStore, - directory: &Entity, - cx: &App, -) -> AnyElement { - let seed = store - .announcement - .as_ref() - .map(|announcement| format!("{}:{}", announcement.owner, announcement.id)) - .or_else(|| store.addr().map(|addr| addr.to_string())) - .or_else(|| { - store - .path - .as_ref() - .map(|path| path.to_string_lossy().into_owned()) - }) - .unwrap_or_default(); - - let owner = store - .announcement - .as_ref() - .map(|announcement| directory.read(cx).profile(&announcement.owner, cx)); - - let (name, picture) = owner - .map(|profile| (Some(profile.name()), profile.picture())) - .unwrap_or((None, None)); - - let avatar = match name { - Some(name) => Avatar::new(seed).name(name), - None => Avatar::new(seed), - }; - - avatar.picture(picture).small().into_any_element() -} diff --git a/crates/signed_app/src/views/repo/refs.rs b/crates/signed_app/src/repo/refs.rs similarity index 76% rename from crates/signed_app/src/views/repo/refs.rs rename to crates/signed_app/src/repo/refs.rs index 91aa388..8fd70f2 100644 --- a/crates/signed_app/src/views/repo/refs.rs +++ b/crates/signed_app/src/repo/refs.rs @@ -2,6 +2,7 @@ use gpui::{App, AppContext as _, Entity, SharedString, WeakEntity, Window}; use gpui_native_kit::combobox::{Combobox, ComboboxState}; use super::RepoDetailView; +use super::helpers::sync_selector; pub(super) struct RefSwitcher { pub(super) branch_select: Entity>, @@ -98,42 +99,3 @@ impl RefSwitcher { }); } } - -fn sync_selector( - select: &Entity>, - cached: &mut Vec, - items: Vec, - selected: Option, - window: &mut Window, - cx: &mut App, -) -> bool { - let items_changed = *cached != items; - - let selection_changed = selected - .as_ref() - .is_some_and(|value| select.read(cx).selected() != Some(value)); - - if !items_changed && !selection_changed { - return false; - } - - select.update(cx, |state, cx| { - if items_changed { - state.set_options( - items - .iter() - .map(|item| (item.clone(), item.clone())) - .collect(), - cx, - ); - } - if let Some(value) = selected - && (items_changed || selection_changed) - { - state.set_selected(Some(value), window, cx); - } - }); - *cached = items; - - true -} diff --git a/crates/signed_app/src/dialogs/mod.rs b/crates/signed_app/src/shared/dialogs.rs similarity index 76% rename from crates/signed_app/src/dialogs/mod.rs rename to crates/signed_app/src/shared/dialogs.rs index b992b82..df78d3e 100644 --- a/crates/signed_app/src/dialogs/mod.rs +++ b/crates/signed_app/src/shared/dialogs.rs @@ -1,15 +1,3 @@ -pub mod about; -pub mod create_repo; -pub mod exit; -pub mod grasp_servers; -pub mod import; -pub mod init_repo; -pub mod new_issue; -pub mod onboarding; -pub mod passphrase; -pub mod progress; -pub mod reset_remote; - use gpui::prelude::FluentBuilder as _; use gpui::{ AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _, @@ -61,16 +49,6 @@ pub fn close_global(cx: &mut App) { host.update(cx, |host, cx| host.close(cx)); } -/// Opens the onboarding form. -pub fn open_onboarding(cx: &mut App) { - open_global(onboarding::OnboardingDialog::new(cx), cx); -} - -/// Opens the identity import form. -pub fn open_import(cx: &mut App) { - open_global(import::ImportDialog::new(cx), cx); -} - impl Render for DialogHost { fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { div() diff --git a/crates/signed_app/src/shared/mod.rs b/crates/signed_app/src/shared/mod.rs new file mode 100644 index 0000000..247c9e2 --- /dev/null +++ b/crates/signed_app/src/shared/mod.rs @@ -0,0 +1,5 @@ +pub mod dialogs; +pub mod panels; +pub mod progress; +pub mod tree; +pub mod widgets; diff --git a/crates/signed_app/src/panels.rs b/crates/signed_app/src/shared/panels.rs similarity index 100% rename from crates/signed_app/src/panels.rs rename to crates/signed_app/src/shared/panels.rs diff --git a/crates/signed_app/src/dialogs/progress.rs b/crates/signed_app/src/shared/progress.rs similarity index 100% rename from crates/signed_app/src/dialogs/progress.rs rename to crates/signed_app/src/shared/progress.rs diff --git a/crates/signed_app/src/views/tree.rs b/crates/signed_app/src/shared/tree.rs similarity index 100% rename from crates/signed_app/src/views/tree.rs rename to crates/signed_app/src/shared/tree.rs diff --git a/crates/signed_app/src/shared/widgets.rs b/crates/signed_app/src/shared/widgets.rs new file mode 100644 index 0000000..4fd9d81 --- /dev/null +++ b/crates/signed_app/src/shared/widgets.rs @@ -0,0 +1,98 @@ +use assets::CustomIconName; +use gpui::{ + AnyElement, App, Entity, IntoElement, ParentElement as _, SharedString, Styled as _, div, +}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::badge::{Badge, BadgeVariant}; +use gpui_native_kit::common::Sizable as _; +use gpui_native_kit::empty_state::EmptyState; +use gpui_native_kit::icons::Icon; +use gpui_native_kit::theme::ActiveTheme as _; +use gpui_native_kit::tooltip::Tooltip; +use metadata::ProfileDirectory; +use signed_core::RepoStatus; +use signed_state::RepoStore; + +/// The status pill shown on issue and pull-request rows. +pub fn status_badge(status: RepoStatus) -> Badge { + let (label, variant, icon) = match status { + RepoStatus::Open => ("Open", BadgeVariant::Neutral, CustomIconName::GitIssueOpen), + RepoStatus::Closed => ( + "Closed", + BadgeVariant::Danger, + CustomIconName::GitIssueClosed, + ), + RepoStatus::Draft => ( + "Draft", + BadgeVariant::Warning, + CustomIconName::GitIssueOngoing, + ), + RepoStatus::Applied => ( + "Applied", + BadgeVariant::Success, + CustomIconName::GitIssueDone, + ), + }; + + Badge::new(format!("status-{label}"), label) + .variant(variant) + .icon(Icon::named(icon)) + .tooltip(Tooltip::text(label)) +} + +/// A centered empty-state message filling the available space. +pub fn empty_state(message: impl Into) -> AnyElement { + let message = message.into(); + gpui_native_kit::common::v_flex() + .size_full() + .items_center() + .justify_center() + .child(EmptyState::new(message)) + .into_any_element() +} + +/// The profile avatar shown on a repository's panel tab. +pub fn repo_tab_avatar( + store: &RepoStore, + directory: &Entity, + cx: &App, +) -> AnyElement { + let seed = store + .announcement + .as_ref() + .map(|announcement| format!("{}:{}", announcement.owner, announcement.id)) + .or_else(|| store.addr().map(|addr| addr.to_string())) + .or_else(|| { + store + .path + .as_ref() + .map(|path| path.to_string_lossy().into_owned()) + }) + .unwrap_or_default(); + + let owner = store + .announcement + .as_ref() + .map(|announcement| directory.read(cx).profile(&announcement.owner, cx)); + + let (name, picture) = owner + .map(|profile| (Some(profile.name()), profile.picture())) + .unwrap_or((None, None)); + + let avatar = match name { + Some(name) => Avatar::new(seed).name(name), + None => Avatar::new(seed), + }; + + avatar.picture(picture).small().into_any_element() +} + +/// A one-line muted label used by section headers. +pub fn section_label(text: impl Into, cx: &App) -> AnyElement { + div() + .text_xs() + .font_weight(gpui::FontWeight::SEMIBOLD) + .text_color(cx.theme().colors.muted_foreground) + .child(text.into()) + .into_any_element() +} diff --git a/crates/signed_app/src/sidebar/helpers.rs b/crates/signed_app/src/sidebar/helpers.rs new file mode 100644 index 0000000..c21583d --- /dev/null +++ b/crates/signed_app/src/sidebar/helpers.rs @@ -0,0 +1,51 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use gpui::{AnyElement, App, IntoElement, SharedString}; +use gpui_native_kit::avatar::Avatar; +use gpui_native_kit::common::Sizable as _; +use metadata::ProfileDirectory; +use signed_state::{Nip34Binding, ResolvedLocalRepo}; + +pub(super) 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() +} + +/// The platform that bound a repository, `"nak"` or `"ngit"`. +pub(super) 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 + } +} + +/// The bound owner's avatar, falling back to the path's pixel avatar when unowned. +pub(super) fn local_avatar( + entry: &ResolvedLocalRepo, + directory: &gpui::Entity, + cx: &App, +) -> AnyElement { + let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else { + return Avatar::new(entry.path.to_string_lossy()) + .small() + .into_any_element(); + }; + + let profile = directory.read(cx).profile(&owner, cx); + + Avatar::new(owner) + .name(profile.name()) + .picture(profile.picture()) + .small() + .into_any_element() +} diff --git a/crates/signed_app/src/views/sidebar.rs b/crates/signed_app/src/sidebar/mod.rs similarity index 91% rename from crates/signed_app/src/views/sidebar.rs rename to crates/signed_app/src/sidebar/mod.rs index e4babe7..51226f3 100644 --- a/crates/signed_app/src/views/sidebar.rs +++ b/crates/signed_app/src/sidebar/mod.rs @@ -1,6 +1,5 @@ use std::collections::HashMap; use std::sync::Arc; -use std::time::{SystemTime, UNIX_EPOCH}; use assets::CustomIconName; use gpui::{ @@ -22,12 +21,16 @@ use metadata::{Profile, ProfileDirectory}; use nostr::prelude::PublicKey; use signed_core::{Announcement, RepoAddr}; use signed_state::{ - Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Binding, Nip34Kind, RepoListStore, + Backend, BackendEvent, CheckoutsStore, LocalReposStore, Nip34Kind, RepoListStore, ResolvedLocalRepo, SyncStatusDirectory, }; -use crate::dialogs; -use crate::views::explore::ExploreView; +mod helpers; + +use crate::explore::ExploreView; +use crate::identity; +use crate::repo::dialogs; +use helpers::{local_avatar, local_platform, pick_banner}; pub struct SidebarView { weak: WeakEntity, @@ -351,7 +354,7 @@ impl SidebarView { if row < self.announcements.len() { let announcement = self.announcements[row].clone(); let addr = announcement.addr(); - crate::views::repo::actions::open_repo_panel( + crate::repo::actions::open_repo_panel( &self.dock, &addr, Some(&announcement), @@ -384,12 +387,12 @@ impl SidebarView { let id = SharedString::from(format!("repo:{}", announcement.addr())); let dock = self.dock.clone(); let dock_for_build = dock.clone(); - crate::panels::open( + crate::shared::panels::open( &dock, id, move |window, cx| { cx.new(|cx| { - crate::views::repo::RepoDetailView::new_local_announced( + crate::repo::RepoDetailView::new_local_announced( dock_for_build.clone(), announcement.clone(), path.clone(), @@ -407,12 +410,12 @@ impl SidebarView { let id = SharedString::from(format!("repo-local:{}", path.display())); let dock = self.dock.clone(); let dock_for_build = dock.clone(); - crate::panels::open( + crate::shared::panels::open( &dock, id, move |window, cx| { cx.new(|cx| { - crate::views::repo::RepoDetailView::new_local( + crate::repo::RepoDetailView::new_local( dock_for_build.clone(), path.clone(), nip34.clone(), @@ -429,7 +432,7 @@ impl SidebarView { fn open_explore(&mut self, window: &mut Window, cx: &mut Context) { let dock = self.dock.clone(); let dock_for_build = dock.clone(); - crate::panels::open( + crate::shared::panels::open( &dock, "explore", move |_window, cx| cx.new(|cx| ExploreView::new(dock_for_build.clone(), cx)), @@ -517,7 +520,7 @@ impl SidebarView { MenuItem::separator(), MenuItem::new("Exit") .destructive(true) - .handler(|_, cx| dialogs::exit::open(cx)), + .handler(|_, cx| identity::helpers::open_exit(cx)), ] }); @@ -544,7 +547,7 @@ impl SidebarView { .on_click({ let dock = dock.clone(); move |_, window, cx| { - dialogs::create_repo::open(dock.clone(), window, cx); + dialogs::create::open(dock.clone(), window, cx); } }), ), @@ -581,7 +584,7 @@ impl SidebarView { move |_, window, cx| { if let Err(error) = weak.update(cx, |this, cx| { this.open_panel("welcome", window, cx); - crate::dialogs::open_onboarding(cx); + identity::helpers::open_onboarding(cx); }) { log::warn!("failed to open onboarding: {error}"); } @@ -593,7 +596,7 @@ impl SidebarView { .variant(ButtonVariant::Secondary) .on_click(move |_, _, cx| { if let Err(error) = import_weak - .update(cx, |_, cx| crate::dialogs::open_import(cx)) + .update(cx, |_, cx| identity::helpers::open_import(cx)) { log::warn!("failed to open import: {error}"); } @@ -603,47 +606,3 @@ impl SidebarView { ) } } - -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() -} - -/// 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 - } -} - -/// The bound owner's avatar, falling back to the path's pixel avatar when unowned. -fn local_avatar( - entry: &ResolvedLocalRepo, - directory: &gpui::Entity, - cx: &App, -) -> AnyElement { - let Some(owner) = entry.nip34.as_ref().and_then(|binding| binding.owner) else { - return Avatar::new(entry.path.to_string_lossy()) - .small() - .into_any_element(); - }; - - let profile = directory.read(cx).profile(&owner, cx); - - Avatar::new(owner) - .name(profile.name()) - .picture(profile.picture()) - .small() - .into_any_element() -} diff --git a/crates/signed_app/src/views/mod.rs b/crates/signed_app/src/views/mod.rs deleted file mode 100644 index bc3091b..0000000 --- a/crates/signed_app/src/views/mod.rs +++ /dev/null @@ -1,8 +0,0 @@ -pub mod discussion; -pub mod explore; -pub mod issues; -pub mod placeholder; -pub mod repo; -pub mod sidebar; -pub mod status_list; -pub mod tree; diff --git a/crates/signed_app/src/views/status_list.rs b/crates/signed_app/src/views/status_list.rs deleted file mode 100644 index 1381ba1..0000000 --- a/crates/signed_app/src/views/status_list.rs +++ /dev/null @@ -1,45 +0,0 @@ -use nostr::prelude::Event; -use signed_core::RepoStatus; - -/// Root events counted by their resolved status. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] -pub struct StatusCounts { - pub total: usize, - pub open: usize, - pub closed: usize, - pub draft: usize, - pub applied: usize, -} - -impl StatusCounts { - fn record(&mut self, status: RepoStatus) { - self.total += 1; - match status { - RepoStatus::Open => self.open += 1, - RepoStatus::Closed => self.closed += 1, - RepoStatus::Draft => self.draft += 1, - RepoStatus::Applied => self.applied += 1, - } - } -} - -/// Indices of `roots` whose status `keep` accepts, counting every root's status. -pub fn filter_by_status<'a>( - roots: impl IntoIterator, - status_of: impl Fn(&Event) -> RepoStatus, - keep: impl Fn(RepoStatus) -> bool, -) -> (Vec, StatusCounts) { - let mut counts = StatusCounts::default(); - - let visible = roots - .into_iter() - .enumerate() - .filter_map(|(index, root)| { - let status = status_of(root); - counts.record(status); - keep(status).then_some(index) - }) - .collect(); - - (visible, counts) -}