//! Pure UI state machine. Storage behavior is represented only by typed results. use std::collections::BTreeSet; use ironstorage::{ config::Config, crypto::KeyInfo, document::{DocumentError, EntryDocument, EntryFieldId}, presentation::ClipboardDisposition, read::{FindResults, TreeModel}, repository::SecretBytes, write::WriteOutcome, }; use crate::{ action::{Action, WorkflowAction}, editor::EntryEditor, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, }; #[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, Debug, Eq, PartialEq)] pub struct StartupData { pub config: Config, pub tree: TreeModel, pub key: KeyInfo, } #[derive(Debug)] pub enum AsyncPayload { Startup(Box), Refreshed(TreeModel), Filtered { query: String, results: FindResults, }, DocumentLoaded { entry: String, document: Box, }, ClipboardFinished(ClipboardDisposition), GeneratedField { target: EntryFieldId, password: SecretBytes, }, DocumentSaveFinished { entry: String, editor: Box, result: Result, }, } #[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(SecretBytes), GenerateField(EntryFieldId), SaveDocument { config: Box, entry: String, editor: Box, }, OpenWorkflow(WorkflowAction), ManualLock, } #[derive(Debug)] pub struct App { mode: Mode, suspended_mode: Option, focus: PaneFocus, status: String, config: Option, default_key: Option, sidebar: Sidebar, selected_entry: Option, viewer: Option, editor: Option, discard_confirmation: bool, editor_generation_pending: Option, authentication_pending: Option, 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, sidebar: Sidebar::default(), selected_entry: None, viewer: None, editor: None, discard_confirmation: false, editor_generation_pending: None, authentication_pending: None, 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 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 default_key(&self) -> Option<&KeyInfo> { self.default_key.as_ref() } pub fn authentication_pending(&self) -> bool { self.authentication_pending.is_some() } 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 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.remove(&result.token.id) { return ResultDisposition::Stale; } 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.config = Some(startup.config); } Ok(AsyncPayload::Refreshed(tree)) => { self.sidebar.replace_tree(&tree); 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::DocumentLoaded { entry, document }) => { if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) { return ResultDisposition::Stale; } let field_count = document.fields().len(); self.viewer = Some(EntryViewer::new(*document)); self.focus = PaneFocus::Main; self.status = format!("Opened {entry} ({field_count} fields)"); } Ok(AsyncPayload::ClipboardFinished(disposition)) => { self.status = match disposition { ClipboardDisposition::RestoredPrevious => { "Clipboard restored to its previous value".to_owned() } ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(), ClipboardDisposition::PreservedNewer => { "Clipboard changed; the newer value was preserved".to_owned() } }; } 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()); } }, } } Err(error) => { self.status = error; self.editor_generation_pending = 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.transition(Transition::OpenHelp); } Action::Command => { self.transition(Transition::OpenCommand); } Action::Cancel => { if self.mode == Mode::Editor { self.cancel_editor(); } else if self.mode == Mode::Dialog && self.discard_confirmation { self.keep_editing(); } else { 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 => 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.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.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::Reveal => { let revealed = if self.mode == Mode::Editor { self.editor .as_mut() .is_some_and(EntryEditor::reveal_focused) } else { self.viewer .as_mut() .is_some_and(EntryViewer::reveal_focused) }; if revealed { self.status = "Focused sensitive field revealed".to_owned(); } } Action::Hide => { let hidden = if self.mode == Mode::Editor { self.editor.as_mut().is_some_and(EntryEditor::hide_revealed) } else { self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) }; if hidden { self.status = "Sensitive field hidden".to_owned(); } } Action::Copy => { if let Some(viewer) = self.viewer.as_ref() { match viewer.copy_focused() { Ok(value) => { self.status = self.config.as_ref().map_or_else( || "Copying focused field…".to_owned(), |config| { format!( "Copied focused field; cleanup in {}s", config.clipboard_timeout().duration().as_secs() ) }, ); return AppEffect::CopyFocused(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 => self.discard_editor(), Action::KeepEditing => self.keep_editing(), Action::Initialize | Action::InsertEntry | Action::GenerateEntry | Action::Grep | Action::RemoveEntry | Action::MoveEntry | Action::CopyEntry | Action::GitPull | Action::GitPush => { let workflow = action.workflow().expect("matched workflow action"); self.status = format!("Selected {} workflow", workflow_label(workflow)); return AppEffect::OpenWorkflow(workflow); } Action::Quit => {} } 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) } 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.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.authentication_pending = None; self.selected_entry = None; self.viewer = None; self.editor = None; self.discard_confirmation = false; self.editor_generation_pending = 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.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, Transition::OpenHelp) => Some(Mode::Help), (Mode::Browser | Mode::Viewer | Mode::Editor, 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(viewer) = self.viewer.as_mut() { viewer.hide_revealed(); } if let Some(editor) = self.editor.as_mut() { editor.hide_revealed(); 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.editor_generation_pending = 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 workflow_label(workflow: WorkflowAction) -> &'static str { match workflow { WorkflowAction::Initialize => "initialization", WorkflowAction::InsertEntry => "entry insertion", WorkflowAction::GenerateEntry => "entry generation", WorkflowAction::Grep => "decrypted grep", 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; #[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 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::OpenWorkflow(WorkflowAction::InsertEntry) )); assert!(app.status().contains("entry insertion")); app.open_test_document("email/personal", fixture_document("email/personal")); assert!(matches!( app.dispatch(Action::RemoveEntry), AppEffect::OpenWorkflow(WorkflowAction::RemoveEntry) )); assert!(app.status().contains("entry removal")); } }