Implement TUI search and mutation workflows

This commit is contained in:
Hermes Agent
2026-08-10 10:11:34 +00:00
parent c86ea9eb0e
commit ce3e4a9f79
13 changed files with 1195 additions and 93 deletions

View File

@@ -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();