Files
IronStorage/apps/tui/src/app.rs
2026-08-10 08:03:20 +00:00

1191 lines
41 KiB
Rust

//! 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<StartupData>),
Refreshed(TreeModel),
Filtered {
query: String,
results: FindResults,
},
DocumentLoaded {
entry: String,
document: Box<EntryDocument>,
},
ClipboardFinished(ClipboardDisposition),
GeneratedField {
target: EntryFieldId,
password: SecretBytes,
},
DocumentSaveFinished {
entry: String,
editor: Box<EntryEditor>,
result: Result<WriteOutcome, EditorSaveFailure>,
},
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum EditorSaveFailureKind {
Conflict,
Unchanged,
Storage,
}
#[derive(Debug)]
pub struct EditorSaveFailure {
kind: EditorSaveFailureKind,
message: String,
}
impl EditorSaveFailure {
pub fn new(kind: EditorSaveFailureKind, message: String) -> Self {
Self { kind, message }
}
pub fn kind(&self) -> EditorSaveFailureKind {
self.kind
}
pub fn message(&self) -> &str {
&self.message
}
}
#[derive(Debug)]
pub struct AsyncResult {
pub token: RequestToken,
pub payload: Result<AsyncPayload, String>,
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ResultDisposition {
Applied,
Stale,
}
#[derive(Debug)]
pub enum AppEffect {
None,
RefreshTree,
AuthenticateEntry(String),
CopyFocused(SecretBytes),
GenerateField(EntryFieldId),
SaveDocument {
config: Box<Config>,
entry: String,
editor: Box<EntryEditor>,
},
OpenWorkflow(WorkflowAction),
ManualLock,
}
#[derive(Debug)]
pub struct App {
mode: Mode,
suspended_mode: Option<Mode>,
focus: PaneFocus,
status: String,
config: Option<Config>,
default_key: Option<KeyInfo>,
sidebar: Sidebar,
selected_entry: Option<String>,
viewer: Option<EntryViewer>,
editor: Option<EntryEditor>,
discard_confirmation: bool,
editor_generation_pending: Option<EntryFieldId>,
authentication_pending: Option<String>,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
ticks: u64,
should_quit: bool,
generation: u64,
next_request_id: u64,
pending: BTreeSet<u64>,
}
impl Default for App {
fn default() -> Self {
Self::new()
}
}
impl App {
pub fn new() -> Self {
Self {
mode: Mode::Browser,
suspended_mode: None,
focus: PaneFocus::Sidebar,
status: "Starting…".to_owned(),
config: None,
default_key: None,
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<std::time::Duration> {
self.remaining_lease
}
pub fn terminal_size(&self) -> (u16, u16) {
self.terminal_size
}
pub fn ticks(&self) -> u64 {
self.ticks
}
pub fn is_busy(&self) -> bool {
!self.pending.is_empty()
}
pub fn should_quit(&self) -> bool {
self.should_quit
}
pub fn resize(&mut self, width: u16, height: u16) {
self.terminal_size = (width, height);
let content_height = height.saturating_sub(3) as usize;
let sidebar_height = if width < 80 {
content_height.saturating_mul(40) / 100
} else {
content_height
};
self.sidebar
.set_viewport_height(sidebar_height.saturating_sub(2));
}
pub fn tick(&mut self) {
self.ticks = self.ticks.wrapping_add(1);
}
pub fn 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<Result<(), DocumentError>>) {
if let Some(Err(error)) = result {
self.status = error.to_string();
}
}
fn begin_editor_save(&mut self) -> AppEffect {
if self.editor_generation_pending.is_some() {
self.status = "Wait for password generation before saving".to_owned();
return AppEffect::None;
}
let Some(config) = self.config.clone() else {
self.status = "Cannot save before configuration is loaded".to_owned();
return AppEffect::None;
};
let Some(editor) = self.editor.take() else {
return AppEffect::None;
};
let editor = match editor.prepare_save() {
Ok(editor) => editor,
Err(failure) => {
let (editor, error) = *failure;
self.editor = Some(editor);
self.status = error.to_string();
return AppEffect::None;
}
};
if !editor.is_dirty() {
match editor.into_document() {
Ok(document) => {
self.viewer = Some(EntryViewer::new(document));
self.mode = Mode::Viewer;
self.status = "No changes to save".to_owned();
}
Err(failure) => {
let (editor, error) = *failure;
self.editor = Some(editor);
self.status = error.to_string();
}
}
return AppEffect::None;
}
let Some(entry) = self.selected_entry.clone() else {
self.editor = Some(editor);
self.status = "Cannot save an editor without an entry identity".to_owned();
return AppEffect::None;
};
self.status = format!("Saving {entry}");
AppEffect::SaveDocument {
config: Box::new(config),
entry,
editor: Box::new(editor),
}
}
fn cancel_editor(&mut self) {
let Some(editor) = self.editor.take() else {
return;
};
let editor = match editor.prepare_save() {
Ok(editor) => editor,
Err(failure) => {
let (editor, error) = *failure;
self.editor = Some(editor);
self.status = error.to_string();
return;
}
};
if editor.is_dirty() {
self.editor = Some(editor);
self.discard_confirmation = true;
self.transition(Transition::OpenDialog);
self.status = "Discard unsaved edits? y discards; n or Esc keeps editing".to_owned();
return;
}
match editor.into_document() {
Ok(document) => {
self.viewer = Some(EntryViewer::new(document));
self.mode = Mode::Viewer;
self.status = "Edit cancelled".to_owned();
}
Err(failure) => {
let (editor, error) = *failure;
self.editor = Some(editor);
self.status = error.to_string();
}
}
}
fn discard_editor(&mut self) {
if self.mode != Mode::Dialog || !self.discard_confirmation {
return;
}
self.editor = None;
self.viewer = None;
self.selected_entry = None;
self.discard_confirmation = false;
self.suspended_mode = None;
self.editor_generation_pending = None;
self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar;
self.status = "Unsaved edits discarded".to_owned();
}
fn keep_editing(&mut self) {
if self.mode != Mode::Dialog || !self.discard_confirmation {
return;
}
self.discard_confirmation = false;
self.transition(Transition::Dismiss);
self.status = "Continuing edit".to_owned();
}
pub fn authentication_granted(&mut self, entry: String) -> bool {
if self.authentication_pending.as_deref() != Some(&entry) {
return false;
}
self.authentication_pending = None;
self.selected_entry = Some(entry);
self.viewer = None;
self.editor = None;
self.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<std::time::Duration>) {
self.remaining_lease = remaining;
}
pub fn forced_relock(&mut self, reason: &'static str) {
let discarded_edit = self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor);
self.transition(Transition::Lock);
self.authentication_pending = None;
self.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"));
}
}