wire up app shell with dock, sidebar, and dialogs
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run
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:
Generated
+1
@@ -6927,6 +6927,7 @@ dependencies = [
|
|||||||
"gpui-native-kit",
|
"gpui-native-kit",
|
||||||
"log",
|
"log",
|
||||||
"metadata",
|
"metadata",
|
||||||
|
"nostr",
|
||||||
"paths",
|
"paths",
|
||||||
"settings",
|
"settings",
|
||||||
"signed_core",
|
"signed_core",
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ utils = { path = "../utils" }
|
|||||||
|
|
||||||
gpui.workspace = true
|
gpui.workspace = true
|
||||||
gpui-native-kit.workspace = true
|
gpui-native-kit.workspace = true
|
||||||
|
nostr.workspace = true
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
futures.workspace = true
|
futures.workspace = true
|
||||||
log.workspace = true
|
log.workspace = true
|
||||||
|
|||||||
@@ -1,32 +1,80 @@
|
|||||||
use gpui::{
|
use gpui::{
|
||||||
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render, Styled as _,
|
||||||
Window, div,
|
Subscription, Window, div,
|
||||||
};
|
};
|
||||||
use gpui_native_kit::empty_state::EmptyState;
|
use gpui_native_kit::icons::IconRole;
|
||||||
use gpui_native_kit::layout::dock::DockState;
|
use gpui_native_kit::layout::dock::{Dock, DockState};
|
||||||
use gpui_native_kit::layout::shell::{LayoutState, Shell};
|
use gpui_native_kit::layout::shell::{LayoutState, Shell};
|
||||||
use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override};
|
use gpui_native_kit::theme::{ActiveTheme as _, ThemeVariant, follow_system, set_mode_override};
|
||||||
use settings::{AppearanceMode, Settings};
|
use gpui_native_kit::toast::Toast;
|
||||||
|
use settings::{AppearanceMode, Settings, SettingsStore};
|
||||||
|
use signed_state::{Backend, BackendEvent};
|
||||||
|
|
||||||
use crate::dialogs::DialogHost;
|
use crate::dialogs::{self, DialogHost};
|
||||||
|
use crate::views::placeholder::PlaceholderView;
|
||||||
|
use crate::views::sidebar::SidebarView;
|
||||||
|
|
||||||
pub struct AppRoot {
|
pub struct AppRoot {
|
||||||
layout: Entity<LayoutState>,
|
layout: Entity<LayoutState>,
|
||||||
dock: Entity<DockState>,
|
dock: Entity<DockState>,
|
||||||
|
sidebar: Entity<SidebarView>,
|
||||||
dialogs: Entity<DialogHost>,
|
dialogs: Entity<DialogHost>,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppRoot {
|
impl AppRoot {
|
||||||
pub fn new(_window: &mut Window, cx: &mut App) -> Self {
|
pub fn new(window: &mut Window, cx: &mut Context<Self>) -> Self {
|
||||||
let layout = LayoutState::new(cx);
|
let layout = LayoutState::new(cx);
|
||||||
let dock = DockState::new(cx);
|
let dock = DockState::new(cx);
|
||||||
|
|
||||||
|
dock.update(cx, |dock, cx| {
|
||||||
|
dock.register_panel("welcome", |_, cx| {
|
||||||
|
cx.new(|_| PlaceholderView::new("Welcome"))
|
||||||
|
});
|
||||||
|
dock.register_panel("profile", |_, cx| {
|
||||||
|
cx.new(|_| PlaceholderView::new("Profile"))
|
||||||
|
});
|
||||||
|
dock.register_panel("relays", |_, cx| cx.new(|_| PlaceholderView::new("Relays")));
|
||||||
|
dock.open_panel("welcome", window, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let sidebar = cx.new(|cx| SidebarView::new(dock.downgrade(), cx));
|
||||||
let dialogs = cx.new(DialogHost::new);
|
let dialogs = cx.new(DialogHost::new);
|
||||||
DialogHost::set_global(dialogs.clone(), cx);
|
DialogHost::set_global(dialogs.clone(), cx);
|
||||||
|
|
||||||
|
let mut subscriptions = Vec::new();
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
subscriptions.push(
|
||||||
|
cx.subscribe(&backend, |this, _backend, event, cx| match event {
|
||||||
|
BackendEvent::PassphraseRequired => dialogs::passphrase::open(cx),
|
||||||
|
BackendEvent::Error(message) => {
|
||||||
|
this.layout.update(cx, |layout, cx| {
|
||||||
|
layout.push_toast(Toast::new(message.clone()).icon(IconRole::Warning), cx);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
let settings = SettingsStore::global(cx);
|
||||||
|
subscriptions.push(cx.observe(&settings, |_this, store, cx| {
|
||||||
|
let settings = store.read(cx).settings().clone();
|
||||||
|
apply_appearance(&settings, cx);
|
||||||
|
}));
|
||||||
|
|
||||||
|
window.defer(cx, |_window, cx| {
|
||||||
|
let required = Backend::global(cx).read(cx).passphrase_required();
|
||||||
|
if required {
|
||||||
|
dialogs::passphrase::open(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
layout,
|
layout,
|
||||||
dock,
|
dock,
|
||||||
|
sidebar,
|
||||||
dialogs,
|
dialogs,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -40,7 +88,13 @@ impl Render for AppRoot {
|
|||||||
.text_color(theme.colors.foreground)
|
.text_color(theme.colors.foreground)
|
||||||
.child(
|
.child(
|
||||||
Shell::new("signed-shell", self.layout.clone())
|
Shell::new("signed-shell", self.layout.clone())
|
||||||
.center(EmptyState::new("Signed").hint("UI migration in progress")),
|
.left_sidebar(self.sidebar.read(cx).build(cx))
|
||||||
|
.center(
|
||||||
|
Dock::new("signed-dock", self.dock.clone())
|
||||||
|
.window_controls(true)
|
||||||
|
.traffic_light_pad(!self.layout.read(cx).is_left_sidebar_visible())
|
||||||
|
.sidebar_toggles(self.layout.clone()),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
.child(self.dialogs.clone())
|
.child(self.dialogs.clone())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Context, IntoElement, ParentElement as _, Render,
|
||||||
|
Styled as _, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||||
|
use gpui_native_kit::common::v_flex;
|
||||||
|
use gpui_native_kit::dialog::Dialog;
|
||||||
|
use gpui_native_kit::theme::ActiveTheme as _;
|
||||||
|
use signed_state::Backend;
|
||||||
|
|
||||||
|
use super::{close_global, open_global};
|
||||||
|
|
||||||
|
pub struct ExitDialog;
|
||||||
|
|
||||||
|
impl Render for ExitDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
Dialog::new("exit-dialog")
|
||||||
|
.open(true)
|
||||||
|
.on_change(|open, _, cx| {
|
||||||
|
if !open {
|
||||||
|
close_global(cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.title("Exit Signed?")
|
||||||
|
.content(|_, cx| {
|
||||||
|
v_flex()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(
|
||||||
|
"You will be signed out on this device and will need to sign in again on the next session.",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.actions(|_, _| {
|
||||||
|
let cancel: AnyElement = Button::new("cancel-exit")
|
||||||
|
.label("Cancel")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(|_, _, cx| close_global(cx))
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
let exit: AnyElement = Button::new("confirm-exit")
|
||||||
|
.label("Exit")
|
||||||
|
.variant(ButtonVariant::Destructive)
|
||||||
|
.on_click(|_, _, cx| {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
backend.update(cx, |backend, cx| backend.sign_out(cx));
|
||||||
|
close_global(cx);
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
vec![cancel, exit]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Opens the sign-out confirmation.
|
||||||
|
pub fn open(cx: &mut App) {
|
||||||
|
open_global(cx.new(|_| ExitDialog), cx);
|
||||||
|
}
|
||||||
@@ -0,0 +1,337 @@
|
|||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||||
|
Styled as _, Subscription, Task, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::button::Button;
|
||||||
|
use gpui_native_kit::common::v_flex;
|
||||||
|
use gpui_native_kit::dialog::Dialog;
|
||||||
|
use gpui_native_kit::field::Field;
|
||||||
|
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};
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Default, PartialEq, Eq)]
|
||||||
|
enum Stage {
|
||||||
|
#[default]
|
||||||
|
Credential,
|
||||||
|
/// An `nsec` was entered; ask for a passphrase to encrypt it with.
|
||||||
|
Encrypt,
|
||||||
|
/// An `ncryptsec` was entered; ask for its passphrase to decrypt it.
|
||||||
|
Decrypt,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The credential entered on the first step.
|
||||||
|
enum Credential {
|
||||||
|
Nsec(String),
|
||||||
|
Ncryptsec(String),
|
||||||
|
Bunker(String),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Credential {
|
||||||
|
fn parse(input: &str) -> Option<Self> {
|
||||||
|
let input = input.trim();
|
||||||
|
|
||||||
|
if input.starts_with("nsec1") {
|
||||||
|
Some(Self::Nsec(input.to_owned()))
|
||||||
|
} else if input.starts_with("ncryptsec1") {
|
||||||
|
Some(Self::Ncryptsec(input.to_owned()))
|
||||||
|
} else if input.starts_with("bunker://") {
|
||||||
|
Some(Self::Bunker(input.to_owned()))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// State of the import dialog, so the current step and async results can be rendered.
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct ImportState {
|
||||||
|
stage: Stage,
|
||||||
|
credential: String,
|
||||||
|
progress: DialogProgress,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct ImportDialog {
|
||||||
|
secret_input: Entity<InputState>,
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
repass_input: Entity<InputState>,
|
||||||
|
state: Entity<ImportState>,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ImportDialog {
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
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();
|
||||||
|
|
||||||
|
for input in [&secret_input, &pass_input, &repass_input] {
|
||||||
|
let secret_input = secret_input.clone();
|
||||||
|
let pass_input = pass_input.clone();
|
||||||
|
let repass_input = repass_input.clone();
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
|
subscriptions.push(cx.subscribe(input, move |_this, _input, event, cx| {
|
||||||
|
if matches!(event, InputStateEvent::Submitted) {
|
||||||
|
submit(&secret_input, &pass_input, &repass_input, &state, cx);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
Self {
|
||||||
|
secret_input,
|
||||||
|
pass_input,
|
||||||
|
repass_input,
|
||||||
|
state,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for ImportDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
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();
|
||||||
|
|
||||||
|
Dialog::new("import-dialog")
|
||||||
|
.open(true)
|
||||||
|
.on_change(|open, _, cx| {
|
||||||
|
if !open {
|
||||||
|
close_global(cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.title("Import identity")
|
||||||
|
.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."
|
||||||
|
}
|
||||||
|
Stage::Encrypt => "Choose a passphrase to encrypt your key on this device.",
|
||||||
|
Stage::Decrypt => "Enter the passphrase used to encrypt this key.",
|
||||||
|
};
|
||||||
|
|
||||||
|
let form = match stage {
|
||||||
|
Stage::Credential => v_flex().gap_3().child(
|
||||||
|
Field::new("secret-field", "Secret key or bunker URI")
|
||||||
|
.child(Input::new("secret-input", secret_input.clone())),
|
||||||
|
),
|
||||||
|
Stage::Encrypt => v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
Field::new("passphrase-field", "Passphrase")
|
||||||
|
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Field::new("repeat-field", "Repeat passphrase")
|
||||||
|
.child(Input::new("repeat-input", repass_input.clone())),
|
||||||
|
),
|
||||||
|
Stage::Decrypt => v_flex().gap_3().child(
|
||||||
|
Field::new("passphrase-field", "Passphrase")
|
||||||
|
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(description),
|
||||||
|
)
|
||||||
|
.child(form)
|
||||||
|
.when_some(error_row(&state.progress.error, cx), |el, error| {
|
||||||
|
el.child(error)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.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();
|
||||||
|
let state = action_state.clone();
|
||||||
|
|
||||||
|
let import: AnyElement = Button::new("import-identity")
|
||||||
|
.label(label)
|
||||||
|
.loading(busy)
|
||||||
|
.disabled(busy)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
submit(&secret, &pass, &repass, &state, cx);
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
vec![import]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn submit(
|
||||||
|
secret_input: &Entity<InputState>,
|
||||||
|
pass_input: &Entity<InputState>,
|
||||||
|
repass_input: &Entity<InputState>,
|
||||||
|
state: &Entity<ImportState>,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
if state.read(cx).progress.busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match state.read(cx).stage {
|
||||||
|
Stage::Credential => start_import(secret_input, state, cx),
|
||||||
|
Stage::Encrypt => import_nsec(pass_input, repass_input, state, cx),
|
||||||
|
Stage::Decrypt => import_ncryptsec(pass_input, state, cx),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses the entered credential and either moves to the passphrase step or connects to a bunker.
|
||||||
|
fn start_import(secret_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
||||||
|
let credential = match Credential::parse(secret_input.read(cx).value()) {
|
||||||
|
Some(credential) => credential,
|
||||||
|
None => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state
|
||||||
|
.progress
|
||||||
|
.fail("Enter an nsec, ncryptsec or bunker:// URI");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match credential {
|
||||||
|
Credential::Nsec(secret) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.credential = secret;
|
||||||
|
state.stage = Stage::Encrypt;
|
||||||
|
state.progress.error = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Credential::Ncryptsec(secret) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.credential = secret;
|
||||||
|
state.stage = Stage::Decrypt;
|
||||||
|
state.progress.error = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Credential::Bunker(uri) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.begin();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let task = backend.update(cx, |backend, cx| backend.import_bunker(&uri, cx));
|
||||||
|
finish_import(task, state, cx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encrypts the entered `nsec` with the passphrase and signs in with it.
|
||||||
|
fn import_nsec(
|
||||||
|
pass_input: &Entity<InputState>,
|
||||||
|
repass_input: &Entity<InputState>,
|
||||||
|
state: &Entity<ImportState>,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let nsec = state.read(cx).credential.clone();
|
||||||
|
let pass = pass_input.read(cx).value().to_string();
|
||||||
|
let repass = repass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if pass.is_empty() {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.fail("Passphrase must not be empty");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if pass != repass {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.fail("Passphrases do not match");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.begin();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = backend.update(cx, |backend, cx| backend.import_nsec(&nsec, &pass, cx));
|
||||||
|
finish_import(task, state, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decrypts the entered `ncryptsec` with the passphrase and signs in with it.
|
||||||
|
fn import_ncryptsec(pass_input: &Entity<InputState>, state: &Entity<ImportState>, cx: &mut App) {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let ncryptsec = state.read(cx).credential.clone();
|
||||||
|
let pass = pass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if pass.is_empty() {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.fail("Passphrase must not be empty");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.begin();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let task = backend.update(cx, |backend, cx| {
|
||||||
|
backend.import_ncryptsec(&ncryptsec, &pass, cx)
|
||||||
|
});
|
||||||
|
finish_import(task, state, cx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs the import task to completion, closing the dialog on success.
|
||||||
|
fn finish_import<T: 'static>(
|
||||||
|
task: Task<Result<T, anyhow::Error>>,
|
||||||
|
state: &Entity<ImportState>,
|
||||||
|
cx: &mut App,
|
||||||
|
) {
|
||||||
|
let state = state.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |cx| match task.await {
|
||||||
|
Ok(_) => cx.update(close_global),
|
||||||
|
Err(error) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.progress.fail(error.to_string());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
@@ -1,3 +1,9 @@
|
|||||||
|
pub mod exit;
|
||||||
|
pub mod import;
|
||||||
|
pub mod onboarding;
|
||||||
|
pub mod passphrase;
|
||||||
|
pub mod progress;
|
||||||
|
|
||||||
use gpui::prelude::FluentBuilder as _;
|
use gpui::prelude::FluentBuilder as _;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _,
|
AnyView, App, Context, Entity, Global, IntoElement, ParentElement as _, Render, Styled as _,
|
||||||
@@ -37,6 +43,28 @@ impl DialogHost {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Replaces the mounted modal with `view`.
|
||||||
|
pub fn open_global(view: impl Into<AnyView>, cx: &mut App) {
|
||||||
|
let host = DialogHost::global(cx);
|
||||||
|
host.update(cx, |host, cx| host.open(view.into(), cx));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Unmounts the current modal, if any.
|
||||||
|
pub fn close_global(cx: &mut App) {
|
||||||
|
let host = DialogHost::global(cx);
|
||||||
|
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 {
|
impl Render for DialogHost {
|
||||||
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
div()
|
div()
|
||||||
|
|||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||||
|
Styled as _, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::button::Button;
|
||||||
|
use gpui_native_kit::common::v_flex;
|
||||||
|
use gpui_native_kit::dialog::Dialog;
|
||||||
|
use gpui_native_kit::field::Field;
|
||||||
|
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};
|
||||||
|
|
||||||
|
pub struct OnboardingDialog {
|
||||||
|
name_input: Entity<InputState>,
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
repass_input: Entity<InputState>,
|
||||||
|
state: Entity<DialogProgress>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl OnboardingDialog {
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
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,
|
||||||
|
pass_input,
|
||||||
|
repass_input,
|
||||||
|
state,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn create_identity(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.state.read(cx).busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let name = self.name_input.read(cx).value().to_string();
|
||||||
|
let pass = self.pass_input.read(cx).value().to_string();
|
||||||
|
let repass = self.repass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if pass != repass {
|
||||||
|
self.state.update(cx, |state, cx| {
|
||||||
|
state.fail("Passphrases do not match");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.update(cx, |state, cx| {
|
||||||
|
state.begin();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let task = backend.update(cx, |backend, cx| backend.create_identity(&name, &pass, cx));
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |_this, cx| match task.await {
|
||||||
|
Ok(_) => cx.update(close_global),
|
||||||
|
Err(error) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.fail(error.to_string());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for OnboardingDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let name_input = self.name_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 this = cx.entity().downgrade();
|
||||||
|
|
||||||
|
Dialog::new("onboarding-dialog")
|
||||||
|
.open(true)
|
||||||
|
.on_change(|open, _, cx| {
|
||||||
|
if !open {
|
||||||
|
close_global(cx);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.title("Create identity")
|
||||||
|
.content(move |_window, cx| {
|
||||||
|
let progress = state.read(cx);
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child("Set up your Signed identity to get started."),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Field::new("name-field", "Name")
|
||||||
|
.description("Max 255 characters")
|
||||||
|
.child(Input::new("name-input", name_input.clone())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Field::new("passphrase-field", "Passphrase")
|
||||||
|
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Field::new("repeat-field", "Repeat passphrase")
|
||||||
|
.child(Input::new("repeat-input", repass_input.clone())),
|
||||||
|
)
|
||||||
|
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.actions(move |_, cx| {
|
||||||
|
let busy = action_state.read(cx).busy;
|
||||||
|
let this = this.clone();
|
||||||
|
|
||||||
|
let create: AnyElement = Button::new("create-identity")
|
||||||
|
.label("Create new identity")
|
||||||
|
.loading(busy)
|
||||||
|
.disabled(busy)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
let _ = this.update(cx, |this, cx| this.create_identity(cx));
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
vec![create]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
use assets::CustomIconName;
|
||||||
|
use gpui::prelude::FluentBuilder as _;
|
||||||
|
use gpui::{
|
||||||
|
AnyElement, App, AppContext as _, Context, Entity, IntoElement, ParentElement as _, Render,
|
||||||
|
Styled as _, Subscription, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::button::Button;
|
||||||
|
use gpui_native_kit::common::v_flex;
|
||||||
|
use gpui_native_kit::dialog::Dialog;
|
||||||
|
use gpui_native_kit::field::Field;
|
||||||
|
use gpui_native_kit::icons::Icon;
|
||||||
|
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};
|
||||||
|
|
||||||
|
pub struct PassphraseDialog {
|
||||||
|
pass_input: Entity<InputState>,
|
||||||
|
state: Entity<DialogProgress>,
|
||||||
|
_enter_subscription: Subscription,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PassphraseDialog {
|
||||||
|
pub fn new(cx: &mut App) -> Entity<Self> {
|
||||||
|
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 =
|
||||||
|
cx.subscribe(&pass_input, |this: &mut Self, _input, event, cx| {
|
||||||
|
if matches!(event, InputStateEvent::Submitted) {
|
||||||
|
this.unlock(cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
pass_input,
|
||||||
|
state,
|
||||||
|
_enter_subscription: enter_subscription,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unlock(&mut self, cx: &mut Context<Self>) {
|
||||||
|
if self.state.read(cx).busy {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let pass = self.pass_input.read(cx).value().to_string();
|
||||||
|
|
||||||
|
if pass.is_empty() {
|
||||||
|
self.state.update(cx, |state, cx| {
|
||||||
|
state.fail("Passphrase must not be empty");
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.state.update(cx, |state, cx| {
|
||||||
|
state.begin();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let task = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
|
||||||
|
let state = self.state.clone();
|
||||||
|
|
||||||
|
cx.spawn(async move |_this, cx| match task.await {
|
||||||
|
Ok(_) => {
|
||||||
|
cx.update(close_global);
|
||||||
|
}
|
||||||
|
Err(error) => {
|
||||||
|
state.update(cx, |state, cx| {
|
||||||
|
state.fail(error.to_string());
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.detach();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for PassphraseDialog {
|
||||||
|
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
let pass_input = self.pass_input.clone();
|
||||||
|
let state = self.state.clone();
|
||||||
|
let action_state = state.clone();
|
||||||
|
let this = cx.entity().downgrade();
|
||||||
|
|
||||||
|
Dialog::new("passphrase-dialog")
|
||||||
|
.open(true)
|
||||||
|
.on_change(|_open, _, _| {})
|
||||||
|
.title("Unlock your identity")
|
||||||
|
.content(move |_window, cx| {
|
||||||
|
let progress = state.read(cx);
|
||||||
|
v_flex()
|
||||||
|
.gap_3()
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child("Enter the passphrase used to encrypt this identity."),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Field::new("passphrase-field", "Passphrase")
|
||||||
|
.child(Input::new("passphrase-input", pass_input.clone())),
|
||||||
|
)
|
||||||
|
.when_some(error_row(&progress.error, cx), |el, error| el.child(error))
|
||||||
|
.when_some(push_steps_row(&progress.push_steps, cx), |el, steps| {
|
||||||
|
el.child(steps)
|
||||||
|
})
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
.actions(move |_, cx| {
|
||||||
|
let busy = action_state.read(cx).busy;
|
||||||
|
let this = this.clone();
|
||||||
|
|
||||||
|
let unlock: AnyElement = Button::new("unlock")
|
||||||
|
.label("Unlock")
|
||||||
|
.icon(Icon::named(CustomIconName::Unlock))
|
||||||
|
.loading(busy)
|
||||||
|
.disabled(busy)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
let _ = this.update(cx, |this, cx| this.unlock(cx));
|
||||||
|
})
|
||||||
|
.into_any_element();
|
||||||
|
|
||||||
|
vec![unlock]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
use gpui::{
|
||||||
|
AnyElement, App, Context, IntoElement as _, ParentElement as _, SharedString, Styled as _,
|
||||||
|
Subscription, div, px,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::common::{h_flex, v_flex};
|
||||||
|
use gpui_native_kit::icons::{Icon, IconRole};
|
||||||
|
use gpui_native_kit::spinner::Spinner;
|
||||||
|
use gpui_native_kit::theme::ActiveTheme as _;
|
||||||
|
use signed_core::RepoAddr;
|
||||||
|
use signed_state::{Backend, BackendEvent, PushStepStatus};
|
||||||
|
|
||||||
|
/// Progress of an async dialog action: a busy flag that disables the form and an error shown below it.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct DialogProgress {
|
||||||
|
pub busy: bool,
|
||||||
|
pub error: Option<SharedString>,
|
||||||
|
// The repository whose push checklist is shown; `None` outside a publish.
|
||||||
|
push_addr: Option<RepoAddr>,
|
||||||
|
pub push_steps: Vec<PushStepStatus>,
|
||||||
|
_push_subscription: Option<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DialogProgress {
|
||||||
|
/// Marks an action as started, disabling the form and clearing the previous error.
|
||||||
|
pub fn begin(&mut self) {
|
||||||
|
self.busy = true;
|
||||||
|
self.error = None;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Starts a publish for the repository at `addr` and listens for its push steps.
|
||||||
|
pub fn begin_push(&mut self, addr: RepoAddr, cx: &mut Context<Self>) {
|
||||||
|
self.begin();
|
||||||
|
self.push_addr = Some(addr);
|
||||||
|
self.push_steps.clear();
|
||||||
|
|
||||||
|
if self._push_subscription.is_none() {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
self._push_subscription = Some(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
|
let BackendEvent::PushProgress { addr, status } = event else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if this.push_addr.as_ref() != Some(addr) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
match this
|
||||||
|
.push_steps
|
||||||
|
.iter_mut()
|
||||||
|
.find(|step| step.step() == status.step())
|
||||||
|
{
|
||||||
|
Some(step) => *step = status.clone(),
|
||||||
|
None => this.push_steps.push(status.clone()),
|
||||||
|
}
|
||||||
|
|
||||||
|
cx.notify();
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Marks an action as failed, enabling the form and showing `message`.
|
||||||
|
pub fn fail(&mut self, message: impl Into<SharedString>) {
|
||||||
|
self.busy = false;
|
||||||
|
self.error = Some(message.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The shared error line under a dialog form, `None` when there is no error.
|
||||||
|
pub fn error_row(error: &Option<SharedString>, cx: &App) -> Option<AnyElement> {
|
||||||
|
error.as_ref().map(|message| {
|
||||||
|
div()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().ramps.destructive.base)
|
||||||
|
.child(message.clone())
|
||||||
|
.into_any_element()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The push checklist of a running publish, `None` before any step is reported.
|
||||||
|
pub fn push_steps_row(steps: &[PushStepStatus], cx: &App) -> Option<AnyElement> {
|
||||||
|
if steps.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let rows = steps
|
||||||
|
.iter()
|
||||||
|
.map(|status| {
|
||||||
|
let label = SharedString::from(status.step().label());
|
||||||
|
|
||||||
|
match status {
|
||||||
|
PushStepStatus::Running(_) => h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.muted_foreground)
|
||||||
|
.child(Spinner::new("push-step"))
|
||||||
|
.child(label)
|
||||||
|
.into_any_element(),
|
||||||
|
PushStepStatus::Done(_) => h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().colors.foreground)
|
||||||
|
.child(
|
||||||
|
Icon::named(IconRole::Check)
|
||||||
|
.size(px(14.))
|
||||||
|
.color(cx.theme().colors.foreground),
|
||||||
|
)
|
||||||
|
.child(label)
|
||||||
|
.into_any_element(),
|
||||||
|
PushStepStatus::Failed(_, reason) => h_flex()
|
||||||
|
.gap_2()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(cx.theme().ramps.destructive.base)
|
||||||
|
.child(
|
||||||
|
Icon::named(IconRole::Close)
|
||||||
|
.size(px(14.))
|
||||||
|
.color(cx.theme().ramps.destructive.base),
|
||||||
|
)
|
||||||
|
.child(SharedString::from(format!(
|
||||||
|
"{}: {reason}",
|
||||||
|
status.step().label()
|
||||||
|
)))
|
||||||
|
.into_any_element(),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
|
||||||
|
Some(v_flex().gap_1().children(rows).into_any_element())
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
pub mod placeholder;
|
||||||
|
pub mod sidebar;
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
use gpui::{
|
||||||
|
App, Context, IntoElement, ParentElement as _, Render, SharedString, Styled as _, Window, div,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::empty_state::EmptyState;
|
||||||
|
use gpui_native_kit::layout::dock::Panel;
|
||||||
|
|
||||||
|
/// A tab that stands in for a screen that is not ported yet.
|
||||||
|
pub struct PlaceholderView {
|
||||||
|
title: SharedString,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PlaceholderView {
|
||||||
|
pub fn new(title: impl Into<SharedString>) -> Self {
|
||||||
|
Self {
|
||||||
|
title: title.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Panel for PlaceholderView {
|
||||||
|
fn title(&self, _cx: &App) -> Option<SharedString> {
|
||||||
|
Some(self.title.clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Render for PlaceholderView {
|
||||||
|
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
|
||||||
|
div().size_full().child(EmptyState::new(self.title.clone()))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,369 @@
|
|||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
use gpui::{
|
||||||
|
App, Context, FontWeight, IntoElement, ObjectFit, ParentElement as _, SharedString,
|
||||||
|
Styled as _, StyledImage as _, Subscription, WeakEntity, Window, div, img, px,
|
||||||
|
};
|
||||||
|
use gpui_native_kit::avatar::Avatar;
|
||||||
|
use gpui_native_kit::button::{Button, ButtonVariant};
|
||||||
|
use gpui_native_kit::common::{h_flex, v_flex};
|
||||||
|
use gpui_native_kit::data::sidebar::Sidebar;
|
||||||
|
use gpui_native_kit::icons::{Icon, IconRole};
|
||||||
|
use gpui_native_kit::layout::dock::DockState;
|
||||||
|
use gpui_native_kit::menu::{Menu, MenuItem};
|
||||||
|
use gpui_native_kit::theme::ActiveTheme as _;
|
||||||
|
use metadata::{Profile, ProfileDirectory};
|
||||||
|
use nostr::prelude::PublicKey;
|
||||||
|
use signed_core::{Announcement, RepoAddr};
|
||||||
|
use signed_state::{
|
||||||
|
Backend, BackendEvent, CheckoutsStore, LocalReposStore, RepoListStore, ResolvedLocalRepo,
|
||||||
|
SyncStatusDirectory,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::dialogs;
|
||||||
|
|
||||||
|
pub struct SidebarView {
|
||||||
|
weak: WeakEntity<Self>,
|
||||||
|
dock: WeakEntity<DockState>,
|
||||||
|
banner: SharedString,
|
||||||
|
scanning: bool,
|
||||||
|
/// User's announced repositories.
|
||||||
|
announcements: Arc<Vec<Announcement>>,
|
||||||
|
/// Local repositories found by the scan that are not announced yet.
|
||||||
|
local_repos: Arc<Vec<ResolvedLocalRepo>>,
|
||||||
|
/// Unpushed commit counts per announced repository.
|
||||||
|
unpushed: HashMap<RepoAddr, usize>,
|
||||||
|
/// Profiles rendered by the sidebar.
|
||||||
|
directory: gpui::Entity<ProfileDirectory>,
|
||||||
|
/// Sync statuses for the announced repositories.
|
||||||
|
sync_directory: gpui::Entity<SyncStatusDirectory>,
|
||||||
|
user_menu_open: bool,
|
||||||
|
_subscriptions: Vec<Subscription>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SidebarView {
|
||||||
|
pub fn new(dock: WeakEntity<DockState>, cx: &mut Context<Self>) -> Self {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let repos = RepoListStore::global(cx);
|
||||||
|
let local = LocalReposStore::global(cx);
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
|
||||||
|
let mut subscriptions = Vec::new();
|
||||||
|
|
||||||
|
// Identity changes swap the whole sidebar between the sign-in screen and the signed-in content.
|
||||||
|
subscriptions.push(cx.subscribe(&backend, |this, _backend, event, cx| {
|
||||||
|
let signer_changed = matches!(event, BackendEvent::SignerChanged);
|
||||||
|
let signer_required = matches!(event, BackendEvent::SignerRequired);
|
||||||
|
|
||||||
|
if signer_required {
|
||||||
|
this.banner = pick_banner();
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
if this.refresh(cx) || signer_changed {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
subscriptions.push(cx.observe(&repos, |this, _repos, cx| {
|
||||||
|
if this.refresh(cx) {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
subscriptions.push(cx.observe(&local, |this, _local, cx| {
|
||||||
|
if this.refresh(cx) {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
// Push statuses are recomputed in the background, so only the badge counts change.
|
||||||
|
subscriptions.push(cx.observe(&checkouts, |this, _checkouts, cx| {
|
||||||
|
if this.refresh_unpushed(cx) {
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Self {
|
||||||
|
weak: cx.entity().downgrade(),
|
||||||
|
dock,
|
||||||
|
banner: pick_banner(),
|
||||||
|
announcements: Arc::new(Vec::new()),
|
||||||
|
local_repos: Arc::new(Vec::new()),
|
||||||
|
scanning: false,
|
||||||
|
unpushed: HashMap::new(),
|
||||||
|
directory: ProfileDirectory::new(cx),
|
||||||
|
sync_directory: SyncStatusDirectory::new(cx),
|
||||||
|
user_menu_open: false,
|
||||||
|
_subscriptions: subscriptions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh(&mut self, cx: &mut Context<Self>) -> bool {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
let user = backend.read(cx).current_user();
|
||||||
|
|
||||||
|
let (announcements, local_repos, scanning) = {
|
||||||
|
let repo_list = RepoListStore::global(cx).read(cx);
|
||||||
|
|
||||||
|
let announcements = user
|
||||||
|
.as_ref()
|
||||||
|
.map(|user| repo_list.announcements_of(user))
|
||||||
|
.unwrap_or_default();
|
||||||
|
|
||||||
|
let local = LocalReposStore::global(cx).read(cx);
|
||||||
|
let local_repos =
|
||||||
|
LocalReposStore::resolve(&local.repos, &repo_list.announcements, &announcements);
|
||||||
|
|
||||||
|
(announcements, local_repos, local.scanning)
|
||||||
|
};
|
||||||
|
|
||||||
|
let announcements_changed = *self.announcements != announcements;
|
||||||
|
let local_changed = *self.local_repos != local_repos;
|
||||||
|
let scanning_changed = self.scanning != scanning;
|
||||||
|
|
||||||
|
self.announcements = Arc::new(announcements);
|
||||||
|
self.local_repos = Arc::new(local_repos);
|
||||||
|
self.scanning = scanning;
|
||||||
|
|
||||||
|
let mut profiles: Vec<PublicKey> = user.into_iter().collect();
|
||||||
|
let owners: Vec<PublicKey> = self
|
||||||
|
.local_repos
|
||||||
|
.iter()
|
||||||
|
.filter_map(|entry| {
|
||||||
|
entry
|
||||||
|
.nip34
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|binding| binding.owner)
|
||||||
|
.filter(|owner| !profiles.contains(owner))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
profiles.extend(owners);
|
||||||
|
|
||||||
|
self.directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(profiles, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let addrs: Vec<RepoAddr> = self
|
||||||
|
.announcements
|
||||||
|
.iter()
|
||||||
|
.map(|announcement| announcement.addr())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
self.sync_directory.update(cx, |directory, cx| {
|
||||||
|
directory.track(addrs, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
if announcements_changed {
|
||||||
|
self.request_push_watches(cx);
|
||||||
|
self.unpushed.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
announcements_changed || local_changed || scanning_changed
|
||||||
|
}
|
||||||
|
|
||||||
|
fn refresh_unpushed(&mut self, cx: &mut Context<Self>) -> bool {
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
let mut unpushed = HashMap::with_capacity(self.announcements.len());
|
||||||
|
|
||||||
|
for announcement in self.announcements.iter() {
|
||||||
|
let addr = announcement.addr();
|
||||||
|
let count = checkouts.read(cx).unpushed(&addr);
|
||||||
|
if count > 0 {
|
||||||
|
unpushed.insert(addr, count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if unpushed == self.unpushed {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.unpushed = unpushed;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request_push_watches(&self, cx: &mut Context<Self>) {
|
||||||
|
let checkouts = CheckoutsStore::global(cx);
|
||||||
|
checkouts.update(cx, |checkouts, cx| {
|
||||||
|
for announcement in self.announcements.iter() {
|
||||||
|
checkouts.request_push_statuses(&announcement.addr(), cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Renders the sidebar for the current identity state.
|
||||||
|
pub fn build(&self, cx: &App) -> Sidebar {
|
||||||
|
let backend = Backend::global(cx);
|
||||||
|
|
||||||
|
let profile = backend
|
||||||
|
.read(cx)
|
||||||
|
.current_user()
|
||||||
|
.map(|public_key| self.directory.read(cx).profile(&public_key, cx));
|
||||||
|
|
||||||
|
let Some(profile) = profile else {
|
||||||
|
return Sidebar::new("signed-sidebar").content(self.render_sign_in(cx));
|
||||||
|
};
|
||||||
|
|
||||||
|
let weak = self.weak.clone();
|
||||||
|
|
||||||
|
Sidebar::new("signed-sidebar")
|
||||||
|
.transparent(true)
|
||||||
|
.header(self.render_user(&profile, cx))
|
||||||
|
.entries(|_, _| Vec::new())
|
||||||
|
.on_change(move |index, window, cx| {
|
||||||
|
let _ = weak.update(cx, |this, cx| this.activate(index, window, cx));
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn activate(&mut self, _index: Option<usize>, _window: &mut Window, _cx: &mut Context<Self>) {
|
||||||
|
// Repository and navigation rows arrive with their screens.
|
||||||
|
}
|
||||||
|
|
||||||
|
fn open_panel(&mut self, id: &str, window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
if let Err(error) = self
|
||||||
|
.dock
|
||||||
|
.update(cx, |dock, cx| dock.open_panel(id, window, cx))
|
||||||
|
{
|
||||||
|
log::warn!("failed to open panel {id}: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_user(&self, profile: &Profile, cx: &App) -> impl IntoElement {
|
||||||
|
let name = profile.name();
|
||||||
|
let picture = profile.picture();
|
||||||
|
let public_key = profile.public_key();
|
||||||
|
let weak = self.weak.clone();
|
||||||
|
let weak_for_open = self.weak.clone();
|
||||||
|
let muted = cx.theme().colors.muted_foreground;
|
||||||
|
|
||||||
|
Menu::new("user-menu")
|
||||||
|
.open(self.user_menu_open)
|
||||||
|
.on_change({
|
||||||
|
let weak = weak_for_open.clone();
|
||||||
|
move |open, _, cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| {
|
||||||
|
this.user_menu_open = open;
|
||||||
|
cx.notify();
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to update user menu: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.trigger({
|
||||||
|
let name = name.clone();
|
||||||
|
let picture = picture.clone();
|
||||||
|
move |_, _| {
|
||||||
|
h_flex()
|
||||||
|
.gap_1()
|
||||||
|
.items_center()
|
||||||
|
.child(
|
||||||
|
Avatar::new(public_key)
|
||||||
|
.name(name.clone())
|
||||||
|
.picture(picture.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
div()
|
||||||
|
.text_xs()
|
||||||
|
.font_weight(FontWeight::SEMIBOLD)
|
||||||
|
.child(name.clone()),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
Icon::named(IconRole::ChevronDown)
|
||||||
|
.size(px(12.))
|
||||||
|
.color(muted),
|
||||||
|
)
|
||||||
|
.into_any_element()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.items(move |_, _| {
|
||||||
|
let profile_weak = weak.clone();
|
||||||
|
let relays_weak = weak.clone();
|
||||||
|
|
||||||
|
vec![
|
||||||
|
MenuItem::new("View Profile").handler(move |window, cx| {
|
||||||
|
if let Err(error) = profile_weak.update(cx, |this, cx| {
|
||||||
|
this.open_panel("profile", window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to open profile: {error}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
MenuItem::new("View Relays").handler(move |window, cx| {
|
||||||
|
if let Err(error) = relays_weak.update(cx, |this, cx| {
|
||||||
|
this.open_panel("relays", window, cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to open relays: {error}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
MenuItem::separator(),
|
||||||
|
MenuItem::new("Exit")
|
||||||
|
.destructive(true)
|
||||||
|
.handler(|_, cx| dialogs::exit::open(cx)),
|
||||||
|
]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shown while no identity is signed in.
|
||||||
|
fn render_sign_in(&self, _cx: &App) -> impl IntoElement {
|
||||||
|
let weak = self.weak.clone();
|
||||||
|
let import_weak = self.weak.clone();
|
||||||
|
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.relative()
|
||||||
|
.child(
|
||||||
|
div().absolute().inset_0().child(
|
||||||
|
img(self.banner.clone())
|
||||||
|
.size_full()
|
||||||
|
.object_fit(ObjectFit::Cover),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.size_full()
|
||||||
|
.justify_end()
|
||||||
|
.p_4()
|
||||||
|
.gap_4()
|
||||||
|
.child(img("backgrounds/headline.png").max_w_48())
|
||||||
|
.child(
|
||||||
|
v_flex()
|
||||||
|
.gap_1()
|
||||||
|
.w_full()
|
||||||
|
.child(Button::new("onboarding").label("Join now").on_click(
|
||||||
|
move |_, window, cx| {
|
||||||
|
if let Err(error) = weak.update(cx, |this, cx| {
|
||||||
|
this.open_panel("welcome", window, cx);
|
||||||
|
crate::dialogs::open_onboarding(cx);
|
||||||
|
}) {
|
||||||
|
log::warn!("failed to open onboarding: {error}");
|
||||||
|
}
|
||||||
|
},
|
||||||
|
))
|
||||||
|
.child(
|
||||||
|
Button::new("import")
|
||||||
|
.label("Import identity")
|
||||||
|
.variant(ButtonVariant::Secondary)
|
||||||
|
.on_click(move |_, _, cx| {
|
||||||
|
if let Err(error) = import_weak
|
||||||
|
.update(cx, |_, cx| crate::dialogs::open_import(cx))
|
||||||
|
{
|
||||||
|
log::warn!("failed to open import: {error}");
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pick_banner() -> SharedString {
|
||||||
|
let num = SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|duration| duration.subsec_nanos())
|
||||||
|
.unwrap_or(1)
|
||||||
|
% 3
|
||||||
|
+ 1;
|
||||||
|
format!("backgrounds/banner{num}.jpg").into()
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user