restructure signed_app into flat modules and bump gpui-native-kit
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-12 07:53:23 +07:00
parent 78b7cac4c8
commit 530be762ff
44 changed files with 1132 additions and 1078 deletions
Generated
+8 -8
View File
@@ -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",
+1 -1
View File
@@ -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" }
+1
View File
@@ -96,3 +96,4 @@ impl IconNamed for CustomIconName {
.into()
}
}
+12
View File
@@ -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),
}
}
@@ -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<LayoutState>,
@@ -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),
}
}
-32
View File
@@ -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))
}
+191
View File
@@ -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<usize> {
let announcements = &store.announcements;
let mut indices: Vec<usize> = (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<u32> = 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<Timestamp>,
announcements: &[Announcement],
directory: &Entity<ProfileDirectory>,
dock: &WeakEntity<DockState>,
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<SharedString> = 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()
}
@@ -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<usize> {
let announcements = &store.announcements;
let mut indices: Vec<usize> = (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<u32> = 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<RepoListStore>,
@@ -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<Self>) {
@@ -290,133 +253,3 @@ impl Render for ExploreView {
)
}
}
fn render_card(
ix: usize,
announcement: &Announcement,
last_activity: Option<Timestamp>,
announcements: &[Announcement],
directory: &Entity<ProfileDirectory>,
dock: &WeakEntity<DockState>,
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<SharedString> = 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()
}
@@ -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);
}
+23
View File
@@ -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);
}
@@ -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<Self> {
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<Self>) -> 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();
+5
View File
@@ -0,0 +1,5 @@
pub mod exit;
pub mod helpers;
pub mod import;
pub mod onboarding;
pub mod passphrase;
@@ -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<InputState>,
@@ -23,12 +23,14 @@ pub struct OnboardingDialog {
impl OnboardingDialog {
pub fn new(cx: &mut App) -> Entity<Self> {
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,
@@ -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<InputState>,
@@ -24,9 +24,10 @@ pub struct PassphraseDialog {
impl PassphraseDialog {
pub fn new(cx: &mut App) -> Entity<Self> {
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);
}
@@ -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<RepoStore>,
@@ -97,11 +97,7 @@ impl Panel for IssueDetailView {
}
fn lead(&self, cx: &gpui::App) -> Option<AnyElement> {
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))
}
}
@@ -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<Item = &'a Event>,
status_of: impl Fn(&Event) -> RepoStatus,
keep: impl Fn(RepoStatus) -> bool,
) -> (Vec<usize>, 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<RepoStore>,
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<RepoStore>,
root: EventId,
directory: &Entity<ProfileDirectory>,
@@ -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<RepoStore>,
root: EventId,
roots: fn(&RepoStore) -> &[Event],
@@ -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<DockState>,
store: Entity<RepoStore>,
@@ -185,11 +170,7 @@ impl Panel for IssuesView {
}
fn lead(&self, cx: &App) -> Option<AnyElement> {
Some(super::repo::repo_tab_avatar(
self.store.read(cx),
&self.directory,
cx,
))
Some(repo_tab_avatar(self.store.read(cx), &self.directory, cx))
}
}
@@ -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<RepoStore>,
+8 -5
View File
@@ -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};
@@ -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(
@@ -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<CommitDiff>,
seeds: Vec<crate::views::tree::TreeItemSeed>,
seeds: Vec<TreeItemSeed>,
expanded: HashSet<String>,
rows: Vec<TreeRow>,
selected_file: Option<SharedString>,
@@ -195,17 +194,17 @@ impl DiffPane {
fn render_detail_column(&self, cx: &mut Context<Self>) -> 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<AnyElement> {
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<DiffRow> {
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<SharedString>) -> AnyElement {
v_flex()
.size_full()
.items_center()
.justify_center()
.child(EmptyState::new(message))
.into_any_element()
}
@@ -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,
@@ -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 {
@@ -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,
@@ -0,0 +1,5 @@
pub mod about;
pub mod create;
pub mod grasp_servers;
pub mod init;
pub mod reset;
@@ -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<RepoStore>,
@@ -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<String> {
fn push(seed: &TreeItemSeed, ids: &mut HashSet<String>) {
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<SharedString>) -> 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"
)
})
}
+514
View File
@@ -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<TreeItemSeed>,
pub(super) entries: Vec<PathBuf>,
pub(super) readme_path: Option<PathBuf>,
pub(super) readme: Option<Vec<u8>>,
pub(super) worktree: Option<PathBuf>,
pub(super) branches: Vec<String>,
pub(super) tags: Vec<String>,
pub(super) current_branch: Option<String>,
pub(super) head_commit: Option<FileCommit>,
}
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<RepoDetailView>,
) -> Option<AnyElement> {
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:<pubkey>:<repo-id>`.
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<MenuItem> {
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<RepoData, Error> {
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<ProfileDirectory>,
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<DiffRow> {
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<ComboboxState<SharedString>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
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
}
@@ -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<RepoStore>,
@@ -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()
}
@@ -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<Subscription>,
}
struct RepoData {
tree: Vec<TreeItemSeed>,
entries: Vec<PathBuf>,
readme_path: Option<PathBuf>,
readme: Option<Vec<u8>>,
worktree: Option<PathBuf>,
branches: Vec<String>,
tags: Vec<String>,
current_branch: Option<String>,
head_commit: Option<FileCommit>,
}
impl RepoDetailView {
pub fn new(
dock: WeakEntity<DockState>,
@@ -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<Self>) {
pub(super) fn open_upstream(&mut self, window: &mut Window, cx: &mut Context<Self>) {
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<Self>) {
@@ -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<RepoDetailView>) -> Option<AnyElement> {
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:<pubkey>:<repo-id>`.
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<MenuItem> {
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<RepoData, Error> {
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<ProfileDirectory>,
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<ProfileDirectory>,
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()
}
@@ -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<ComboboxState<SharedString>>,
@@ -98,42 +99,3 @@ impl RefSwitcher {
});
}
}
fn sync_selector(
select: &Entity<ComboboxState<SharedString>>,
cached: &mut Vec<SharedString>,
items: Vec<SharedString>,
selected: Option<SharedString>,
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
}
@@ -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<Self>) -> impl IntoElement {
div()
+5
View File
@@ -0,0 +1,5 @@
pub mod dialogs;
pub mod panels;
pub mod progress;
pub mod tree;
pub mod widgets;
+98
View File
@@ -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<SharedString>) -> 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<ProfileDirectory>,
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<SharedString>, cx: &App) -> AnyElement {
div()
.text_xs()
.font_weight(gpui::FontWeight::SEMIBOLD)
.text_color(cx.theme().colors.muted_foreground)
.child(text.into())
.into_any_element()
}
+51
View File
@@ -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<ProfileDirectory>,
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()
}
@@ -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<Self>,
@@ -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<Self>) {
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<ProfileDirectory>,
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()
}
-8
View File
@@ -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;
@@ -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<Item = &'a Event>,
status_of: impl Fn(&Event) -> RepoStatus,
keep: impl Fn(RepoStatus) -> bool,
) -> (Vec<usize>, 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)
}