@@ -5,14 +5,16 @@ use std::collections::BTreeSet;
|
||||
use ironstorage::{
|
||||
config::Config,
|
||||
crypto::KeyInfo,
|
||||
document::EntryDocument,
|
||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||
presentation::ClipboardDisposition,
|
||||
read::{FindResults, TreeModel},
|
||||
repository::SecretBytes,
|
||||
write::WriteOutcome,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
action::Action,
|
||||
editor::EntryEditor,
|
||||
sidebar::{Sidebar, SidebarIntent},
|
||||
viewer::EntryViewer,
|
||||
};
|
||||
@@ -73,6 +75,42 @@ pub enum AsyncPayload {
|
||||
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)]
|
||||
@@ -93,6 +131,12 @@ pub enum AppEffect {
|
||||
RefreshTree,
|
||||
AuthenticateEntry(String),
|
||||
CopyFocused(SecretBytes),
|
||||
GenerateField(EntryFieldId),
|
||||
SaveDocument {
|
||||
config: Box<Config>,
|
||||
entry: String,
|
||||
editor: Box<EntryEditor>,
|
||||
},
|
||||
ManualLock,
|
||||
}
|
||||
|
||||
@@ -107,6 +151,9 @@ pub struct App {
|
||||
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),
|
||||
@@ -135,6 +182,9 @@ impl App {
|
||||
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),
|
||||
@@ -178,6 +228,14 @@ impl App {
|
||||
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()
|
||||
}
|
||||
@@ -278,8 +336,75 @@ impl App {
|
||||
}
|
||||
};
|
||||
}
|
||||
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;
|
||||
@@ -291,6 +416,23 @@ impl App {
|
||||
}
|
||||
|
||||
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) => {
|
||||
self.should_quit = true;
|
||||
@@ -302,7 +444,13 @@ impl App {
|
||||
self.transition(Transition::OpenCommand);
|
||||
}
|
||||
Action::Cancel => {
|
||||
self.transition(Transition::Dismiss);
|
||||
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);
|
||||
@@ -351,6 +499,14 @@ impl App {
|
||||
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,
|
||||
@@ -358,16 +514,26 @@ impl App {
|
||||
};
|
||||
}
|
||||
Action::Reveal => {
|
||||
if self
|
||||
.viewer
|
||||
.as_mut()
|
||||
.is_some_and(EntryViewer::reveal_focused)
|
||||
{
|
||||
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 => {
|
||||
if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) {
|
||||
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();
|
||||
}
|
||||
}
|
||||
@@ -403,11 +569,236 @@ impl App {
|
||||
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::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;
|
||||
@@ -415,6 +806,7 @@ impl App {
|
||||
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 {
|
||||
@@ -427,6 +819,9 @@ impl App {
|
||||
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;
|
||||
@@ -443,7 +838,7 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn forced_relock(&mut self, reason: &'static str) {
|
||||
let discarded_edit = self.mode == Mode::Editor;
|
||||
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;
|
||||
@@ -470,7 +865,15 @@ impl App {
|
||||
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
|
||||
self.suspended_mode.take()
|
||||
}
|
||||
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::Lock) => Some(Mode::Locked),
|
||||
(
|
||||
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,
|
||||
};
|
||||
@@ -482,12 +885,19 @@ impl App {
|
||||
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;
|
||||
@@ -495,6 +905,9 @@ impl App {
|
||||
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();
|
||||
@@ -521,6 +934,7 @@ impl App {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::viewer::test_support::fixture_document;
|
||||
|
||||
#[test]
|
||||
fn every_legal_transition_reaches_its_destination() {
|
||||
@@ -651,4 +1065,74 @@ mod tests {
|
||||
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"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user