2846 lines
105 KiB
Rust
2846 lines
105 KiB
Rust
//! Pure UI state machine. Storage behavior is represented only by typed results.
|
|
|
|
use std::{collections::BTreeSet, time::Instant};
|
|
|
|
use ironstorage::{
|
|
command::{
|
|
CommandRequest, GitRequest, OtpCodeRequest, OtpRequest, OtpUriPresentation, OtpUriRequest,
|
|
Presentation, help_text, otp_version_text, version_text,
|
|
},
|
|
config::Config,
|
|
config::SshFingerprint,
|
|
crypto::KeyInfo,
|
|
document::{DocumentError, EntryDocument, EntryFieldId},
|
|
git::{GitConflict, GitProgressPhase, GitSnapshot, SshHostKey},
|
|
otp::OtpCodeValidity,
|
|
presentation::{ClipboardDisposition, ClipboardError, QrMatrix},
|
|
read::{FindResults, GrepResults, TreeModel},
|
|
repository::SecretBytes,
|
|
write::WriteOutcome,
|
|
};
|
|
|
|
use crate::{
|
|
action::{Action, WorkflowAction},
|
|
command::{CommandInvocation, CommandLine, operation_name},
|
|
editor::EntryEditor,
|
|
search::GrepView,
|
|
sidebar::{Sidebar, SidebarIntent},
|
|
viewer::EntryViewer,
|
|
workflow::{OtpFormKind, WorkflowForm, WorkflowInput, WorkflowSubmission},
|
|
};
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
|
pub enum Mode {
|
|
Browser,
|
|
Viewer,
|
|
Editor,
|
|
Dialog,
|
|
Help,
|
|
Command,
|
|
Locked,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum Transition {
|
|
OpenEntry,
|
|
EditEntry,
|
|
CloseEntry,
|
|
OpenDialog,
|
|
OpenHelp,
|
|
OpenCommand,
|
|
Dismiss,
|
|
Lock,
|
|
Unlock,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum PaneFocus {
|
|
Sidebar,
|
|
Main,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct RequestToken {
|
|
id: u64,
|
|
generation: u64,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub struct ClipboardPresentationId(pub(crate) u64);
|
|
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct ClipboardPresentation {
|
|
id: ClipboardPresentationId,
|
|
deadline: Option<Instant>,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct StartupData {
|
|
pub config: Config,
|
|
pub tree: TreeModel,
|
|
pub key: KeyInfo,
|
|
pub keys: Vec<KeyInfo>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct WorkflowSuccess {
|
|
pub tree: Option<TreeModel>,
|
|
pub selection: Option<(String, bool)>,
|
|
pub entry: Option<String>,
|
|
pub document: Option<Box<EntryDocument>>,
|
|
pub grep: Option<GrepResults>,
|
|
pub presentation: Option<SecretPresentation>,
|
|
pub status: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct GitView {
|
|
snapshot: GitSnapshot,
|
|
message: String,
|
|
conflicts: Vec<GitConflict>,
|
|
details: Option<SecretBytes>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct OtpDisplay {
|
|
entry: String,
|
|
field: Option<EntryFieldId>,
|
|
code: SecretBytes,
|
|
validity: OtpCodeValidity,
|
|
remaining_seconds: Option<u64>,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct QrPopup {
|
|
title: String,
|
|
matrix: QrMatrix,
|
|
}
|
|
|
|
impl QrPopup {
|
|
pub fn title(&self) -> &str {
|
|
&self.title
|
|
}
|
|
|
|
pub fn matrix(&self) -> &QrMatrix {
|
|
&self.matrix
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum SecretPresentation {
|
|
Clipboard(SecretBytes),
|
|
Qr { title: String, matrix: QrMatrix },
|
|
}
|
|
|
|
impl OtpDisplay {
|
|
pub fn entry(&self) -> &str {
|
|
&self.entry
|
|
}
|
|
pub fn field(&self) -> Option<EntryFieldId> {
|
|
self.field
|
|
}
|
|
pub fn code(&self) -> &SecretBytes {
|
|
&self.code
|
|
}
|
|
pub fn remaining_seconds(&self) -> Option<u64> {
|
|
self.remaining_seconds
|
|
}
|
|
pub fn counter(&self) -> Option<u64> {
|
|
self.validity.counter()
|
|
}
|
|
pub fn period_seconds(&self) -> Option<u64> {
|
|
self.validity.period()
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum OtpPresentationTarget {
|
|
Terminal,
|
|
Clipboard,
|
|
Qr,
|
|
}
|
|
|
|
#[derive(Clone, Debug, Eq, PartialEq)]
|
|
pub struct OtpUiRequest {
|
|
pub request: OtpRequest,
|
|
pub confirmed_hotp: bool,
|
|
pub field: Option<EntryFieldId>,
|
|
}
|
|
|
|
impl GitView {
|
|
pub fn snapshot(&self) -> &GitSnapshot {
|
|
&self.snapshot
|
|
}
|
|
pub fn message(&self) -> &str {
|
|
&self.message
|
|
}
|
|
pub fn conflicts(&self) -> &[GitConflict] {
|
|
&self.conflicts
|
|
}
|
|
pub fn details(&self) -> Option<&SecretBytes> {
|
|
self.details.as_ref()
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum AsyncPayload {
|
|
Startup(Box<StartupData>),
|
|
Refreshed(TreeModel),
|
|
Filtered {
|
|
query: String,
|
|
results: FindResults,
|
|
},
|
|
Found {
|
|
query: String,
|
|
results: FindResults,
|
|
},
|
|
DocumentLoaded {
|
|
entry: String,
|
|
document: Box<EntryDocument>,
|
|
},
|
|
ClipboardStarted {
|
|
presentation: ClipboardPresentationId,
|
|
deadline: Instant,
|
|
},
|
|
ClipboardFinished {
|
|
presentation: ClipboardPresentationId,
|
|
result: Result<ClipboardDisposition, ClipboardError>,
|
|
},
|
|
GeneratedField {
|
|
target: EntryFieldId,
|
|
password: SecretBytes,
|
|
},
|
|
DocumentSaveFinished {
|
|
entry: String,
|
|
editor: Box<EntryEditor>,
|
|
result: Result<WriteOutcome, EditorSaveFailure>,
|
|
},
|
|
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
|
|
GitProgress(GitProgressPhase),
|
|
GitFinished {
|
|
snapshot: Box<GitSnapshot>,
|
|
tree: Option<TreeModel>,
|
|
message: String,
|
|
conflicts: Vec<GitConflict>,
|
|
details: Option<SecretBytes>,
|
|
},
|
|
GitHostConfirmation {
|
|
request: GitRequest,
|
|
host_key: SshHostKey,
|
|
},
|
|
GitPassphraseRequired {
|
|
request: GitRequest,
|
|
fingerprint: SshFingerprint,
|
|
},
|
|
OtpCodeFinished {
|
|
entry: String,
|
|
field: Option<EntryFieldId>,
|
|
code: SecretBytes,
|
|
validity: OtpCodeValidity,
|
|
observed_at: u64,
|
|
clipboard: bool,
|
|
tree: Option<TreeModel>,
|
|
},
|
|
OtpUriFinished {
|
|
entry: String,
|
|
presentation: OtpPresentationTarget,
|
|
payload: SecretBytes,
|
|
qr: Option<QrMatrix>,
|
|
},
|
|
OtpValidated,
|
|
SecretPresented {
|
|
status: String,
|
|
presentation: SecretPresentation,
|
|
},
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum EditorSaveFailureKind {
|
|
Conflict,
|
|
Unchanged,
|
|
Storage,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct EditorSaveFailure {
|
|
kind: EditorSaveFailureKind,
|
|
message: String,
|
|
}
|
|
|
|
impl EditorSaveFailure {
|
|
pub fn new(kind: EditorSaveFailureKind, message: String) -> Self {
|
|
Self { kind, message }
|
|
}
|
|
|
|
pub fn kind(&self) -> EditorSaveFailureKind {
|
|
self.kind
|
|
}
|
|
|
|
pub fn message(&self) -> &str {
|
|
&self.message
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct AsyncResult {
|
|
pub token: RequestToken,
|
|
pub payload: Result<AsyncPayload, String>,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
pub enum ResultDisposition {
|
|
Applied,
|
|
Stale,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub enum AppEffect {
|
|
None,
|
|
RefreshTree,
|
|
AuthenticateEntry(String),
|
|
CopyFocused {
|
|
presentation: ClipboardPresentationId,
|
|
value: SecretBytes,
|
|
},
|
|
GenerateField(EntryFieldId),
|
|
SaveDocument {
|
|
config: Box<Config>,
|
|
entry: String,
|
|
editor: Box<EntryEditor>,
|
|
},
|
|
AuthenticateWorkflow(Box<WorkflowSubmission>),
|
|
AuthenticateGit(ironstorage::command::GitRequest),
|
|
RetryGitAfterHostConfirmation {
|
|
request: GitRequest,
|
|
host_key: SshHostKey,
|
|
},
|
|
RetryGitWithPassphrase {
|
|
request: GitRequest,
|
|
fingerprint: SshFingerprint,
|
|
passphrase: SecretBytes,
|
|
},
|
|
CancelGit,
|
|
ResolveGit(Vec<ironstorage::git::GitConflictResolution>),
|
|
AuthenticateOtp(OtpUiRequest),
|
|
AuthenticateShow(ironstorage::command::ShowRequest),
|
|
RunCommand(CommandRequest),
|
|
ManualLock,
|
|
}
|
|
|
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
|
enum CommandOpenTarget {
|
|
Viewer,
|
|
Editor,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
pub struct App {
|
|
mode: Mode,
|
|
suspended_mode: Option<Mode>,
|
|
focus: PaneFocus,
|
|
status: String,
|
|
config: Option<Config>,
|
|
default_key: Option<KeyInfo>,
|
|
available_keys: Vec<KeyInfo>,
|
|
sidebar: Sidebar,
|
|
selected_entry: Option<String>,
|
|
viewer: Option<EntryViewer>,
|
|
editor: Option<EntryEditor>,
|
|
discard_confirmation: bool,
|
|
command_line: CommandLine,
|
|
command_help: Option<String>,
|
|
command_open_target: Option<CommandOpenTarget>,
|
|
editor_generation_pending: Option<EntryFieldId>,
|
|
authentication_pending: Option<String>,
|
|
workflow: Option<WorkflowForm>,
|
|
workflow_pending: bool,
|
|
grep_view: Option<GrepView>,
|
|
git_view: Option<GitView>,
|
|
git_pending: bool,
|
|
otp_display: Option<OtpDisplay>,
|
|
qr_popup: Option<QrPopup>,
|
|
uri_popup: Option<SecretBytes>,
|
|
otp_pending: bool,
|
|
hotp_confirmation: Option<OtpUiRequest>,
|
|
clipboard_request: Option<SecretBytes>,
|
|
clipboard_presentation: Option<ClipboardPresentation>,
|
|
next_clipboard_presentation: u64,
|
|
remaining_lease: Option<std::time::Duration>,
|
|
terminal_size: (u16, u16),
|
|
ticks: u64,
|
|
should_quit: bool,
|
|
generation: u64,
|
|
next_request_id: u64,
|
|
pending: BTreeSet<u64>,
|
|
}
|
|
|
|
impl Default for App {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
impl App {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
mode: Mode::Browser,
|
|
suspended_mode: None,
|
|
focus: PaneFocus::Sidebar,
|
|
status: "Starting…".to_owned(),
|
|
config: None,
|
|
default_key: None,
|
|
available_keys: Vec::new(),
|
|
sidebar: Sidebar::default(),
|
|
selected_entry: None,
|
|
viewer: None,
|
|
editor: None,
|
|
discard_confirmation: false,
|
|
command_line: CommandLine::default(),
|
|
command_help: None,
|
|
command_open_target: None,
|
|
editor_generation_pending: None,
|
|
authentication_pending: None,
|
|
workflow: None,
|
|
workflow_pending: false,
|
|
grep_view: None,
|
|
git_view: None,
|
|
git_pending: false,
|
|
otp_display: None,
|
|
qr_popup: None,
|
|
uri_popup: None,
|
|
otp_pending: false,
|
|
hotp_confirmation: None,
|
|
clipboard_request: None,
|
|
clipboard_presentation: None,
|
|
next_clipboard_presentation: 0,
|
|
remaining_lease: None,
|
|
terminal_size: (0, 0),
|
|
ticks: 0,
|
|
should_quit: false,
|
|
generation: 0,
|
|
next_request_id: 0,
|
|
pending: BTreeSet::new(),
|
|
}
|
|
}
|
|
|
|
pub fn mode(&self) -> Mode {
|
|
self.mode
|
|
}
|
|
|
|
pub fn help_context_mode(&self) -> Mode {
|
|
if self.mode == Mode::Help {
|
|
self.suspended_mode.unwrap_or(Mode::Browser)
|
|
} else {
|
|
self.mode
|
|
}
|
|
}
|
|
|
|
pub fn command_context_mode(&self) -> Mode {
|
|
if self.mode == Mode::Command {
|
|
self.suspended_mode.unwrap_or(Mode::Browser)
|
|
} else {
|
|
self.mode
|
|
}
|
|
}
|
|
|
|
pub fn focus(&self) -> PaneFocus {
|
|
self.focus
|
|
}
|
|
|
|
pub fn status(&self) -> &str {
|
|
&self.status
|
|
}
|
|
|
|
pub fn config(&self) -> Option<&Config> {
|
|
self.config.as_ref()
|
|
}
|
|
|
|
pub fn sidebar(&self) -> &Sidebar {
|
|
&self.sidebar
|
|
}
|
|
|
|
pub fn sidebar_mut(&mut self) -> &mut Sidebar {
|
|
&mut self.sidebar
|
|
}
|
|
|
|
pub fn selected_entry(&self) -> Option<&str> {
|
|
self.selected_entry.as_deref()
|
|
}
|
|
|
|
pub fn viewer(&self) -> Option<&EntryViewer> {
|
|
self.viewer.as_ref()
|
|
}
|
|
|
|
pub fn editor(&self) -> Option<&EntryEditor> {
|
|
self.editor.as_ref()
|
|
}
|
|
|
|
pub fn discard_confirmation(&self) -> bool {
|
|
self.discard_confirmation
|
|
}
|
|
|
|
pub fn command_line(&self) -> &CommandLine {
|
|
&self.command_line
|
|
}
|
|
|
|
pub fn command_help(&self) -> Option<&str> {
|
|
self.command_help.as_deref()
|
|
}
|
|
|
|
pub fn default_key(&self) -> Option<&KeyInfo> {
|
|
self.default_key.as_ref()
|
|
}
|
|
|
|
pub fn authentication_pending(&self) -> bool {
|
|
self.authentication_pending.is_some() || self.workflow_pending
|
|
}
|
|
|
|
pub fn workflow(&self) -> Option<&WorkflowForm> {
|
|
self.workflow.as_ref()
|
|
}
|
|
|
|
pub fn grep_view(&self) -> Option<&GrepView> {
|
|
self.grep_view.as_ref()
|
|
}
|
|
|
|
pub fn git_view(&self) -> Option<&GitView> {
|
|
self.git_view.as_ref()
|
|
}
|
|
|
|
pub fn git_pending(&self) -> bool {
|
|
self.git_pending
|
|
}
|
|
|
|
pub fn otp_display(&self) -> Option<&OtpDisplay> {
|
|
self.otp_display.as_ref().filter(|display| {
|
|
self.selected_entry
|
|
.as_deref()
|
|
.is_some_and(|entry| entry == display.entry())
|
|
})
|
|
}
|
|
pub fn qr_popup(&self) -> Option<&QrPopup> {
|
|
self.qr_popup.as_ref()
|
|
}
|
|
pub fn uri_popup(&self) -> Option<&SecretBytes> {
|
|
self.uri_popup.as_ref()
|
|
}
|
|
pub fn otp_pending(&self) -> bool {
|
|
self.otp_pending
|
|
}
|
|
pub fn hotp_confirmation(&self) -> bool {
|
|
self.hotp_confirmation.is_some()
|
|
}
|
|
pub fn take_clipboard_effect(&mut self) -> Option<AppEffect> {
|
|
let value = self.clipboard_request.take()?;
|
|
Some(self.begin_clipboard_presentation(value))
|
|
}
|
|
|
|
pub fn clipboard_remaining_seconds(&self) -> Option<u64> {
|
|
self.clipboard_remaining_seconds_at(Instant::now())
|
|
}
|
|
|
|
fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option<u64> {
|
|
let deadline = self.clipboard_presentation?.deadline?;
|
|
let milliseconds = deadline.saturating_duration_since(now).as_millis();
|
|
Some(u64::try_from(milliseconds.div_ceil(1_000)).unwrap_or(u64::MAX))
|
|
}
|
|
|
|
pub fn clipboard_pending(&self) -> bool {
|
|
self.clipboard_presentation.is_some()
|
|
}
|
|
|
|
pub fn begin_git_operation(&mut self, label: &str) {
|
|
self.git_pending = true;
|
|
self.status = format!("{label} queued for secure-storage authentication…");
|
|
}
|
|
|
|
fn begin_git_retry(&mut self) {
|
|
self.git_pending = true;
|
|
self.workflow_pending = false;
|
|
self.workflow = None;
|
|
self.transition(Transition::Dismiss);
|
|
self.status = "Retrying Git operation with confirmed SSH input…".to_owned();
|
|
}
|
|
|
|
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
|
|
self.remaining_lease
|
|
}
|
|
|
|
pub fn terminal_size(&self) -> (u16, u16) {
|
|
self.terminal_size
|
|
}
|
|
|
|
pub fn ticks(&self) -> u64 {
|
|
self.ticks
|
|
}
|
|
|
|
pub fn is_busy(&self) -> bool {
|
|
!self.pending.is_empty()
|
|
}
|
|
|
|
pub fn should_quit(&self) -> bool {
|
|
self.should_quit
|
|
}
|
|
|
|
pub fn resize(&mut self, width: u16, height: u16) {
|
|
self.terminal_size = (width, height);
|
|
let content_height = height.saturating_sub(3) as usize;
|
|
let sidebar_height = if width < 80 {
|
|
content_height.saturating_mul(40) / 100
|
|
} else {
|
|
content_height
|
|
};
|
|
self.sidebar
|
|
.set_viewport_height(sidebar_height.saturating_sub(2));
|
|
}
|
|
|
|
pub fn tick(&mut self) {
|
|
self.ticks = self.ticks.wrapping_add(1);
|
|
}
|
|
|
|
pub fn observe_time(&mut self, unix_seconds: u64) {
|
|
if let Some(display) = self.otp_display.as_mut() {
|
|
display.remaining_seconds = display.validity.remaining_at(unix_seconds);
|
|
}
|
|
}
|
|
|
|
pub fn begin_totp_refresh(&mut self) -> Option<(String, EntryFieldId)> {
|
|
if self.otp_pending || self.mode != Mode::Viewer {
|
|
return None;
|
|
}
|
|
let viewer = self.viewer.as_ref()?;
|
|
let entry = self.selected_entry.clone()?;
|
|
let field = if let Some(display) = self
|
|
.otp_display
|
|
.as_ref()
|
|
.filter(|display| display.entry == entry)
|
|
{
|
|
match display.remaining_seconds {
|
|
Some(0) => {
|
|
let field_id = display.field?;
|
|
viewer.document().fields().iter().find(|field| {
|
|
field.id() == field_id
|
|
&& field
|
|
.metadata()
|
|
.otp()
|
|
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
|
|
})?
|
|
}
|
|
Some(_) | None => return None,
|
|
}
|
|
} else {
|
|
viewer.document().fields().iter().find(|field| {
|
|
field
|
|
.metadata()
|
|
.otp()
|
|
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
|
|
})?
|
|
};
|
|
self.otp_pending = true;
|
|
Some((entry, field.id()))
|
|
}
|
|
|
|
pub fn begin_request(&mut self) -> RequestToken {
|
|
let token = RequestToken {
|
|
id: self.next_request_id,
|
|
generation: self.generation,
|
|
};
|
|
self.next_request_id = self.next_request_id.wrapping_add(1);
|
|
self.pending.insert(token.id);
|
|
token
|
|
}
|
|
|
|
pub fn begin_latest_request(&mut self) -> RequestToken {
|
|
self.invalidate_requests();
|
|
self.begin_request()
|
|
}
|
|
|
|
pub fn apply_result(&mut self, result: AsyncResult) -> ResultDisposition {
|
|
if result.token.generation != self.generation || !self.pending.contains(&result.token.id) {
|
|
return ResultDisposition::Stale;
|
|
}
|
|
if !matches!(
|
|
result.payload,
|
|
Ok(AsyncPayload::GitProgress(_) | AsyncPayload::ClipboardStarted { .. })
|
|
) {
|
|
self.pending.remove(&result.token.id);
|
|
}
|
|
match result.payload {
|
|
Ok(AsyncPayload::Startup(startup)) => {
|
|
self.status = format!("Vault: {}", startup.config.vault().display());
|
|
self.sidebar.replace_tree(&startup.tree);
|
|
self.default_key = Some(startup.key);
|
|
self.available_keys = startup.keys;
|
|
self.config = Some(startup.config);
|
|
}
|
|
Ok(AsyncPayload::Refreshed(tree)) => {
|
|
self.sidebar.replace_tree(&tree);
|
|
self.grep_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = "Password store refreshed".to_owned();
|
|
}
|
|
Ok(AsyncPayload::Filtered { query, results }) => {
|
|
if !self.sidebar.apply_filter_results(&query, &results) {
|
|
return ResultDisposition::Stale;
|
|
}
|
|
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
|
|
}
|
|
Ok(AsyncPayload::Found { query, results }) => {
|
|
let count = results.matches().len();
|
|
self.sidebar.apply_find_results(&query, &results);
|
|
self.grep_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = if count == 0 {
|
|
format!("Find: {query} (no matches)")
|
|
} else {
|
|
format!("Find: {query} ({count} matches)")
|
|
};
|
|
}
|
|
Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
|
|
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
|
|
return ResultDisposition::Stale;
|
|
}
|
|
let field_count = document.fields().len();
|
|
if self.command_open_target.take() == Some(CommandOpenTarget::Editor) {
|
|
self.editor = Some(EntryEditor::new(*document));
|
|
self.viewer = None;
|
|
self.mode = Mode::Editor;
|
|
self.status = format!("Editing {entry} ({field_count} fields)");
|
|
} else {
|
|
self.viewer = Some(EntryViewer::new(*document));
|
|
self.status = format!("Opened {entry} ({field_count} fields)");
|
|
}
|
|
self.focus = PaneFocus::Main;
|
|
}
|
|
Ok(AsyncPayload::ClipboardStarted {
|
|
presentation,
|
|
deadline,
|
|
}) => {
|
|
if let Some(active) = self
|
|
.clipboard_presentation
|
|
.as_mut()
|
|
.filter(|active| active.id == presentation)
|
|
{
|
|
active.deadline = Some(deadline);
|
|
self.status = "Secret copied".to_owned();
|
|
}
|
|
}
|
|
Ok(AsyncPayload::ClipboardFinished {
|
|
presentation,
|
|
result,
|
|
}) => {
|
|
if self
|
|
.clipboard_presentation
|
|
.is_none_or(|active| active.id != presentation)
|
|
{
|
|
return ResultDisposition::Applied;
|
|
}
|
|
self.clipboard_presentation = None;
|
|
self.status = match result {
|
|
Ok(ClipboardDisposition::RestoredPrevious) => {
|
|
"Clipboard restored to its previous value".to_owned()
|
|
}
|
|
Ok(ClipboardDisposition::Cleared) => "Clipboard secret cleared".to_owned(),
|
|
Ok(ClipboardDisposition::PreservedNewer) => {
|
|
"Clipboard changed; the newer value was preserved".to_owned()
|
|
}
|
|
Err(error) => format!("Clipboard presentation failed: {error}"),
|
|
};
|
|
}
|
|
Ok(AsyncPayload::GeneratedField { target, password }) => {
|
|
if !self.editor_context_active()
|
|
|| self.editor_generation_pending.take() != Some(target)
|
|
{
|
|
return ResultDisposition::Stale;
|
|
}
|
|
let Some(editor) = self.editor.as_mut() else {
|
|
return ResultDisposition::Stale;
|
|
};
|
|
match editor.apply_generated(target, password) {
|
|
Ok(()) => {
|
|
self.status = "Generated password applied to focused field".to_owned()
|
|
}
|
|
Err(error) => self.status = error.to_string(),
|
|
}
|
|
}
|
|
Ok(AsyncPayload::DocumentSaveFinished {
|
|
entry,
|
|
editor,
|
|
result,
|
|
}) => {
|
|
if !self.editor_context_active() || self.selected_entry.as_deref() != Some(&entry) {
|
|
return ResultDisposition::Stale;
|
|
}
|
|
match result {
|
|
Ok(_) => {
|
|
self.editor = None;
|
|
self.selected_entry = None;
|
|
self.suspended_mode = None;
|
|
self.discard_confirmation = false;
|
|
self.editor_generation_pending = None;
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = format!("Saved {entry}");
|
|
}
|
|
Err(failure) => match failure.kind() {
|
|
EditorSaveFailureKind::Unchanged => match (*editor).into_document() {
|
|
Ok(document) => {
|
|
self.viewer = Some(EntryViewer::new(document));
|
|
self.suspended_mode = None;
|
|
self.discard_confirmation = false;
|
|
self.mode = Mode::Viewer;
|
|
self.focus = PaneFocus::Main;
|
|
self.status = "No changes to save".to_owned();
|
|
}
|
|
Err(failed) => {
|
|
let (editor, error) = *failed;
|
|
self.editor = Some(editor);
|
|
self.status = error.to_string();
|
|
}
|
|
},
|
|
EditorSaveFailureKind::Conflict => {
|
|
self.editor = Some(*editor);
|
|
self.status = format!(
|
|
"Concurrent change detected: {}. Draft retained; Esc offers discard.",
|
|
failure.message()
|
|
);
|
|
}
|
|
EditorSaveFailureKind::Storage => {
|
|
self.editor = Some(*editor);
|
|
self.status =
|
|
format!("Save failed: {}. Draft retained.", failure.message());
|
|
}
|
|
},
|
|
}
|
|
}
|
|
Ok(AsyncPayload::WorkflowFinished(result)) => {
|
|
self.workflow_pending = false;
|
|
match result {
|
|
Ok(success) => {
|
|
if let Some(tree) = success.tree.as_ref() {
|
|
self.sidebar.replace_tree(tree);
|
|
}
|
|
self.workflow = None;
|
|
self.suspended_mode = None;
|
|
if let Some(grep) = success.grep {
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.grep_view = Some(GrepView::new(grep));
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Main;
|
|
} else if let (Some(entry), Some(document)) =
|
|
(success.entry, success.document)
|
|
{
|
|
self.selected_entry = Some(entry);
|
|
self.viewer = Some(EntryViewer::new(*document));
|
|
self.editor = None;
|
|
self.grep_view = None;
|
|
self.mode = Mode::Viewer;
|
|
self.focus = PaneFocus::Main;
|
|
} else {
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.grep_view = None;
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Sidebar;
|
|
}
|
|
if let Some((path, directory)) = success.selection {
|
|
self.sidebar.select_path(&path, directory);
|
|
}
|
|
if let Some(presentation) = success.presentation {
|
|
self.apply_secret_presentation(presentation);
|
|
}
|
|
self.status = success.status;
|
|
}
|
|
Err(error) => {
|
|
self.status = format!("Workflow failed: {error}. Form retained.");
|
|
}
|
|
}
|
|
}
|
|
Ok(AsyncPayload::GitProgress(phase)) => {
|
|
self.status = format!("Git {}… (Esc cancels)", git_phase_name(phase));
|
|
}
|
|
Ok(AsyncPayload::GitFinished {
|
|
snapshot,
|
|
tree,
|
|
message,
|
|
conflicts,
|
|
details,
|
|
}) => {
|
|
self.git_pending = false;
|
|
if let Some(tree) = tree {
|
|
self.sidebar.replace_tree(&tree);
|
|
}
|
|
self.git_view = Some(GitView {
|
|
snapshot: *snapshot,
|
|
message: message.clone(),
|
|
conflicts,
|
|
details,
|
|
});
|
|
self.grep_view = None;
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Main;
|
|
self.status = message;
|
|
}
|
|
Ok(AsyncPayload::GitHostConfirmation { request, host_key }) => {
|
|
self.git_pending = false;
|
|
self.workflow = Some(WorkflowForm::ssh_host(request, host_key));
|
|
self.workflow_pending = false;
|
|
self.status =
|
|
"Verify the SSH host fingerprint through a trusted channel before confirming."
|
|
.to_owned();
|
|
self.transition(Transition::OpenDialog);
|
|
}
|
|
Ok(AsyncPayload::GitPassphraseRequired {
|
|
request,
|
|
fingerprint,
|
|
}) => {
|
|
self.git_pending = false;
|
|
self.workflow = Some(WorkflowForm::ssh_passphrase(request, fingerprint));
|
|
self.workflow_pending = false;
|
|
self.status =
|
|
"Enter the encrypted SSH key passphrase; input stays masked.".to_owned();
|
|
self.transition(Transition::OpenDialog);
|
|
}
|
|
Ok(AsyncPayload::OtpCodeFinished {
|
|
entry,
|
|
field,
|
|
code,
|
|
validity,
|
|
observed_at,
|
|
clipboard,
|
|
tree,
|
|
}) => {
|
|
self.otp_pending = false;
|
|
if let Some(tree) = tree {
|
|
self.sidebar.replace_tree(&tree);
|
|
}
|
|
if clipboard {
|
|
self.clipboard_request = Some(SecretBytes::new(code.expose().to_vec()));
|
|
}
|
|
self.status = if let Some(counter) = validity.counter() {
|
|
format!("Generated and committed HOTP counter {counter}")
|
|
} else {
|
|
"TOTP code refreshed".to_owned()
|
|
};
|
|
self.otp_display = Some(OtpDisplay {
|
|
entry,
|
|
field,
|
|
code,
|
|
validity,
|
|
remaining_seconds: validity.remaining_at(observed_at),
|
|
});
|
|
self.hotp_confirmation = None;
|
|
}
|
|
Ok(AsyncPayload::OtpUriFinished {
|
|
entry,
|
|
presentation,
|
|
payload,
|
|
qr,
|
|
}) => {
|
|
self.otp_pending = false;
|
|
self.status = format!("Presented OTP URI for {entry}");
|
|
match presentation {
|
|
OtpPresentationTarget::Terminal => {
|
|
self.qr_popup = None;
|
|
self.otp_display = None;
|
|
self.uri_popup = Some(payload);
|
|
}
|
|
OtpPresentationTarget::Clipboard => {
|
|
self.clipboard_request = Some(payload);
|
|
}
|
|
OtpPresentationTarget::Qr => {
|
|
self.uri_popup = None;
|
|
self.qr_popup = qr.map(|matrix| QrPopup {
|
|
title: "OTP QR".to_owned(),
|
|
matrix,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
Ok(AsyncPayload::OtpValidated) => {
|
|
self.otp_pending = false;
|
|
self.status = "OTP URI is valid".to_owned();
|
|
}
|
|
Ok(AsyncPayload::SecretPresented {
|
|
status,
|
|
presentation,
|
|
}) => {
|
|
self.apply_secret_presentation(presentation);
|
|
self.status = status;
|
|
}
|
|
Err(error) => {
|
|
self.git_pending = false;
|
|
self.otp_pending = false;
|
|
self.status = error;
|
|
self.editor_generation_pending = None;
|
|
self.command_open_target = None;
|
|
if self.mode == Mode::Viewer && self.viewer.is_none() {
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.selected_entry = None;
|
|
}
|
|
}
|
|
}
|
|
ResultDisposition::Applied
|
|
}
|
|
|
|
pub fn dispatch(&mut self, action: Action) -> AppEffect {
|
|
if self.editor_generation_pending.is_some()
|
|
&& matches!(
|
|
action,
|
|
Action::FocusNext
|
|
| Action::FocusPrevious
|
|
| Action::BeginInput
|
|
| Action::SaveEditor
|
|
| Action::AddField
|
|
| Action::RemoveField
|
|
| Action::MoveFieldUp
|
|
| Action::MoveFieldDown
|
|
| Action::Generate
|
|
)
|
|
{
|
|
self.status = "Wait for password generation to finish".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
match action {
|
|
Action::Quit if matches!(self.mode, Mode::Browser | Mode::Viewer | Mode::Locked) => {
|
|
self.should_quit = true;
|
|
}
|
|
Action::Help => {
|
|
self.command_help = None;
|
|
self.transition(Transition::OpenHelp);
|
|
}
|
|
Action::Command => {
|
|
self.command_line.clear();
|
|
self.transition(Transition::OpenCommand);
|
|
}
|
|
Action::Cancel => {
|
|
if self.qr_popup.is_some() || self.uri_popup.is_some() {
|
|
self.qr_popup = None;
|
|
self.uri_popup = None;
|
|
self.status = "OTP presentation closed".to_owned();
|
|
} else if self.hotp_confirmation.is_some() {
|
|
self.hotp_confirmation = None;
|
|
self.status = "HOTP generation cancelled; counter unchanged".to_owned();
|
|
self.transition(Transition::Dismiss);
|
|
} else if self.git_pending {
|
|
self.status = "Cancelling Git operation…".to_owned();
|
|
return AppEffect::CancelGit;
|
|
} else if self.git_view.is_some() && self.mode == Mode::Browser {
|
|
self.git_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = "Git status closed".to_owned();
|
|
} else if self.grep_view.is_some() && self.mode == Mode::Browser {
|
|
self.grep_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = "Decrypted search results closed".to_owned();
|
|
} else if self.mode == Mode::Editor {
|
|
self.cancel_editor();
|
|
} else if self.mode == Mode::Dialog && self.discard_confirmation {
|
|
self.keep_editing();
|
|
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
|
|
self.cancel_workflow();
|
|
} else {
|
|
if self.mode == Mode::Command {
|
|
self.command_line.clear();
|
|
self.status = "Command cancelled".to_owned();
|
|
}
|
|
self.transition(Transition::Dismiss);
|
|
}
|
|
}
|
|
Action::Lock => {
|
|
self.transition(Transition::Lock);
|
|
return AppEffect::ManualLock;
|
|
}
|
|
Action::Unlock => {
|
|
self.transition(Transition::Unlock);
|
|
}
|
|
Action::Refresh => return AppEffect::RefreshTree,
|
|
Action::Next if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
|
|
self.grep_view.as_mut().expect("checked grep view").next();
|
|
}
|
|
Action::Previous if self.grep_view.is_some() && self.focus == PaneFocus::Main => {
|
|
self.grep_view
|
|
.as_mut()
|
|
.expect("checked grep view")
|
|
.previous();
|
|
}
|
|
Action::Next if self.mode == Mode::Viewer => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.focus_next();
|
|
}
|
|
}
|
|
Action::Previous if self.mode == Mode::Viewer => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.focus_previous();
|
|
}
|
|
}
|
|
Action::Next => self.sidebar.move_next(),
|
|
Action::Previous => self.sidebar.move_previous(),
|
|
Action::PageDown => self.sidebar.page_down(),
|
|
Action::PageUp => self.sidebar.page_up(),
|
|
Action::First => self.sidebar.move_first(),
|
|
Action::Last => self.sidebar.move_last(),
|
|
Action::Parent => self.sidebar.collapse_or_parent(),
|
|
Action::Child => self.sidebar.move_child(),
|
|
Action::Activate => {
|
|
if self.grep_view.is_some() && self.focus == PaneFocus::Main {
|
|
let entry = self
|
|
.grep_view
|
|
.as_ref()
|
|
.and_then(GrepView::selected_entry)
|
|
.map(|entry| entry.path().to_string());
|
|
if let Some(entry) = entry {
|
|
self.sidebar.select_path(&entry, false);
|
|
self.grep_view = None;
|
|
self.authentication_pending = Some(entry.clone());
|
|
self.status = format!("Authenticating to open search result {entry}…");
|
|
return AppEffect::AuthenticateEntry(entry);
|
|
}
|
|
self.status = "The decrypted search has no results".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
if self.authentication_pending.is_none()
|
|
&& let SidebarIntent::OpenEntry(path) = self.sidebar.activate()
|
|
{
|
|
self.authentication_pending = Some(path.clone());
|
|
self.status = if self
|
|
.default_key
|
|
.as_ref()
|
|
.is_some_and(KeyInfo::requires_passphrase)
|
|
{
|
|
"Authenticating through secure storage for the OpenPGP passphrase…"
|
|
.to_owned()
|
|
} else {
|
|
"Authenticating through secure storage…".to_owned()
|
|
};
|
|
return AppEffect::AuthenticateEntry(path);
|
|
}
|
|
}
|
|
Action::Filter => {
|
|
self.grep_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.sidebar.begin_filter();
|
|
}
|
|
Action::NextMatch => self.sidebar.next_match(),
|
|
Action::PreviousMatch => self.sidebar.previous_match(),
|
|
Action::FocusNext if self.mode == Mode::Viewer => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.focus_next();
|
|
}
|
|
}
|
|
Action::FocusPrevious if self.mode == Mode::Viewer => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.focus_previous();
|
|
}
|
|
}
|
|
Action::FocusNext if self.mode == Mode::Editor => {
|
|
let result = self.editor.as_mut().map(EntryEditor::focus_next);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::FocusPrevious if self.mode == Mode::Editor => {
|
|
let result = self.editor.as_mut().map(EntryEditor::focus_previous);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::FocusNext | Action::FocusPrevious => {
|
|
self.focus = match self.focus {
|
|
PaneFocus::Sidebar => PaneFocus::Main,
|
|
PaneFocus::Main => PaneFocus::Sidebar,
|
|
};
|
|
}
|
|
Action::Copy => {
|
|
if let Some(viewer) = self.viewer.as_ref() {
|
|
match viewer.copy_focused() {
|
|
Ok(value) => {
|
|
return self.begin_clipboard_presentation(value);
|
|
}
|
|
Err(error) => self.status = error.to_string(),
|
|
}
|
|
}
|
|
}
|
|
Action::ScrollDown => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.scroll_down(5);
|
|
}
|
|
}
|
|
Action::ScrollUp => {
|
|
if let Some(viewer) = self.viewer.as_mut() {
|
|
viewer.scroll_up(5);
|
|
}
|
|
}
|
|
Action::CloseEntry => {
|
|
self.transition(Transition::CloseEntry);
|
|
}
|
|
Action::EditEntry => {
|
|
if let Some(viewer) = self.viewer.take() {
|
|
self.editor = Some(EntryEditor::new(viewer.into_document()));
|
|
self.transition(Transition::EditEntry);
|
|
self.status = "Structured editor: i edits, C-s saves, Esc cancels".to_owned();
|
|
}
|
|
}
|
|
Action::BeginInput => {
|
|
if let Some(editor) = self.editor.as_mut() {
|
|
editor.begin_input();
|
|
self.status = "Editing raw structured field; Esc stops input".to_owned();
|
|
}
|
|
}
|
|
Action::SaveEditor => return self.begin_editor_save(),
|
|
Action::AddField => {
|
|
let result = self.editor.as_mut().map(EntryEditor::add_after_focused);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::RemoveField => {
|
|
let result = self.editor.as_mut().map(EntryEditor::remove_focused);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::MoveFieldUp => {
|
|
let result = self.editor.as_mut().map(EntryEditor::move_focused_up);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::MoveFieldDown => {
|
|
let result = self.editor.as_mut().map(EntryEditor::move_focused_down);
|
|
self.report_editor_result(result);
|
|
}
|
|
Action::Generate => {
|
|
if self.editor_generation_pending.is_none()
|
|
&& let Some(editor) = self.editor.as_mut()
|
|
{
|
|
match editor.generation_target() {
|
|
Ok(Some(target)) => {
|
|
self.editor_generation_pending = Some(target);
|
|
self.status = "Generating password for focused field…".to_owned();
|
|
return AppEffect::GenerateField(target);
|
|
}
|
|
Ok(None) => {
|
|
self.status = "Focused field is not a password field".to_owned();
|
|
}
|
|
Err(error) => self.status = error.to_string(),
|
|
}
|
|
}
|
|
}
|
|
Action::ConfirmDiscard => {
|
|
if let Some(mut request) = self.hotp_confirmation.take() {
|
|
request.confirmed_hotp = true;
|
|
self.transition(Transition::Dismiss);
|
|
self.otp_pending = true;
|
|
return AppEffect::AuthenticateOtp(request);
|
|
} else if self.discard_confirmation {
|
|
self.discard_editor();
|
|
}
|
|
}
|
|
Action::KeepEditing => {
|
|
if self.hotp_confirmation.take().is_some() {
|
|
self.transition(Transition::Dismiss);
|
|
self.status = "HOTP generation cancelled; counter unchanged".to_owned();
|
|
} else if self.discard_confirmation {
|
|
self.keep_editing();
|
|
}
|
|
}
|
|
Action::Initialize
|
|
| Action::InsertEntry
|
|
| Action::GenerateEntry
|
|
| Action::Grep
|
|
| Action::ImportKdbx
|
|
| Action::RemoveEntry
|
|
| Action::MoveEntry
|
|
| Action::CopyEntry
|
|
| Action::GitPull
|
|
| Action::GitPush => {
|
|
let workflow = action.workflow().expect("matched workflow action");
|
|
if matches!(
|
|
workflow,
|
|
WorkflowAction::Initialize
|
|
| WorkflowAction::InsertEntry
|
|
| WorkflowAction::GenerateEntry
|
|
| WorkflowAction::Grep
|
|
| WorkflowAction::ImportKdbx
|
|
| WorkflowAction::RemoveEntry
|
|
| WorkflowAction::MoveEntry
|
|
| WorkflowAction::CopyEntry
|
|
) {
|
|
self.open_workflow(workflow, None);
|
|
return AppEffect::None;
|
|
}
|
|
self.status = format!("Selected {} workflow", workflow_label(workflow));
|
|
self.git_pending = true;
|
|
return AppEffect::AuthenticateGit(match workflow {
|
|
WorkflowAction::GitPull => ironstorage::command::GitRequest::Pull {
|
|
remote: None,
|
|
branch: None,
|
|
},
|
|
WorkflowAction::GitPush => ironstorage::command::GitRequest::Push {
|
|
remote: None,
|
|
branch: None,
|
|
},
|
|
_ => unreachable!("only Git workflows reach this branch"),
|
|
});
|
|
}
|
|
Action::Quit => {}
|
|
Action::OtpCode
|
|
| Action::OtpCopyCode
|
|
| Action::OtpUri
|
|
| Action::OtpCopyUri
|
|
| Action::OtpQr => {
|
|
let Some(entry) = self.selected_entry.clone() else {
|
|
self.status = "Select an OTP entry first".to_owned();
|
|
return AppEffect::None;
|
|
};
|
|
let Some((field, kind)) = self
|
|
.viewer
|
|
.as_ref()
|
|
.and_then(EntryViewer::focused_field)
|
|
.and_then(|field| field.metadata().otp().map(|otp| (field.id(), otp.kind())))
|
|
else {
|
|
self.status = "Focus an OTP field first".to_owned();
|
|
return AppEffect::None;
|
|
};
|
|
let request = match action {
|
|
Action::OtpCode | Action::OtpCopyCode => OtpRequest::Code(OtpCodeRequest {
|
|
entry,
|
|
clipboard: action == Action::OtpCopyCode,
|
|
}),
|
|
Action::OtpUri | Action::OtpCopyUri | Action::OtpQr => {
|
|
OtpRequest::Uri(OtpUriRequest {
|
|
entry,
|
|
presentation: match action {
|
|
Action::OtpUri => OtpUriPresentation::Terminal,
|
|
Action::OtpCopyUri => OtpUriPresentation::Clipboard,
|
|
Action::OtpQr => OtpUriPresentation::QrCode,
|
|
_ => unreachable!(),
|
|
},
|
|
})
|
|
}
|
|
_ => unreachable!(),
|
|
};
|
|
let ui_request = OtpUiRequest {
|
|
request,
|
|
confirmed_hotp: false,
|
|
field: Some(field),
|
|
};
|
|
if matches!(ui_request.request, OtpRequest::Code(_))
|
|
&& kind == ironstorage::otp::OtpKind::Hotp
|
|
{
|
|
self.hotp_confirmation = Some(ui_request);
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "Generate HOTP and commit the advanced counter? y/n".to_owned();
|
|
return AppEffect::None;
|
|
} else {
|
|
self.otp_pending = true;
|
|
return AppEffect::AuthenticateOtp(ui_request);
|
|
}
|
|
}
|
|
Action::OtpInsert | Action::OtpAppend | Action::OtpValidate => {
|
|
let (kind, entry) = match action {
|
|
Action::OtpInsert => (OtpFormKind::Insert, None),
|
|
Action::OtpAppend => (OtpFormKind::Append, self.selected_entry.clone()),
|
|
Action::OtpValidate => (OtpFormKind::Validate, None),
|
|
_ => unreachable!(),
|
|
};
|
|
self.workflow = Some(WorkflowForm::otp(kind, entry, false));
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "OTP form: Tab navigates, C-s submits, Esc cancels".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
}
|
|
AppEffect::None
|
|
}
|
|
|
|
pub fn editor_input_active(&self) -> bool {
|
|
self.mode == Mode::Editor
|
|
&& self.editor_generation_pending.is_none()
|
|
&& self
|
|
.editor
|
|
.as_ref()
|
|
.is_some_and(EntryEditor::is_input_active)
|
|
}
|
|
|
|
pub fn handle_workflow_input(
|
|
&mut self,
|
|
code: crossterm::event::KeyCode,
|
|
modifiers: crossterm::event::KeyModifiers,
|
|
) -> Option<AppEffect> {
|
|
if self.mode != Mode::Dialog || self.workflow.is_none() {
|
|
return None;
|
|
}
|
|
if self.workflow_pending {
|
|
if code == crossterm::event::KeyCode::Esc {
|
|
self.status = "Wait for the active workflow to finish".to_owned();
|
|
}
|
|
return Some(AppEffect::None);
|
|
}
|
|
let input = self.workflow.as_mut()?.handle_key(code, modifiers);
|
|
match input {
|
|
WorkflowInput::Consumed => Some(AppEffect::None),
|
|
WorkflowInput::Cancel => {
|
|
self.cancel_workflow();
|
|
Some(AppEffect::None)
|
|
}
|
|
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
|
|
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
|
|
Ok(WorkflowSubmission::SshHost { request, host_key }) => {
|
|
self.begin_git_retry();
|
|
Some(AppEffect::RetryGitAfterHostConfirmation { request, host_key })
|
|
}
|
|
Ok(WorkflowSubmission::SshPassphrase {
|
|
request,
|
|
fingerprint,
|
|
passphrase,
|
|
}) => {
|
|
self.begin_git_retry();
|
|
Some(AppEffect::RetryGitWithPassphrase {
|
|
request,
|
|
fingerprint,
|
|
passphrase,
|
|
})
|
|
}
|
|
Ok(submission) => {
|
|
self.workflow_pending = true;
|
|
self.status = "Authenticating before applying the workflow…".to_owned();
|
|
Some(AppEffect::AuthenticateWorkflow(Box::new(submission)))
|
|
}
|
|
Err(error) => {
|
|
self.status = format!("Validation: {error}");
|
|
Some(AppEffect::None)
|
|
}
|
|
},
|
|
}
|
|
}
|
|
|
|
pub fn handle_command_input(
|
|
&mut self,
|
|
code: crossterm::event::KeyCode,
|
|
modifiers: crossterm::event::KeyModifiers,
|
|
) -> Option<AppEffect> {
|
|
use crossterm::event::{KeyCode, KeyModifiers};
|
|
|
|
if self.mode != Mode::Command {
|
|
return None;
|
|
}
|
|
let control = modifiers.contains(KeyModifiers::CONTROL);
|
|
match (code, control) {
|
|
(KeyCode::Esc, _) => return Some(self.dispatch(Action::Cancel)),
|
|
(KeyCode::Enter, _) => return Some(self.submit_command()),
|
|
(KeyCode::Char('l' | 'z'), true) => return Some(self.dispatch(Action::Lock)),
|
|
(KeyCode::Tab, _) => {
|
|
let paths = self.sidebar.completion_paths();
|
|
if !self.command_line.complete(&paths, false) {
|
|
self.status = "No command completion is available".to_owned();
|
|
}
|
|
}
|
|
(KeyCode::BackTab, _) => {
|
|
let paths = self.sidebar.completion_paths();
|
|
if !self.command_line.complete(&paths, true) {
|
|
self.status = "No command completion is available".to_owned();
|
|
}
|
|
}
|
|
(KeyCode::Up, _) => self.command_line.previous_history(),
|
|
(KeyCode::Down, _) => self.command_line.next_history(),
|
|
(KeyCode::Left, _) => self.command_line.move_left(),
|
|
(KeyCode::Right, _) => self.command_line.move_right(),
|
|
(KeyCode::Home, _) | (KeyCode::Char('a'), true) => self.command_line.move_home(),
|
|
(KeyCode::End, _) | (KeyCode::Char('e'), true) => self.command_line.move_end(),
|
|
(KeyCode::Backspace, _) => self.command_line.backspace(),
|
|
(KeyCode::Delete, _) => self.command_line.delete(),
|
|
(KeyCode::Char(character), false) => self.command_line.insert(character),
|
|
_ => return Some(AppEffect::None),
|
|
}
|
|
Some(AppEffect::None)
|
|
}
|
|
|
|
/// Insert bracketed paste as inert text into the active input surface.
|
|
/// Control characters are rejected so paste can never submit a command or
|
|
/// silently create additional password-store lines.
|
|
pub fn handle_paste(&mut self, text: &str) -> bool {
|
|
if text.chars().any(char::is_control) {
|
|
self.status = "Paste rejected: control characters are not accepted".to_owned();
|
|
return matches!(
|
|
self.mode,
|
|
Mode::Command | Mode::Editor | Mode::Dialog | Mode::Browser
|
|
);
|
|
}
|
|
if self.mode == Mode::Command {
|
|
for character in text.chars() {
|
|
self.command_line.insert(character);
|
|
}
|
|
return true;
|
|
}
|
|
if self.mode == Mode::Dialog
|
|
&& let Some(workflow) = self.workflow.as_mut()
|
|
{
|
|
for character in text.chars() {
|
|
workflow.handle_key(
|
|
crossterm::event::KeyCode::Char(character),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
}
|
|
return true;
|
|
}
|
|
if self.mode == Mode::Editor {
|
|
for character in text.chars() {
|
|
self.handle_editor_input(crossterm::event::KeyCode::Char(character));
|
|
}
|
|
return true;
|
|
}
|
|
if self.sidebar.is_editing_filter() {
|
|
for character in text.chars() {
|
|
self.sidebar.push_filter_character(character);
|
|
}
|
|
return true;
|
|
}
|
|
false
|
|
}
|
|
|
|
fn submit_command(&mut self) -> AppEffect {
|
|
let invocation = match self.command_line.submit() {
|
|
Ok(invocation) => invocation,
|
|
Err(error) => {
|
|
self.status = format!("Command error: {error}");
|
|
return AppEffect::None;
|
|
}
|
|
};
|
|
self.route_command(invocation)
|
|
}
|
|
|
|
fn route_command(&mut self, invocation: CommandInvocation) -> AppEffect {
|
|
let context = self.command_context_mode();
|
|
match invocation {
|
|
CommandInvocation::Display(text) => {
|
|
self.show_command_help(text);
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Lock if context != Mode::Locked => {
|
|
self.transition(Transition::Dismiss);
|
|
self.transition(Transition::Lock);
|
|
AppEffect::ManualLock
|
|
}
|
|
CommandInvocation::Unlock if context == Mode::Locked => {
|
|
self.transition(Transition::Dismiss);
|
|
self.transition(Transition::Unlock);
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Quit if context != Mode::Editor => {
|
|
self.transition(Transition::Dismiss);
|
|
self.should_quit = true;
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::ResolveGit(choice) => {
|
|
let Some(view) = self.git_view.as_ref() else {
|
|
self.status = "There is no active Git conflict set".to_owned();
|
|
return AppEffect::None;
|
|
};
|
|
if view.conflicts.is_empty() {
|
|
self.status = "The active Git snapshot has no conflicts".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
let resolutions = view
|
|
.conflicts
|
|
.iter()
|
|
.map(|conflict| {
|
|
ironstorage::git::GitConflictResolution::new(
|
|
conflict.path().to_owned(),
|
|
choice,
|
|
)
|
|
})
|
|
.collect();
|
|
self.git_pending = true;
|
|
self.status = format!("Resolving all conflicts with {choice:?} versions…");
|
|
AppEffect::ResolveGit(resolutions)
|
|
}
|
|
CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => {
|
|
self.status = "Command is unavailable in the current mode".to_owned();
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Help { topic }) => {
|
|
self.show_command_help(help_text(topic));
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Version) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.status = version_text().trim().to_owned();
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Help)) => {
|
|
self.show_command_help(help_text(Some(ironstorage::command::HelpTopic::Otp)));
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Version)) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.status = format!("pass-otp {}", otp_version_text().trim());
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(request) if context == Mode::Locked => {
|
|
self.status = format!(
|
|
"{} is unavailable while the password store is locked",
|
|
operation_name(&request)
|
|
);
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Code(_))) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.hotp_confirmation = Some(OtpUiRequest {
|
|
request,
|
|
confirmed_hotp: false,
|
|
field: None,
|
|
});
|
|
self.transition(Transition::OpenDialog);
|
|
self.status =
|
|
"Confirm OTP generation; HOTP advances and commits its counter".to_owned();
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(request @ OtpRequest::Uri(_))) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.otp_pending = true;
|
|
AppEffect::AuthenticateOtp(OtpUiRequest {
|
|
request,
|
|
confirmed_hotp: false,
|
|
field: None,
|
|
})
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Insert(request))) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.workflow = Some(WorkflowForm::otp(
|
|
OtpFormKind::Insert,
|
|
request.entry,
|
|
request.force,
|
|
));
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "OTP insert form: enter a URI, confirm, then C-s".to_owned();
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Append(request))) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.workflow = Some(WorkflowForm::otp(
|
|
OtpFormKind::Append,
|
|
Some(request.entry),
|
|
request.force,
|
|
));
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "OTP append form: enter a URI, confirm, then C-s".to_owned();
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Show(request))
|
|
if request.presentation == Presentation::Terminal =>
|
|
{
|
|
if context == Mode::Editor {
|
|
self.status = "show is unavailable while an editor is open".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
self.transition(Transition::Dismiss);
|
|
if let Some(entry) = request.entry {
|
|
self.begin_command_open(entry, CommandOpenTarget::Viewer)
|
|
} else {
|
|
self.close_to_browser();
|
|
self.status = "Showing the password-store root".to_owned();
|
|
AppEffect::None
|
|
}
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Show(request)) => {
|
|
self.transition(Transition::Dismiss);
|
|
self.status = "Authenticating for secret presentation…".to_owned();
|
|
AppEffect::AuthenticateShow(request)
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::Edit(request)) => {
|
|
if context == Mode::Editor {
|
|
if self.selected_entry.as_deref() == Some(&request.entry) {
|
|
self.transition(Transition::Dismiss);
|
|
self.status = format!("Already editing {}", request.entry);
|
|
} else {
|
|
self.status = "edit is unavailable while another editor is open".to_owned();
|
|
}
|
|
return AppEffect::None;
|
|
}
|
|
self.transition(Transition::Dismiss);
|
|
self.begin_command_open(request.entry, CommandOpenTarget::Editor)
|
|
}
|
|
CommandInvocation::Storage(CommandRequest::List(request)) => {
|
|
if context == Mode::Editor {
|
|
self.status = "list is unavailable while an editor is open".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
self.transition(Transition::Dismiss);
|
|
self.close_to_browser();
|
|
if let Some(path) = request.path {
|
|
if self.sidebar.select_path(&path, true) {
|
|
self.status = format!("Selected directory {path}");
|
|
} else {
|
|
self.status =
|
|
format!("Directory is unavailable in the loaded tree: {path}");
|
|
}
|
|
} else {
|
|
self.status = "Showing the password-store root".to_owned();
|
|
}
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(request) if context == Mode::Editor => {
|
|
self.status = format!(
|
|
"{} is unavailable while an editor is open",
|
|
operation_name(&request)
|
|
);
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(request @ CommandRequest::Init(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Generate(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Grep(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Remove(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Move(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::Copy(_))
|
|
| CommandInvocation::Storage(request @ CommandRequest::ImportKdbx(_)) => {
|
|
self.transition(Transition::Dismiss);
|
|
let workflow = match &request {
|
|
CommandRequest::Init(_) => WorkflowAction::Initialize,
|
|
CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
|
|
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
|
|
CommandRequest::Grep(_) => WorkflowAction::Grep,
|
|
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
|
|
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
|
|
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
|
|
CommandRequest::ImportKdbx(_) => WorkflowAction::ImportKdbx,
|
|
_ => unreachable!(),
|
|
};
|
|
self.open_workflow(workflow, Some(request));
|
|
AppEffect::None
|
|
}
|
|
CommandInvocation::Storage(request) => {
|
|
let operation = operation_name(&request);
|
|
self.transition(Transition::Dismiss);
|
|
self.status = format!("Starting {operation} workflow…");
|
|
AppEffect::RunCommand(request)
|
|
}
|
|
}
|
|
}
|
|
|
|
fn show_command_help(&mut self, text: String) {
|
|
self.transition(Transition::Dismiss);
|
|
self.command_help = Some(text);
|
|
self.transition(Transition::OpenHelp);
|
|
self.status = "Command help; Esc returns".to_owned();
|
|
}
|
|
|
|
fn begin_command_open(&mut self, entry: String, target: CommandOpenTarget) -> AppEffect {
|
|
if self.selected_entry.as_deref() == Some(&entry) && self.mode == Mode::Viewer {
|
|
if target == CommandOpenTarget::Editor {
|
|
return self.dispatch(Action::EditEntry);
|
|
}
|
|
self.status = format!("Already viewing {entry}");
|
|
return AppEffect::None;
|
|
}
|
|
self.authentication_pending = Some(entry.clone());
|
|
self.command_open_target = Some(target);
|
|
self.status = format!("Authenticating to open {entry}…");
|
|
AppEffect::AuthenticateEntry(entry)
|
|
}
|
|
|
|
fn close_to_browser(&mut self) {
|
|
self.grep_view = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
if matches!(self.mode, Mode::Viewer | Mode::Editor) {
|
|
self.transition(Transition::CloseEntry);
|
|
}
|
|
}
|
|
|
|
fn apply_secret_presentation(&mut self, presentation: SecretPresentation) {
|
|
match presentation {
|
|
SecretPresentation::Clipboard(value) => self.clipboard_request = Some(value),
|
|
SecretPresentation::Qr { title, matrix } => {
|
|
self.uri_popup = None;
|
|
self.qr_popup = Some(QrPopup { title, matrix });
|
|
}
|
|
}
|
|
}
|
|
|
|
fn begin_clipboard_presentation(&mut self, value: SecretBytes) -> AppEffect {
|
|
let presentation = ClipboardPresentationId(self.next_clipboard_presentation);
|
|
self.next_clipboard_presentation = self.next_clipboard_presentation.wrapping_add(1);
|
|
self.clipboard_presentation = Some(ClipboardPresentation {
|
|
id: presentation,
|
|
deadline: None,
|
|
});
|
|
self.status = "Copying secret to the native clipboard…".to_owned();
|
|
AppEffect::CopyFocused {
|
|
presentation,
|
|
value,
|
|
}
|
|
}
|
|
|
|
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
|
|
let form = match (workflow, request) {
|
|
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
|
WorkflowForm::init(
|
|
&self.available_keys,
|
|
self.default_key.as_ref(),
|
|
Some(request),
|
|
)
|
|
}
|
|
(WorkflowAction::Initialize, None) => {
|
|
WorkflowForm::init(&self.available_keys, self.default_key.as_ref(), None)
|
|
}
|
|
(WorkflowAction::InsertEntry, Some(CommandRequest::Insert(request))) => {
|
|
WorkflowForm::insert(Some(request))
|
|
}
|
|
(WorkflowAction::InsertEntry, None) => WorkflowForm::insert(None),
|
|
(WorkflowAction::GenerateEntry, Some(CommandRequest::Generate(request))) => {
|
|
WorkflowForm::generate(Some(request))
|
|
}
|
|
(WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None),
|
|
(WorkflowAction::Grep, Some(CommandRequest::Grep(request))) => {
|
|
WorkflowForm::grep(Some(request))
|
|
}
|
|
(WorkflowAction::Grep, None) => WorkflowForm::grep(None),
|
|
(WorkflowAction::ImportKdbx, Some(CommandRequest::ImportKdbx(request))) => {
|
|
WorkflowForm::kdbx(Some(request))
|
|
}
|
|
(WorkflowAction::ImportKdbx, None) => WorkflowForm::kdbx(None),
|
|
(WorkflowAction::RemoveEntry, Some(CommandRequest::Remove(request))) => {
|
|
WorkflowForm::remove(Some(request), None)
|
|
}
|
|
(WorkflowAction::RemoveEntry, None) => {
|
|
let selected = self
|
|
.selected_entry
|
|
.as_deref()
|
|
.map(|entry| (entry, false))
|
|
.or_else(|| {
|
|
self.sidebar
|
|
.selected()
|
|
.map(|selected| (selected.path(), selected.is_directory()))
|
|
});
|
|
WorkflowForm::remove(None, selected)
|
|
}
|
|
(WorkflowAction::MoveEntry, Some(CommandRequest::Move(request))) => {
|
|
WorkflowForm::move_entry(Some(request), None)
|
|
}
|
|
(WorkflowAction::MoveEntry, None) => WorkflowForm::move_entry(
|
|
None,
|
|
self.selected_entry
|
|
.as_deref()
|
|
.or_else(|| self.sidebar.selected().map(|selected| selected.path())),
|
|
),
|
|
(WorkflowAction::CopyEntry, Some(CommandRequest::Copy(request))) => {
|
|
WorkflowForm::copy_entry(Some(request), None)
|
|
}
|
|
(WorkflowAction::CopyEntry, None) => WorkflowForm::copy_entry(
|
|
None,
|
|
self.selected_entry
|
|
.as_deref()
|
|
.or_else(|| self.sidebar.selected().map(|selected| selected.path())),
|
|
),
|
|
_ => return,
|
|
};
|
|
self.workflow = Some(form);
|
|
self.workflow_pending = false;
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "Tab moves, Space toggles, Ctrl-S submits, Esc cancels".to_owned();
|
|
}
|
|
|
|
fn cancel_workflow(&mut self) {
|
|
if self.workflow_pending {
|
|
self.status = "Wait for the active workflow to finish".to_owned();
|
|
return;
|
|
}
|
|
self.workflow = None;
|
|
self.transition(Transition::Dismiss);
|
|
self.status = "Workflow cancelled; password store unchanged".to_owned();
|
|
}
|
|
|
|
pub fn workflow_authentication_granted(&mut self) -> bool {
|
|
self.mode == Mode::Dialog && self.workflow.is_some() && self.workflow_pending
|
|
}
|
|
|
|
pub fn workflow_authentication_failed(&mut self, message: String) {
|
|
self.workflow_pending = false;
|
|
self.status = format!("Authentication failed: {message}. Form retained.");
|
|
}
|
|
|
|
fn editor_context_active(&self) -> bool {
|
|
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
|
|
}
|
|
|
|
pub fn handle_editor_input(&mut self, code: crossterm::event::KeyCode) -> bool {
|
|
use crossterm::event::KeyCode;
|
|
|
|
if !self.editor_input_active() {
|
|
return false;
|
|
}
|
|
let editor = self.editor.as_mut().expect("active editor input has state");
|
|
let result = match code {
|
|
KeyCode::Esc => {
|
|
editor.end_input();
|
|
self.status = "Field input ended".to_owned();
|
|
return true;
|
|
}
|
|
KeyCode::Enter => editor.split_line(),
|
|
KeyCode::Backspace => {
|
|
editor.backspace();
|
|
return true;
|
|
}
|
|
KeyCode::Delete => {
|
|
editor.delete();
|
|
return true;
|
|
}
|
|
KeyCode::Left => {
|
|
editor.move_cursor_left();
|
|
return true;
|
|
}
|
|
KeyCode::Right => {
|
|
editor.move_cursor_right();
|
|
return true;
|
|
}
|
|
KeyCode::Home => {
|
|
editor.move_cursor_home();
|
|
return true;
|
|
}
|
|
KeyCode::End => {
|
|
editor.move_cursor_end();
|
|
return true;
|
|
}
|
|
KeyCode::Char(character) => {
|
|
editor.insert_character(character);
|
|
return true;
|
|
}
|
|
_ => return false,
|
|
};
|
|
if let Err(error) = result {
|
|
self.status = error.to_string();
|
|
}
|
|
true
|
|
}
|
|
|
|
fn report_editor_result(&mut self, result: Option<Result<(), DocumentError>>) {
|
|
if let Some(Err(error)) = result {
|
|
self.status = error.to_string();
|
|
}
|
|
}
|
|
|
|
fn begin_editor_save(&mut self) -> AppEffect {
|
|
if self.editor_generation_pending.is_some() {
|
|
self.status = "Wait for password generation before saving".to_owned();
|
|
return AppEffect::None;
|
|
}
|
|
let Some(config) = self.config.clone() else {
|
|
self.status = "Cannot save before configuration is loaded".to_owned();
|
|
return AppEffect::None;
|
|
};
|
|
let Some(editor) = self.editor.take() else {
|
|
return AppEffect::None;
|
|
};
|
|
let editor = match editor.prepare_save() {
|
|
Ok(editor) => editor,
|
|
Err(failure) => {
|
|
let (editor, error) = *failure;
|
|
self.editor = Some(editor);
|
|
self.status = error.to_string();
|
|
return AppEffect::None;
|
|
}
|
|
};
|
|
if !editor.is_dirty() {
|
|
match editor.into_document() {
|
|
Ok(document) => {
|
|
self.viewer = Some(EntryViewer::new(document));
|
|
self.mode = Mode::Viewer;
|
|
self.status = "No changes to save".to_owned();
|
|
}
|
|
Err(failure) => {
|
|
let (editor, error) = *failure;
|
|
self.editor = Some(editor);
|
|
self.status = error.to_string();
|
|
}
|
|
}
|
|
return AppEffect::None;
|
|
}
|
|
let Some(entry) = self.selected_entry.clone() else {
|
|
self.editor = Some(editor);
|
|
self.status = "Cannot save an editor without an entry identity".to_owned();
|
|
return AppEffect::None;
|
|
};
|
|
self.status = format!("Saving {entry}…");
|
|
AppEffect::SaveDocument {
|
|
config: Box::new(config),
|
|
entry,
|
|
editor: Box::new(editor),
|
|
}
|
|
}
|
|
|
|
fn cancel_editor(&mut self) {
|
|
let Some(editor) = self.editor.take() else {
|
|
return;
|
|
};
|
|
let editor = match editor.prepare_save() {
|
|
Ok(editor) => editor,
|
|
Err(failure) => {
|
|
let (editor, error) = *failure;
|
|
self.editor = Some(editor);
|
|
self.status = error.to_string();
|
|
return;
|
|
}
|
|
};
|
|
if editor.is_dirty() {
|
|
self.editor = Some(editor);
|
|
self.discard_confirmation = true;
|
|
self.transition(Transition::OpenDialog);
|
|
self.status = "Discard unsaved edits? y discards; n or Esc keeps editing".to_owned();
|
|
return;
|
|
}
|
|
match editor.into_document() {
|
|
Ok(document) => {
|
|
self.viewer = Some(EntryViewer::new(document));
|
|
self.mode = Mode::Viewer;
|
|
self.status = "Edit cancelled".to_owned();
|
|
}
|
|
Err(failure) => {
|
|
let (editor, error) = *failure;
|
|
self.editor = Some(editor);
|
|
self.status = error.to_string();
|
|
}
|
|
}
|
|
}
|
|
|
|
fn discard_editor(&mut self) {
|
|
if self.mode != Mode::Dialog || !self.discard_confirmation {
|
|
return;
|
|
}
|
|
self.editor = None;
|
|
self.viewer = None;
|
|
self.selected_entry = None;
|
|
self.discard_confirmation = false;
|
|
self.suspended_mode = None;
|
|
self.editor_generation_pending = None;
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.status = "Unsaved edits discarded".to_owned();
|
|
}
|
|
|
|
fn keep_editing(&mut self) {
|
|
if self.mode != Mode::Dialog || !self.discard_confirmation {
|
|
return;
|
|
}
|
|
self.discard_confirmation = false;
|
|
self.transition(Transition::Dismiss);
|
|
self.status = "Continuing edit".to_owned();
|
|
}
|
|
|
|
pub fn authentication_granted(&mut self, entry: String) -> bool {
|
|
if self.authentication_pending.as_deref() != Some(&entry) {
|
|
return false;
|
|
}
|
|
self.authentication_pending = None;
|
|
self.selected_entry = Some(entry);
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.grep_view = None;
|
|
self.status = "Authenticated; loading structured entry…".to_owned();
|
|
let transitioned = self.transition(Transition::OpenEntry);
|
|
if transitioned {
|
|
self.focus = PaneFocus::Main;
|
|
}
|
|
transitioned
|
|
}
|
|
|
|
pub fn authentication_failed(&mut self, message: String) {
|
|
self.git_pending = false;
|
|
self.otp_pending = false;
|
|
self.otp_display = None;
|
|
self.qr_popup = None;
|
|
self.uri_popup = None;
|
|
self.clipboard_request = None;
|
|
self.clipboard_presentation = None;
|
|
self.authentication_pending = None;
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.discard_confirmation = false;
|
|
self.editor_generation_pending = None;
|
|
self.command_open_target = None;
|
|
self.status = message;
|
|
if self.mode != Mode::Browser {
|
|
self.mode = Mode::Browser;
|
|
self.focus = PaneFocus::Sidebar;
|
|
}
|
|
}
|
|
|
|
pub fn report_status(&mut self, message: String) {
|
|
self.status = message;
|
|
}
|
|
|
|
pub fn update_remaining_lease(&mut self, remaining: Option<std::time::Duration>) {
|
|
self.remaining_lease = remaining;
|
|
}
|
|
|
|
pub fn forced_relock(&mut self, reason: &'static str) {
|
|
let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
|
|
self.transition(Transition::Lock);
|
|
self.authentication_pending = None;
|
|
self.git_pending = false;
|
|
self.git_view = None;
|
|
self.otp_pending = false;
|
|
self.otp_display = None;
|
|
self.qr_popup = None;
|
|
self.uri_popup = None;
|
|
self.clipboard_request = None;
|
|
self.clipboard_presentation = None;
|
|
self.remaining_lease = None;
|
|
self.status = if discarded_edit {
|
|
format!("Locked: {reason}; unsaved edits were discarded")
|
|
} else {
|
|
format!("Locked: {reason}")
|
|
};
|
|
}
|
|
|
|
pub fn transition(&mut self, transition: Transition) -> bool {
|
|
let current = self.mode;
|
|
let destination = match (current, transition) {
|
|
(Mode::Browser, Transition::OpenEntry) => Some(Mode::Viewer),
|
|
(Mode::Viewer, Transition::EditEntry) => Some(Mode::Editor),
|
|
(Mode::Viewer | Mode::Editor, Transition::CloseEntry) => Some(Mode::Browser),
|
|
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => {
|
|
Some(Mode::Dialog)
|
|
}
|
|
(Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, Transition::OpenHelp) => {
|
|
Some(Mode::Help)
|
|
}
|
|
(
|
|
Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked,
|
|
Transition::OpenCommand,
|
|
) => Some(Mode::Command),
|
|
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
|
|
self.suspended_mode.take()
|
|
}
|
|
(
|
|
Mode::Browser
|
|
| Mode::Viewer
|
|
| Mode::Editor
|
|
| Mode::Dialog
|
|
| Mode::Help
|
|
| Mode::Command,
|
|
Transition::Lock,
|
|
) => Some(Mode::Locked),
|
|
(Mode::Locked, Transition::Unlock) => Some(Mode::Browser),
|
|
_ => None,
|
|
};
|
|
|
|
let Some(destination) = destination else {
|
|
return false;
|
|
};
|
|
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
|
|
if let Some(editor) = self.editor.as_mut() {
|
|
editor.end_input();
|
|
}
|
|
self.suspended_mode = Some(current);
|
|
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.authentication_pending = None;
|
|
self.remaining_lease = None;
|
|
self.discard_confirmation = false;
|
|
self.editor_generation_pending = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
} else if destination == Mode::Locked {
|
|
self.suspended_mode = None;
|
|
self.focus = PaneFocus::Sidebar;
|
|
self.invalidate_requests();
|
|
self.selected_entry = None;
|
|
self.viewer = None;
|
|
self.editor = None;
|
|
self.discard_confirmation = false;
|
|
self.command_line.clear();
|
|
self.command_help = None;
|
|
self.command_open_target = None;
|
|
self.editor_generation_pending = None;
|
|
self.workflow = None;
|
|
self.workflow_pending = false;
|
|
self.grep_view = None;
|
|
self.otp_pending = false;
|
|
self.otp_display = None;
|
|
self.hotp_confirmation = None;
|
|
self.qr_popup = None;
|
|
self.uri_popup = None;
|
|
self.clipboard_request = None;
|
|
self.clipboard_presentation = None;
|
|
self.status = "Locked".to_owned();
|
|
} else if current == Mode::Locked {
|
|
self.status = "Authentication required".to_owned();
|
|
}
|
|
self.mode = destination;
|
|
true
|
|
}
|
|
|
|
fn invalidate_requests(&mut self) {
|
|
self.generation = self.generation.wrapping_add(1);
|
|
self.pending.clear();
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) {
|
|
self.mode = Mode::Viewer;
|
|
self.focus = PaneFocus::Main;
|
|
self.selected_entry = Some(entry.to_owned());
|
|
self.viewer = Some(EntryViewer::new(document));
|
|
self.status = format!("Opened {entry}");
|
|
}
|
|
}
|
|
|
|
fn git_phase_name(phase: GitProgressPhase) -> &'static str {
|
|
match phase {
|
|
GitProgressPhase::Validating => "validating repository",
|
|
GitProgressPhase::Authenticating => "verifying remote and credentials",
|
|
GitProgressPhase::Receiving => "receiving remote objects",
|
|
GitProgressPhase::Integrating => "integrating fetched changes",
|
|
GitProgressPhase::Sending => "sending local objects",
|
|
GitProgressPhase::Refreshing => "refreshing synchronized snapshot",
|
|
}
|
|
}
|
|
|
|
fn workflow_label(workflow: WorkflowAction) -> &'static str {
|
|
match workflow {
|
|
WorkflowAction::Initialize => "initialization",
|
|
WorkflowAction::InsertEntry => "entry insertion",
|
|
WorkflowAction::GenerateEntry => "entry generation",
|
|
WorkflowAction::Grep => "decrypted grep",
|
|
WorkflowAction::ImportKdbx => "KDBX import",
|
|
WorkflowAction::RemoveEntry => "entry removal",
|
|
WorkflowAction::MoveEntry => "entry move",
|
|
WorkflowAction::CopyEntry => "entry copy",
|
|
WorkflowAction::GitPull => "Git pull",
|
|
WorkflowAction::GitPush => "Git push",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::viewer::test_support::fixture_document;
|
|
|
|
fn enter_command(app: &mut App, command: &str) -> AppEffect {
|
|
assert!(matches!(app.dispatch(Action::Command), AppEffect::None));
|
|
for character in command.chars() {
|
|
assert!(matches!(
|
|
app.handle_command_input(
|
|
crossterm::event::KeyCode::Char(character),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
),
|
|
Some(AppEffect::None)
|
|
));
|
|
}
|
|
app.handle_command_input(
|
|
crossterm::event::KeyCode::Enter,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
)
|
|
.expect("command mode consumes Enter")
|
|
}
|
|
|
|
#[test]
|
|
fn every_legal_transition_reaches_its_destination() {
|
|
let cases = [
|
|
(Mode::Browser, Transition::OpenEntry, Mode::Viewer),
|
|
(Mode::Viewer, Transition::EditEntry, Mode::Editor),
|
|
(Mode::Viewer, Transition::CloseEntry, Mode::Browser),
|
|
(Mode::Editor, Transition::CloseEntry, Mode::Browser),
|
|
(Mode::Locked, Transition::Unlock, Mode::Browser),
|
|
];
|
|
for (start, transition, expected) in cases {
|
|
let mut app = App::new();
|
|
app.mode = start;
|
|
assert!(app.transition(transition));
|
|
assert_eq!(app.mode(), expected);
|
|
}
|
|
|
|
for start in [Mode::Browser, Mode::Viewer, Mode::Editor] {
|
|
for (transition, overlay) in [
|
|
(Transition::OpenDialog, Mode::Dialog),
|
|
(Transition::OpenHelp, Mode::Help),
|
|
(Transition::OpenCommand, Mode::Command),
|
|
] {
|
|
let mut app = App::new();
|
|
app.mode = start;
|
|
assert!(app.transition(transition));
|
|
assert_eq!(app.mode(), overlay);
|
|
assert!(app.transition(Transition::Dismiss));
|
|
assert_eq!(app.mode(), start);
|
|
}
|
|
let mut app = App::new();
|
|
app.mode = start;
|
|
assert!(app.transition(Transition::Lock));
|
|
assert_eq!(app.mode(), Mode::Locked);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn illegal_transitions_do_not_change_state() {
|
|
let mut app = App::new();
|
|
assert!(!app.transition(Transition::EditEntry));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
assert!(!app.transition(Transition::Dismiss));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
}
|
|
|
|
#[test]
|
|
fn stale_and_duplicate_async_results_are_rejected() {
|
|
let mut app = App::new();
|
|
let token = app.begin_request();
|
|
let result = AsyncResult {
|
|
token,
|
|
payload: Err("completed".to_owned()),
|
|
};
|
|
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Err("duplicate".to_owned()),
|
|
}),
|
|
ResultDisposition::Stale
|
|
);
|
|
|
|
let stale = app.begin_request();
|
|
assert!(app.transition(Transition::Lock));
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token: stale,
|
|
payload: Err("stale".to_owned()),
|
|
}),
|
|
ResultDisposition::Stale
|
|
);
|
|
assert_eq!(app.status(), "Locked");
|
|
}
|
|
|
|
#[test]
|
|
fn storage_results_are_applied_without_domain_inference() {
|
|
let mut app = App::new();
|
|
let token = app.begin_request();
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Err("typed storage failure".to_owned()),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert_eq!(app.status(), "typed storage failure");
|
|
}
|
|
|
|
#[test]
|
|
fn ticks_resize_and_focus_are_independent_ui_state() {
|
|
let mut app = App::new();
|
|
app.resize(100, 30);
|
|
app.tick();
|
|
app.dispatch(Action::FocusNext);
|
|
assert_eq!(app.terminal_size(), (100, 30));
|
|
assert_eq!(app.ticks(), 1);
|
|
assert_eq!(app.focus(), PaneFocus::Main);
|
|
}
|
|
|
|
#[test]
|
|
fn clipboard_countdown_resets_finishes_and_cannot_survive_lock() {
|
|
let mut app = App::new();
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
let first = match app.dispatch(Action::Copy) {
|
|
AppEffect::CopyFocused { presentation, .. } => presentation,
|
|
effect => panic!("unexpected effect: {effect:?}"),
|
|
};
|
|
let first_token = app.begin_request();
|
|
let first_deadline = Instant::now() + std::time::Duration::from_secs(3);
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token: first_token,
|
|
payload: Ok(AsyncPayload::ClipboardStarted {
|
|
presentation: first,
|
|
deadline: first_deadline,
|
|
}),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert_eq!(
|
|
app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(3)),
|
|
Some(3)
|
|
);
|
|
assert_eq!(
|
|
app.clipboard_remaining_seconds_at(first_deadline - std::time::Duration::from_secs(1)),
|
|
Some(1)
|
|
);
|
|
assert_eq!(app.clipboard_remaining_seconds_at(first_deadline), Some(0));
|
|
|
|
let second = match app.dispatch(Action::Copy) {
|
|
AppEffect::CopyFocused { presentation, .. } => presentation,
|
|
effect => panic!("unexpected effect: {effect:?}"),
|
|
};
|
|
assert_ne!(first, second);
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token: first_token,
|
|
payload: Ok(AsyncPayload::ClipboardFinished {
|
|
presentation: first,
|
|
result: Err(ClipboardError::Cancelled),
|
|
}),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert!(app.clipboard_pending());
|
|
assert_eq!(app.clipboard_remaining_seconds(), None);
|
|
|
|
let second_token = app.begin_request();
|
|
let second_deadline = Instant::now() + std::time::Duration::from_secs(5);
|
|
app.apply_result(AsyncResult {
|
|
token: second_token,
|
|
payload: Ok(AsyncPayload::ClipboardStarted {
|
|
presentation: second,
|
|
deadline: second_deadline,
|
|
}),
|
|
});
|
|
assert_eq!(
|
|
app.clipboard_remaining_seconds_at(second_deadline - std::time::Duration::from_secs(5)),
|
|
Some(5)
|
|
);
|
|
app.apply_result(AsyncResult {
|
|
token: second_token,
|
|
payload: Ok(AsyncPayload::ClipboardFinished {
|
|
presentation: second,
|
|
result: Ok(ClipboardDisposition::Cleared),
|
|
}),
|
|
});
|
|
assert!(!app.clipboard_pending());
|
|
assert_eq!(app.clipboard_remaining_seconds(), None);
|
|
assert_eq!(app.status(), "Clipboard secret cleared");
|
|
|
|
let third = match app.dispatch(Action::Copy) {
|
|
AppEffect::CopyFocused { presentation, .. } => presentation,
|
|
effect => panic!("unexpected effect: {effect:?}"),
|
|
};
|
|
let third_token = app.begin_request();
|
|
app.apply_result(AsyncResult {
|
|
token: third_token,
|
|
payload: Ok(AsyncPayload::ClipboardStarted {
|
|
presentation: third,
|
|
deadline: Instant::now() + std::time::Duration::from_secs(5),
|
|
}),
|
|
});
|
|
assert!(matches!(app.dispatch(Action::Lock), AppEffect::ManualLock));
|
|
assert!(!app.clipboard_pending());
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token: third_token,
|
|
payload: Ok(AsyncPayload::ClipboardFinished {
|
|
presentation: third,
|
|
result: Ok(ClipboardDisposition::Cleared),
|
|
}),
|
|
}),
|
|
ResultDisposition::Stale
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn authentication_must_match_the_pending_entry_before_viewer_transition() {
|
|
let mut app = App::new();
|
|
app.authentication_pending = Some("expected".to_owned());
|
|
assert!(!app.authentication_granted("stale".to_owned()));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
assert!(app.authentication_granted("expected".to_owned()));
|
|
assert_eq!(app.mode(), Mode::Viewer);
|
|
assert_eq!(app.selected_entry(), Some("expected"));
|
|
}
|
|
|
|
#[test]
|
|
fn denial_expiry_and_forced_editor_relock_remove_entry_state() {
|
|
let mut app = App::new();
|
|
app.authentication_pending = Some("secret".to_owned());
|
|
app.authentication_failed("authentication was denied".to_owned());
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
assert_eq!(app.selected_entry(), None);
|
|
assert!(!app.authentication_pending());
|
|
|
|
app.mode = Mode::Editor;
|
|
app.selected_entry = Some("secret".to_owned());
|
|
app.remaining_lease = Some(std::time::Duration::from_secs(1));
|
|
app.forced_relock("authentication lease expired");
|
|
assert_eq!(app.mode(), Mode::Locked);
|
|
assert_eq!(app.focus(), PaneFocus::Sidebar);
|
|
assert_eq!(app.selected_entry(), None);
|
|
assert_eq!(app.remaining_lease(), None);
|
|
assert!(app.status().contains("unsaved edits were discarded"));
|
|
}
|
|
|
|
#[test]
|
|
fn conflict_and_storage_save_failures_restore_the_complete_editor_draft() {
|
|
for (kind, message) in [
|
|
(EditorSaveFailureKind::Conflict, "concurrent ciphertext"),
|
|
(EditorSaveFailureKind::Storage, "encryption unavailable"),
|
|
] {
|
|
let mut app = App::new();
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
app.dispatch(Action::EditEntry);
|
|
app.editor
|
|
.as_mut()
|
|
.expect("editor")
|
|
.add_after_focused()
|
|
.expect("dirty document");
|
|
let editor = Box::new(app.editor.take().expect("move editor to worker"));
|
|
let token = app.begin_request();
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Ok(AsyncPayload::DocumentSaveFinished {
|
|
entry: "email/personal".to_owned(),
|
|
editor,
|
|
result: Err(EditorSaveFailure::new(kind, message.to_owned())),
|
|
}),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert_eq!(app.mode(), Mode::Editor);
|
|
assert!(app.editor().is_some_and(EntryEditor::is_dirty));
|
|
assert!(app.status().contains(message));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn editor_results_complete_safely_while_help_temporarily_has_focus() {
|
|
let mut app = App::new();
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
app.dispatch(Action::EditEntry);
|
|
let target = app
|
|
.editor
|
|
.as_mut()
|
|
.expect("editor")
|
|
.generation_target()
|
|
.expect("target")
|
|
.expect("password target");
|
|
app.editor_generation_pending = Some(target);
|
|
let token = app.begin_request();
|
|
app.dispatch(Action::Help);
|
|
assert_eq!(app.mode(), Mode::Help);
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Ok(AsyncPayload::GeneratedField {
|
|
target,
|
|
password: SecretBytes::new(b"generated-under-help".to_vec()),
|
|
}),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
app.dispatch(Action::Cancel);
|
|
assert_eq!(app.mode(), Mode::Editor);
|
|
assert_eq!(
|
|
app.editor()
|
|
.and_then(EntryEditor::focused_field)
|
|
.expect("password")
|
|
.value(),
|
|
b"generated-under-help"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn direct_workflow_keys_dispatch_typed_ui_effects_without_domain_work() {
|
|
let mut app = App::new();
|
|
assert!(matches!(app.dispatch(Action::InsertEntry), AppEffect::None));
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
assert!(app.workflow().is_some());
|
|
app.dispatch(Action::Cancel);
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
assert!(matches!(app.dispatch(Action::RemoveEntry), AppEffect::None));
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
let rows = app.workflow().expect("remove form").rows().join("\n");
|
|
assert!(rows.contains("email/personal"));
|
|
assert!(rows.contains("Confirm permanent removal: no"));
|
|
}
|
|
|
|
#[test]
|
|
fn colon_write_commands_open_the_same_secure_forms_with_typed_options() {
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "insert --multiline --force nested/note"),
|
|
AppEffect::None
|
|
));
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
let rows = app.workflow().expect("insert form").rows().join("\n");
|
|
assert!(rows.contains("nested/note"));
|
|
assert!(rows.contains("multiline"));
|
|
assert!(rows.contains("Overwrite: yes"));
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
|
|
assert!(matches!(
|
|
enter_command(&mut app, "generate --no-symbols --in-place nested/note 31"),
|
|
AppEffect::None
|
|
));
|
|
let rows = app.workflow().expect("generate form").rows().join("\n");
|
|
assert!(rows.contains("Length: 31"));
|
|
assert!(rows.contains("Symbols: no"));
|
|
assert!(rows.contains("In place: yes"));
|
|
}
|
|
|
|
#[test]
|
|
fn colon_show_and_edit_route_to_authenticated_tui_panes() {
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "show 'email/personal account'"),
|
|
AppEffect::AuthenticateEntry(entry) if entry == "email/personal account"
|
|
));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "show --clip=2 email/personal"),
|
|
AppEffect::AuthenticateShow(ironstorage::command::ShowRequest {
|
|
entry: Some(entry),
|
|
presentation: Presentation::Clipboard { .. },
|
|
}) if entry == "email/personal"
|
|
));
|
|
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
assert!(matches!(
|
|
enter_command(&mut app, "edit email/personal"),
|
|
AppEffect::None
|
|
));
|
|
assert_eq!(app.mode(), Mode::Editor);
|
|
}
|
|
|
|
#[test]
|
|
fn destructive_colon_commands_require_confirmation_and_can_be_cancelled() {
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "remove -r old/folder"),
|
|
AppEffect::None
|
|
));
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
let rows = app.workflow().expect("remove form").rows().join("\n");
|
|
assert!(rows.contains("old/folder"));
|
|
assert!(rows.contains("Recursive folder: yes"));
|
|
assert!(rows.contains("Confirm permanent removal: no"));
|
|
assert!(matches!(
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Char('s'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
),
|
|
Some(AppEffect::None)
|
|
));
|
|
assert!(app.status().contains("Explicitly confirm"));
|
|
for _ in 0..3 {
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Tab,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
}
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Char(' '),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
assert!(matches!(
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Char('s'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
),
|
|
Some(AppEffect::AuthenticateWorkflow(submission))
|
|
if matches!(*submission, WorkflowSubmission::Remove(ironstorage::command::RemoveRequest {
|
|
ref entry,
|
|
recursive: true,
|
|
..
|
|
}) if entry == "old/folder")
|
|
));
|
|
|
|
app.workflow_authentication_failed("cancel test".to_owned());
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
|
|
assert!(matches!(
|
|
enter_command(&mut app, "remove old/other"),
|
|
AppEffect::None
|
|
));
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Esc,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
assert!(app.status().contains("cancelled"));
|
|
}
|
|
|
|
#[test]
|
|
fn mutation_failures_retain_all_explicit_choices_for_retry() {
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "copy --force source/entry destination/folder/"),
|
|
AppEffect::None
|
|
));
|
|
let before = app.workflow().expect("copy form").rows();
|
|
assert!(
|
|
before
|
|
.iter()
|
|
.any(|row| row.contains("Overwrite collision: yes"))
|
|
);
|
|
assert!(
|
|
before
|
|
.iter()
|
|
.any(|row| row.contains("Destination is existing directory: yes"))
|
|
);
|
|
assert!(matches!(
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Char('s'),
|
|
crossterm::event::KeyModifiers::CONTROL,
|
|
),
|
|
Some(AppEffect::AuthenticateWorkflow(_))
|
|
));
|
|
let token = app.begin_request();
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Ok(AsyncPayload::WorkflowFinished(Err(
|
|
"typed destination collision".to_owned(),
|
|
))),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
assert_eq!(app.workflow().expect("retained form").rows(), before);
|
|
assert!(app.status().contains("typed destination collision"));
|
|
assert!(app.status().contains("Form retained"));
|
|
}
|
|
|
|
#[test]
|
|
fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() {
|
|
let mut app = App::new();
|
|
app.open_test_document("email/personal", fixture_document("email/personal"));
|
|
app.dispatch(Action::EditEntry);
|
|
assert_eq!(app.mode(), Mode::Editor);
|
|
assert!(matches!(
|
|
enter_command(&mut app, "show another/entry"),
|
|
AppEffect::None
|
|
));
|
|
assert_eq!(app.mode(), Mode::Command);
|
|
assert!(app.status().contains("unavailable while an editor is open"));
|
|
app.dispatch(Action::Cancel);
|
|
assert_eq!(app.mode(), Mode::Editor);
|
|
|
|
app.forced_relock("test");
|
|
assert_eq!(app.mode(), Mode::Locked);
|
|
assert!(matches!(enter_command(&mut app, "unlock"), AppEffect::None));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
}
|
|
|
|
#[test]
|
|
fn secret_bearing_commands_never_enter_history_or_status() {
|
|
let mut app = App::new();
|
|
let secret = "NEVER-RENDER-THIS";
|
|
assert!(matches!(
|
|
enter_command(
|
|
&mut app,
|
|
&format!("otp validate otpauth://totp/test?secret={secret}"),
|
|
),
|
|
AppEffect::RunCommand(CommandRequest::Otp(OtpRequest::Validate { .. }))
|
|
));
|
|
assert!(app.command_line().history().is_empty());
|
|
assert!(!app.status().contains(secret));
|
|
}
|
|
|
|
#[test]
|
|
fn bracketed_paste_is_inert_unicode_text_and_rejects_submission_controls() {
|
|
let mut app = App::new();
|
|
app.dispatch(Action::Command);
|
|
assert!(app.handle_paste("show unicode/咖啡"));
|
|
assert_eq!(app.command_line().input(), "show unicode/咖啡");
|
|
assert!(app.handle_paste("\nremove everything"));
|
|
assert_eq!(app.command_line().input(), "show unicode/咖啡");
|
|
assert!(app.status().contains("control characters"));
|
|
|
|
app.dispatch(Action::Cancel);
|
|
app.dispatch(Action::Filter);
|
|
assert!(app.handle_paste("咖啡"));
|
|
assert_eq!(app.sidebar().filter_query(), "咖啡");
|
|
|
|
let mut app = App::new();
|
|
app.dispatch(Action::InsertEntry);
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Tab,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Tab,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
assert!(app.handle_paste("pasted-secret"));
|
|
let rows = app.workflow().expect("insert form").rows().join("\n");
|
|
assert!(rows.contains("••••••••"));
|
|
assert!(!rows.contains("pasted-secret"));
|
|
}
|
|
|
|
#[test]
|
|
fn hotp_requires_confirmation_and_uri_forms_remain_masked() {
|
|
let mut app = App::new();
|
|
assert!(matches!(
|
|
enter_command(&mut app, "otp code otp/hotp"),
|
|
AppEffect::None
|
|
));
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
assert!(app.hotp_confirmation());
|
|
assert!(matches!(
|
|
app.dispatch(Action::ConfirmDiscard),
|
|
AppEffect::AuthenticateOtp(OtpUiRequest {
|
|
confirmed_hotp: true,
|
|
..
|
|
})
|
|
));
|
|
|
|
app.workflow_pending = false;
|
|
app.mode = Mode::Browser;
|
|
app.dispatch(Action::OtpInsert);
|
|
let secret = "otpauth://totp/test?secret=NEVER-RENDER";
|
|
app.workflow.as_mut().expect("OTP form").handle_key(
|
|
crossterm::event::KeyCode::Tab,
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
for character in secret.chars() {
|
|
app.workflow.as_mut().expect("OTP form").handle_key(
|
|
crossterm::event::KeyCode::Char(character),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
}
|
|
let rows = app.workflow().expect("OTP form").rows().join("\n");
|
|
assert!(rows.contains("••••••••"));
|
|
assert!(!rows.contains("NEVER-RENDER"));
|
|
}
|
|
|
|
#[test]
|
|
fn typed_ssh_passphrase_request_opens_a_masked_cancellable_dialog() {
|
|
let mut app = App::new();
|
|
app.git_pending = true;
|
|
let token = app.begin_request();
|
|
let fingerprint =
|
|
SshFingerprint::parse("SHA256:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA")
|
|
.expect("fingerprint");
|
|
assert_eq!(
|
|
app.apply_result(AsyncResult {
|
|
token,
|
|
payload: Ok(AsyncPayload::GitPassphraseRequired {
|
|
request: GitRequest::Sync {
|
|
remote: Some("origin".to_owned()),
|
|
},
|
|
fingerprint: fingerprint.clone(),
|
|
}),
|
|
}),
|
|
ResultDisposition::Applied
|
|
);
|
|
assert_eq!(app.mode(), Mode::Dialog);
|
|
assert!(!app.git_pending());
|
|
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
|
|
assert!(rows.contains(fingerprint.as_str()));
|
|
assert!(!rows.contains("secret"));
|
|
app.handle_workflow_input(
|
|
crossterm::event::KeyCode::Char('Q'),
|
|
crossterm::event::KeyModifiers::NONE,
|
|
);
|
|
let rows = app.workflow().expect("SSH prompt").rows().join("\n");
|
|
assert!(rows.contains("••••••••"));
|
|
assert!(!rows.contains('Q'));
|
|
assert!(matches!(app.dispatch(Action::Cancel), AppEffect::None));
|
|
assert_eq!(app.mode(), Mode::Browser);
|
|
}
|
|
}
|