//! 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, } #[derive(Clone, Debug, Eq, PartialEq)] pub struct StartupData { pub config: Config, pub tree: TreeModel, pub key: KeyInfo, pub keys: Vec, } #[derive(Debug)] pub struct WorkflowSuccess { pub tree: Option, pub selection: Option<(String, bool)>, pub entry: Option, pub document: Option>, pub grep: Option, pub presentation: Option, pub status: String, } #[derive(Debug)] pub struct GitView { snapshot: GitSnapshot, message: String, conflicts: Vec, details: Option, } #[derive(Debug)] pub struct OtpDisplay { entry: String, field: Option, code: SecretBytes, validity: OtpCodeValidity, remaining_seconds: Option, } #[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 { self.field } pub fn code(&self) -> &SecretBytes { &self.code } pub fn remaining_seconds(&self) -> Option { self.remaining_seconds } pub fn counter(&self) -> Option { self.validity.counter() } pub fn period_seconds(&self) -> Option { 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, } 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), Refreshed(TreeModel), Filtered { query: String, results: FindResults, }, Found { query: String, results: FindResults, }, DocumentLoaded { entry: String, document: Box, }, ClipboardStarted { presentation: ClipboardPresentationId, deadline: Instant, }, ClipboardFinished { presentation: ClipboardPresentationId, result: Result, }, GeneratedField { target: EntryFieldId, password: SecretBytes, }, DocumentSaveFinished { entry: String, editor: Box, result: Result, }, WorkflowFinished(Result, String>), GitProgress(GitProgressPhase), GitFinished { snapshot: Box, tree: Option, message: String, conflicts: Vec, details: Option, }, GitHostConfirmation { request: GitRequest, host_key: SshHostKey, }, GitPassphraseRequired { request: GitRequest, fingerprint: SshFingerprint, }, OtpCodeFinished { entry: String, field: Option, code: SecretBytes, validity: OtpCodeValidity, observed_at: u64, clipboard: bool, tree: Option, }, OtpUriFinished { entry: String, presentation: OtpPresentationTarget, payload: SecretBytes, qr: Option, }, 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, } #[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, entry: String, editor: Box, }, AuthenticateWorkflow(Box), AuthenticateGit(ironstorage::command::GitRequest), RetryGitAfterHostConfirmation { request: GitRequest, host_key: SshHostKey, }, RetryGitWithPassphrase { request: GitRequest, fingerprint: SshFingerprint, passphrase: SecretBytes, }, CancelGit, ResolveGit(Vec), 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, focus: PaneFocus, status: String, config: Option, default_key: Option, available_keys: Vec, sidebar: Sidebar, selected_entry: Option, viewer: Option, editor: Option, discard_confirmation: bool, command_line: CommandLine, command_help: Option, command_open_target: Option, editor_generation_pending: Option, authentication_pending: Option, workflow: Option, workflow_pending: bool, grep_view: Option, git_view: Option, git_pending: bool, otp_display: Option, qr_popup: Option, uri_popup: Option, otp_pending: bool, hotp_confirmation: Option, clipboard_request: Option, clipboard_presentation: Option, next_clipboard_presentation: u64, remaining_lease: Option, terminal_size: (u16, u16), ticks: u64, should_quit: bool, generation: u64, next_request_id: u64, pending: BTreeSet, } 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 { let value = self.clipboard_request.take()?; Some(self.begin_clipboard_presentation(value)) } pub fn clipboard_remaining_seconds(&self) -> Option { self.clipboard_remaining_seconds_at(Instant::now()) } fn clipboard_remaining_seconds_at(&self, now: Instant) -> Option { 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 { 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 { 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 { 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) { 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>) { 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) { 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); } }