Implement TUI init insert and generate workflows
This commit is contained in:
@@ -21,6 +21,7 @@ use crate::{
|
||||
editor::EntryEditor,
|
||||
sidebar::{Sidebar, SidebarIntent},
|
||||
viewer::EntryViewer,
|
||||
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
|
||||
};
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
|
||||
@@ -64,6 +65,15 @@ pub struct StartupData {
|
||||
pub config: Config,
|
||||
pub tree: TreeModel,
|
||||
pub key: KeyInfo,
|
||||
pub keys: Vec<KeyInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSuccess {
|
||||
pub tree: Option<TreeModel>,
|
||||
pub entry: Option<String>,
|
||||
pub document: Option<Box<EntryDocument>>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
@@ -88,6 +98,7 @@ pub enum AsyncPayload {
|
||||
editor: Box<EntryEditor>,
|
||||
result: Result<WriteOutcome, EditorSaveFailure>,
|
||||
},
|
||||
WorkflowFinished(Result<Box<WorkflowSuccess>, String>),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
@@ -141,6 +152,7 @@ pub enum AppEffect {
|
||||
entry: String,
|
||||
editor: Box<EntryEditor>,
|
||||
},
|
||||
AuthenticateWorkflow(Box<WorkflowSubmission>),
|
||||
OpenWorkflow(WorkflowAction),
|
||||
RunCommand(CommandRequest),
|
||||
ManualLock,
|
||||
@@ -160,6 +172,7 @@ pub struct App {
|
||||
status: String,
|
||||
config: Option<Config>,
|
||||
default_key: Option<KeyInfo>,
|
||||
available_keys: Vec<KeyInfo>,
|
||||
sidebar: Sidebar,
|
||||
selected_entry: Option<String>,
|
||||
viewer: Option<EntryViewer>,
|
||||
@@ -172,6 +185,8 @@ pub struct App {
|
||||
command_open_target: Option<CommandOpenTarget>,
|
||||
editor_generation_pending: Option<EntryFieldId>,
|
||||
authentication_pending: Option<String>,
|
||||
workflow: Option<WorkflowForm>,
|
||||
workflow_pending: bool,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
terminal_size: (u16, u16),
|
||||
ticks: u64,
|
||||
@@ -196,6 +211,7 @@ impl App {
|
||||
status: "Starting…".to_owned(),
|
||||
config: None,
|
||||
default_key: None,
|
||||
available_keys: Vec::new(),
|
||||
sidebar: Sidebar::default(),
|
||||
selected_entry: None,
|
||||
viewer: None,
|
||||
@@ -208,6 +224,8 @@ impl App {
|
||||
command_open_target: None,
|
||||
editor_generation_pending: None,
|
||||
authentication_pending: None,
|
||||
workflow: None,
|
||||
workflow_pending: false,
|
||||
remaining_lease: None,
|
||||
terminal_size: (0, 0),
|
||||
ticks: 0,
|
||||
@@ -291,7 +309,11 @@ impl App {
|
||||
}
|
||||
|
||||
pub fn authentication_pending(&self) -> bool {
|
||||
self.authentication_pending.is_some()
|
||||
self.authentication_pending.is_some() || self.workflow_pending
|
||||
}
|
||||
|
||||
pub fn workflow(&self) -> Option<&WorkflowForm> {
|
||||
self.workflow.as_ref()
|
||||
}
|
||||
|
||||
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
|
||||
@@ -354,6 +376,7 @@ impl App {
|
||||
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)) => {
|
||||
@@ -459,6 +482,35 @@ impl App {
|
||||
},
|
||||
}
|
||||
}
|
||||
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(entry), Some(document)) = (success.entry, success.document) {
|
||||
self.selected_entry = Some(entry);
|
||||
self.viewer = Some(EntryViewer::new(*document));
|
||||
self.editor = None;
|
||||
self.mode = Mode::Viewer;
|
||||
self.focus = PaneFocus::Main;
|
||||
} else {
|
||||
self.selected_entry = None;
|
||||
self.viewer = None;
|
||||
self.editor = None;
|
||||
self.mode = Mode::Browser;
|
||||
self.focus = PaneFocus::Sidebar;
|
||||
}
|
||||
self.status = success.status;
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = format!("Workflow failed: {error}. Form retained.");
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(error) => {
|
||||
self.status = error;
|
||||
self.editor_generation_pending = None;
|
||||
@@ -510,6 +562,8 @@ impl App {
|
||||
self.keep_editing();
|
||||
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
|
||||
self.cancel_command_confirmation();
|
||||
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
|
||||
self.cancel_workflow();
|
||||
} else {
|
||||
if self.mode == Mode::Command {
|
||||
self.command_line.clear();
|
||||
@@ -706,6 +760,15 @@ impl App {
|
||||
| Action::GitPull
|
||||
| Action::GitPush => {
|
||||
let workflow = action.workflow().expect("matched workflow action");
|
||||
if matches!(
|
||||
workflow,
|
||||
WorkflowAction::Initialize
|
||||
| WorkflowAction::InsertEntry
|
||||
| WorkflowAction::GenerateEntry
|
||||
) {
|
||||
self.open_workflow(workflow, None);
|
||||
return AppEffect::None;
|
||||
}
|
||||
self.status = format!("Selected {} workflow", workflow_label(workflow));
|
||||
return AppEffect::OpenWorkflow(workflow);
|
||||
}
|
||||
@@ -723,6 +786,42 @@ impl App {
|
||||
.is_some_and(EntryEditor::is_input_active)
|
||||
}
|
||||
|
||||
pub fn handle_workflow_input(
|
||||
&mut self,
|
||||
code: crossterm::event::KeyCode,
|
||||
modifiers: crossterm::event::KeyModifiers,
|
||||
) -> Option<AppEffect> {
|
||||
if self.mode != Mode::Dialog || self.workflow.is_none() {
|
||||
return None;
|
||||
}
|
||||
if self.workflow_pending {
|
||||
if code == crossterm::event::KeyCode::Esc {
|
||||
self.status = "Wait for the active workflow to finish".to_owned();
|
||||
}
|
||||
return Some(AppEffect::None);
|
||||
}
|
||||
let input = self.workflow.as_mut()?.handle_key(code, modifiers);
|
||||
match input {
|
||||
WorkflowInput::Consumed => Some(AppEffect::None),
|
||||
WorkflowInput::Cancel => {
|
||||
self.cancel_workflow();
|
||||
Some(AppEffect::None)
|
||||
}
|
||||
WorkflowInput::Lock => Some(self.dispatch(Action::Lock)),
|
||||
WorkflowInput::Submit => match self.workflow.as_ref()?.submission() {
|
||||
Ok(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,
|
||||
@@ -893,6 +992,19 @@ impl App {
|
||||
self.status = message;
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request @ CommandRequest::Init(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => {
|
||||
self.transition(Transition::Dismiss);
|
||||
let workflow = match &request {
|
||||
CommandRequest::Init(_) => WorkflowAction::Initialize,
|
||||
CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
|
||||
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
self.open_workflow(workflow, Some(request));
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request) => {
|
||||
let operation = operation_name(&request);
|
||||
self.transition(Transition::Dismiss);
|
||||
@@ -929,6 +1041,53 @@ impl App {
|
||||
}
|
||||
}
|
||||
|
||||
fn open_workflow(&mut self, workflow: WorkflowAction, request: Option<CommandRequest>) {
|
||||
let form = match (workflow, request) {
|
||||
(WorkflowAction::Initialize, Some(CommandRequest::Init(request))) => {
|
||||
WorkflowForm::init(
|
||||
&self.available_keys,
|
||||
self.default_key.as_ref(),
|
||||
Some(request),
|
||||
)
|
||||
}
|
||||
(WorkflowAction::Initialize, None) => {
|
||||
WorkflowForm::init(&self.available_keys, self.default_key.as_ref(), None)
|
||||
}
|
||||
(WorkflowAction::InsertEntry, Some(CommandRequest::Insert(request))) => {
|
||||
WorkflowForm::insert(Some(request))
|
||||
}
|
||||
(WorkflowAction::InsertEntry, None) => WorkflowForm::insert(None),
|
||||
(WorkflowAction::GenerateEntry, Some(CommandRequest::Generate(request))) => {
|
||||
WorkflowForm::generate(Some(request))
|
||||
}
|
||||
(WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None),
|
||||
_ => 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 confirm_command(&mut self) -> Option<CommandRequest> {
|
||||
if self.mode != Mode::Dialog {
|
||||
return None;
|
||||
@@ -1235,6 +1394,8 @@ impl App {
|
||||
self.command_help = None;
|
||||
self.command_open_target = None;
|
||||
self.editor_generation_pending = None;
|
||||
self.workflow = None;
|
||||
self.workflow_pending = false;
|
||||
self.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -1498,11 +1659,10 @@ mod tests {
|
||||
#[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"));
|
||||
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),
|
||||
@@ -1511,6 +1671,34 @@ mod tests {
|
||||
assert!(app.status().contains("entry removal"));
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
Reference in New Issue
Block a user