add passphrase dialog

This commit is contained in:
2026-08-07 07:24:33 +07:00
parent e2ec35a673
commit b137f54a66
6 changed files with 241 additions and 7 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
mod repo_list;
mod sidebar;
pub(crate) mod sidebar;
pub use repo_list::RepoListView;
pub use sidebar::SidebarPanel;
+3 -2
View File
@@ -13,8 +13,9 @@ use signed_state::{Backend, BackendEvent};
use super::RepoListView;
mod import_identity_dialog;
mod import_dialog;
mod onboarding_dialog;
pub(crate) mod passphrase_dialog;
use self::onboarding_dialog::OnboardingState;
@@ -95,7 +96,7 @@ impl SidebarPanel {
/// Show the Import Identity dialog.
fn open_import(&mut self, window: &mut Window, cx: &mut Context<Self>) {
import_identity_dialog::open(window, cx);
import_dialog::open(window, cx);
}
}
@@ -0,0 +1,143 @@
use gpui::prelude::*;
use gpui::{AnyWindowHandle, App, Entity, SharedString, Subscription, Window, div, px};
use gpui_component::button::{Button, ButtonVariants};
use gpui_component::dialog::{DialogDescription, DialogFooter, DialogHeader, DialogTitle};
use gpui_component::form::{field, v_form};
use gpui_component::input::{Input, InputEvent, InputState};
use gpui_component::{ActiveTheme, Disableable, WindowExt};
use signed_state::Backend;
/// Shared state for the passphrase dialog, so async results can be rendered.
#[derive(Default)]
pub struct PassphraseState {
pub busy: bool,
pub error: Option<SharedString>,
/// Keeps the Enter-to-submit subscription alive while the dialog is open.
_enter_subscription: Option<Subscription>,
}
/// Open the dialog asking for the passphrase that protects the stored
/// NIP-49 encrypted identity (`ncryptsec1...`).
///
/// Called when the backend emits [`signed_state::BackendEvent::PassphraseRequired`].
pub fn open(window: &mut Window, cx: &mut App) {
let pass_input = cx.new(|cx| {
InputState::new(window, cx)
.placeholder("Passphrase to unlock your identity")
.masked(true)
});
let handle = window.window_handle();
let state = cx.new(|_| PassphraseState::default());
// Enter in the passphrase field submits, same as the Unlock button.
let enter_pass_input = pass_input.clone();
let enter_state = state.clone();
let enter_subscription = cx.subscribe(&pass_input, move |_input, event, cx| {
if matches!(event, InputEvent::PressEnter { .. }) {
unlock(&enter_pass_input, &enter_state, &handle, cx);
}
});
state.update(cx, |state, _| {
state._enter_subscription = Some(enter_subscription)
});
window.open_dialog(cx, move |dialog, _window, _cx| {
let pass_input = pass_input.clone();
let state = state.clone();
dialog
.width(px(420.))
.margin_top(px(50.))
.content(move |content, _window, cx| {
let busy = state.read(cx).busy;
let error = state.read(cx).error.clone();
content
.child(
DialogHeader::new()
.child(DialogTitle::new().child("Unlock your identity"))
.child(
DialogDescription::new()
.child("Enter the passphrase used to encrypt this identity."),
),
)
.child(
v_form().child(
field()
.label("Passphrase")
.required(true)
.child(Input::new(&pass_input)),
),
)
.children(error.map(|message| {
div().text_sm().text_color(cx.theme().danger).child(message)
}))
.child(
DialogFooter::new().justify_end().child(
Button::new("unlock")
.primary()
.label("Unlock")
.loading(busy)
.disabled(busy)
.on_click({
let pass_input = pass_input.clone();
let state = state.clone();
move |_ev, _window, cx| {
unlock(&pass_input, &state, &handle, cx);
}
}),
),
)
})
});
}
/// Submit the passphrase to the backend. On success the dialog is closed;
/// on failure the error is rendered inline and the dialog stays open.
fn unlock(
pass_input: &Entity<InputState>,
state: &Entity<PassphraseState>,
handle: &AnyWindowHandle,
cx: &mut App,
) {
let backend = Backend::global(cx);
let pass = pass_input.read(cx).value().to_string();
if pass.is_empty() {
state.update(cx, |state, _| {
state.error = Some("Passphrase must not be empty".into());
});
return;
}
state.update(cx, |state, _| {
state.busy = true;
state.error = None;
});
let rx = backend.update(cx, |backend, cx| backend.restore_with_passphrase(&pass, cx));
let handle = *handle;
let state = state.clone();
cx.spawn(async move |cx| match rx.recv_async().await {
Ok(Ok(_)) => {
cx.update_window(handle, |_, window, cx| window.close_dialog(cx))
.ok();
}
Ok(Err(e)) => {
cx.update_window(handle, |_, _window, cx| {
state.update(cx, |state, _| {
state.busy = false;
state.error = Some(e.to_string().into());
});
})
.ok();
}
Err(_) => {}
})
.detach();
}
+20
View File
@@ -7,12 +7,14 @@ use gpui_component::{ActiveTheme, Root, StyledExt, TitleBar, h_flex, v_flex};
use signed_state::{Backend, BackendEvent};
use crate::views::SidebarPanel;
use crate::views::sidebar::passphrase_dialog;
/// Root view of the app: title bar, dock area, status bar.
pub struct Workspace {
dock: Entity<DockArea>,
status: SharedString,
_subscription: Subscription,
_passphrase_subscription: Subscription,
}
impl Workspace {
@@ -59,10 +61,28 @@ impl Workspace {
cx.notify();
});
// Ask for the passphrase when the stored identity is NIP-49
// encrypted. Subscribed via the window, since opening a dialog
// needs one.
let passphrase_subscription =
window.subscribe(&backend, cx, |_backend, event, window, cx| {
if matches!(event, BackendEvent::PassphraseRequired) {
passphrase_dialog::open(window, cx);
}
});
// The event may have fired before this window existed (the backend
// is initialized before the first window opens); fall back to the
// backend state in that case.
if backend.read(cx).passphrase_required() {
passphrase_dialog::open(window, cx);
}
Self {
dock,
status,
_subscription: subscription,
_passphrase_subscription: passphrase_subscription,
}
}
}