Implement TUI search and mutation workflows
This commit is contained in:
@@ -10,7 +10,7 @@ use ironstorage::{
|
||||
crypto::KeyInfo,
|
||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||
presentation::ClipboardDisposition,
|
||||
read::{FindResults, TreeModel},
|
||||
read::{FindResults, GrepResults, TreeModel},
|
||||
repository::SecretBytes,
|
||||
write::WriteOutcome,
|
||||
};
|
||||
@@ -19,6 +19,7 @@ use crate::{
|
||||
action::{Action, WorkflowAction},
|
||||
command::{CommandInvocation, CommandLine, operation_name},
|
||||
editor::EntryEditor,
|
||||
search::GrepView,
|
||||
sidebar::{Sidebar, SidebarIntent},
|
||||
viewer::EntryViewer,
|
||||
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
|
||||
@@ -71,8 +72,10 @@ pub struct StartupData {
|
||||
#[derive(Debug)]
|
||||
pub struct WorkflowSuccess {
|
||||
pub tree: Option<TreeModel>,
|
||||
pub selection: Option<(String, bool)>,
|
||||
pub entry: Option<String>,
|
||||
pub document: Option<Box<EntryDocument>>,
|
||||
pub grep: Option<GrepResults>,
|
||||
pub status: String,
|
||||
}
|
||||
|
||||
@@ -84,6 +87,10 @@ pub enum AsyncPayload {
|
||||
query: String,
|
||||
results: FindResults,
|
||||
},
|
||||
Found {
|
||||
query: String,
|
||||
results: FindResults,
|
||||
},
|
||||
DocumentLoaded {
|
||||
entry: String,
|
||||
document: Box<EntryDocument>,
|
||||
@@ -178,8 +185,6 @@ pub struct App {
|
||||
viewer: Option<EntryViewer>,
|
||||
editor: Option<EntryEditor>,
|
||||
discard_confirmation: bool,
|
||||
command_confirmation: Option<CommandRequest>,
|
||||
command_confirmation_message: Option<String>,
|
||||
command_line: CommandLine,
|
||||
command_help: Option<String>,
|
||||
command_open_target: Option<CommandOpenTarget>,
|
||||
@@ -187,6 +192,7 @@ pub struct App {
|
||||
authentication_pending: Option<String>,
|
||||
workflow: Option<WorkflowForm>,
|
||||
workflow_pending: bool,
|
||||
grep_view: Option<GrepView>,
|
||||
remaining_lease: Option<std::time::Duration>,
|
||||
terminal_size: (u16, u16),
|
||||
ticks: u64,
|
||||
@@ -217,8 +223,6 @@ impl App {
|
||||
viewer: None,
|
||||
editor: None,
|
||||
discard_confirmation: false,
|
||||
command_confirmation: None,
|
||||
command_confirmation_message: None,
|
||||
command_line: CommandLine::default(),
|
||||
command_help: None,
|
||||
command_open_target: None,
|
||||
@@ -226,6 +230,7 @@ impl App {
|
||||
authentication_pending: None,
|
||||
workflow: None,
|
||||
workflow_pending: false,
|
||||
grep_view: None,
|
||||
remaining_lease: None,
|
||||
terminal_size: (0, 0),
|
||||
ticks: 0,
|
||||
@@ -292,10 +297,6 @@ impl App {
|
||||
self.discard_confirmation
|
||||
}
|
||||
|
||||
pub fn command_confirmation_message(&self) -> Option<&str> {
|
||||
self.command_confirmation_message.as_deref()
|
||||
}
|
||||
|
||||
pub fn command_line(&self) -> &CommandLine {
|
||||
&self.command_line
|
||||
}
|
||||
@@ -316,6 +317,10 @@ impl App {
|
||||
self.workflow.as_ref()
|
||||
}
|
||||
|
||||
pub fn grep_view(&self) -> Option<&GrepView> {
|
||||
self.grep_view.as_ref()
|
||||
}
|
||||
|
||||
pub fn remaining_lease(&self) -> Option<std::time::Duration> {
|
||||
self.remaining_lease
|
||||
}
|
||||
@@ -381,6 +386,8 @@ impl App {
|
||||
}
|
||||
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 }) => {
|
||||
@@ -389,6 +396,17 @@ impl App {
|
||||
}
|
||||
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;
|
||||
@@ -491,19 +509,33 @@ impl App {
|
||||
}
|
||||
self.workflow = None;
|
||||
self.suspended_mode = None;
|
||||
if let (Some(entry), Some(document)) = (success.entry, success.document) {
|
||||
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);
|
||||
}
|
||||
self.status = success.status;
|
||||
}
|
||||
Err(error) => {
|
||||
@@ -556,12 +588,14 @@ impl App {
|
||||
self.transition(Transition::OpenCommand);
|
||||
}
|
||||
Action::Cancel => {
|
||||
if self.mode == Mode::Editor {
|
||||
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.command_confirmation.is_some() {
|
||||
self.cancel_command_confirmation();
|
||||
} else if self.mode == Mode::Dialog && self.workflow.is_some() {
|
||||
self.cancel_workflow();
|
||||
} else {
|
||||
@@ -580,6 +614,15 @@ impl App {
|
||||
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 => self.sidebar.move_next(),
|
||||
Action::Previous => self.sidebar.move_previous(),
|
||||
Action::PageDown => self.sidebar.page_down(),
|
||||
@@ -589,6 +632,22 @@ impl App {
|
||||
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()
|
||||
{
|
||||
@@ -606,7 +665,11 @@ impl App {
|
||||
return AppEffect::AuthenticateEntry(path);
|
||||
}
|
||||
}
|
||||
Action::Filter => self.sidebar.begin_filter(),
|
||||
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 => {
|
||||
@@ -739,15 +802,11 @@ impl App {
|
||||
Action::ConfirmDiscard => {
|
||||
if self.discard_confirmation {
|
||||
self.discard_editor();
|
||||
} else if let Some(request) = self.confirm_command() {
|
||||
return AppEffect::RunCommand(request);
|
||||
}
|
||||
}
|
||||
Action::KeepEditing => {
|
||||
if self.discard_confirmation {
|
||||
self.keep_editing();
|
||||
} else {
|
||||
self.cancel_command_confirmation();
|
||||
}
|
||||
}
|
||||
Action::Initialize
|
||||
@@ -765,6 +824,10 @@ impl App {
|
||||
WorkflowAction::Initialize
|
||||
| WorkflowAction::InsertEntry
|
||||
| WorkflowAction::GenerateEntry
|
||||
| WorkflowAction::Grep
|
||||
| WorkflowAction::RemoveEntry
|
||||
| WorkflowAction::MoveEntry
|
||||
| WorkflowAction::CopyEntry
|
||||
) {
|
||||
self.open_workflow(workflow, None);
|
||||
return AppEffect::None;
|
||||
@@ -980,26 +1043,22 @@ impl App {
|
||||
);
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request @ CommandRequest::Remove(_)) => {
|
||||
let message = format!(
|
||||
"Confirm {}? y runs it; n or Esc cancels",
|
||||
operation_name(&request)
|
||||
);
|
||||
self.transition(Transition::Dismiss);
|
||||
self.command_confirmation = Some(request);
|
||||
self.command_confirmation_message = Some(message.clone());
|
||||
self.transition(Transition::OpenDialog);
|
||||
self.status = message;
|
||||
AppEffect::None
|
||||
}
|
||||
CommandInvocation::Storage(request @ CommandRequest::Init(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Insert(_))
|
||||
| CommandInvocation::Storage(request @ CommandRequest::Generate(_)) => {
|
||||
| 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(_)) => {
|
||||
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,
|
||||
_ => unreachable!(),
|
||||
};
|
||||
self.open_workflow(workflow, Some(request));
|
||||
@@ -1036,6 +1095,8 @@ impl App {
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1061,6 +1122,43 @@ impl App {
|
||||
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::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);
|
||||
@@ -1088,27 +1186,6 @@ impl App {
|
||||
self.status = format!("Authentication failed: {message}. Form retained.");
|
||||
}
|
||||
|
||||
fn confirm_command(&mut self) -> Option<CommandRequest> {
|
||||
if self.mode != Mode::Dialog {
|
||||
return None;
|
||||
}
|
||||
let request = self.command_confirmation.take()?;
|
||||
self.command_confirmation_message = None;
|
||||
self.transition(Transition::Dismiss);
|
||||
self.status = format!("Starting confirmed {} workflow…", operation_name(&request));
|
||||
Some(request)
|
||||
}
|
||||
|
||||
fn cancel_command_confirmation(&mut self) {
|
||||
if self.mode != Mode::Dialog || self.command_confirmation.is_none() {
|
||||
return;
|
||||
}
|
||||
self.command_confirmation = None;
|
||||
self.command_confirmation_message = None;
|
||||
self.transition(Transition::Dismiss);
|
||||
self.status = "Destructive command cancelled".to_owned();
|
||||
}
|
||||
|
||||
fn editor_context_active(&self) -> bool {
|
||||
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
|
||||
}
|
||||
@@ -1284,6 +1361,7 @@ impl App {
|
||||
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 {
|
||||
@@ -1388,14 +1466,13 @@ impl App {
|
||||
self.viewer = None;
|
||||
self.editor = None;
|
||||
self.discard_confirmation = false;
|
||||
self.command_confirmation = None;
|
||||
self.command_confirmation_message = None;
|
||||
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.status = "Locked".to_owned();
|
||||
} else if current == Mode::Locked {
|
||||
self.status = "Authentication required".to_owned();
|
||||
@@ -1664,11 +1741,11 @@ mod tests {
|
||||
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::OpenWorkflow(WorkflowAction::RemoveEntry)
|
||||
));
|
||||
assert!(app.status().contains("entry removal"));
|
||||
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]
|
||||
@@ -1724,26 +1801,101 @@ mod tests {
|
||||
AppEffect::None
|
||||
));
|
||||
assert_eq!(app.mode(), Mode::Dialog);
|
||||
assert!(
|
||||
app.command_confirmation_message()
|
||||
.is_some_and(|message| message.contains("Confirm remove"))
|
||||
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.dispatch(Action::ConfirmDiscard),
|
||||
AppEffect::RunCommand(CommandRequest::Remove(request))
|
||||
if request.entry == "old/folder" && request.recursive
|
||||
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
|
||||
));
|
||||
assert!(matches!(app.dispatch(Action::KeepEditing), 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();
|
||||
|
||||
Reference in New Issue
Block a user