wire up app shell with dock, sidebar, and dialogs
Rust / build (macos-latest, stable) (push) Waiting to run
Rust / build (ubuntu-latest, stable) (push) Waiting to run
Rust / build (windows-latest, stable) (push) Waiting to run
Rust / build (macos-latest, stable) (pull_request) Waiting to run
Rust / build (ubuntu-latest, stable) (pull_request) Waiting to run
Rust / build (windows-latest, stable) (pull_request) Waiting to run

This commit is contained in:
2026-11-11 19:41:38 +07:00
parent c32030cf21
commit 1f65a0111f
12 changed files with 1297 additions and 8 deletions
+62
View File
@@ -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);
}
+337
View File
@@ -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();
}
+28
View File
@@ -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::{
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 {
fn render(&mut self, _window: &mut Window, _cx: &mut Context<Self>) -> impl IntoElement {
div()
+139
View File
@@ -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]
})
}
}
+139
View File
@@ -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);
}
+128
View File
@@ -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())
}