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

@@ -4,7 +4,10 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{
command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest},
command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, RemoveRequest,
},
crypto::KeyInfo,
write::{InsertContent, OverwriteDecision},
};
@@ -104,11 +107,43 @@ pub struct GenerateForm {
focus: usize,
}
#[derive(Debug)]
pub struct GrepForm {
pattern: String,
ignore_case: bool,
invert_match: bool,
line_number: bool,
fixed_strings: bool,
focus: usize,
}
#[derive(Debug)]
pub struct RemoveForm {
target: String,
recursive: bool,
force: bool,
confirmed: bool,
focus: usize,
}
#[derive(Debug)]
pub struct TransferForm {
source: String,
destination: String,
destination_directory: bool,
overwrite: bool,
focus: usize,
}
#[derive(Debug)]
pub enum WorkflowForm {
Init(InitForm),
Insert(InsertForm),
Generate(GenerateForm),
Grep(GrepForm),
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
}
#[derive(Debug)]
@@ -123,6 +158,16 @@ pub enum WorkflowSubmission {
request: GenerateRequest,
overwrite: OverwriteDecision,
},
Grep(GrepRequest),
Remove(RemoveRequest),
Move {
request: MoveRequest,
overwrite: OverwriteDecision,
},
Copy {
request: CopyRequest,
overwrite: OverwriteDecision,
},
}
#[derive(Clone, Debug, Eq, PartialEq)]
@@ -215,11 +260,74 @@ impl WorkflowForm {
})
}
pub fn grep(request: Option<GrepRequest>) -> Self {
let request = request.unwrap_or(GrepRequest {
pattern: String::new(),
ignore_case: false,
invert_match: false,
line_number: true,
fixed_strings: false,
});
Self::Grep(GrepForm {
pattern: request.pattern,
ignore_case: request.ignore_case,
invert_match: request.invert_match,
line_number: request.line_number,
fixed_strings: request.fixed_strings,
focus: 0,
})
}
pub fn remove(request: Option<RemoveRequest>, selected: Option<(&str, bool)>) -> Self {
let request = request.unwrap_or_else(|| RemoveRequest {
entry: selected.map_or_else(String::new, |(path, _)| path.to_owned()),
recursive: selected.is_some_and(|(_, directory)| directory),
force: false,
});
Self::Remove(RemoveForm {
target: request.entry,
recursive: request.recursive,
force: request.force,
confirmed: false,
focus: 0,
})
}
pub fn move_entry(request: Option<MoveRequest>, selected: Option<&str>) -> Self {
let request = request.unwrap_or_else(|| MoveRequest {
source: selected.unwrap_or_default().to_owned(),
destination: String::new(),
force: false,
});
Self::Move(transfer_form(
request.source,
request.destination,
request.force,
))
}
pub fn copy_entry(request: Option<CopyRequest>, selected: Option<&str>) -> Self {
let request = request.unwrap_or_else(|| CopyRequest {
source: selected.unwrap_or_default().to_owned(),
destination: String::new(),
force: false,
});
Self::Copy(transfer_form(
request.source,
request.destination,
request.force,
))
}
pub fn title(&self) -> &'static str {
match self {
Self::Init(_) => "Initialize recipients",
Self::Insert(_) => "Insert entry",
Self::Generate(_) => "Generate password",
Self::Grep(_) => "Search decrypted entries",
Self::Remove(_) => "Remove entry or folder",
Self::Move(_) => "Move or rename",
Self::Copy(_) => "Copy entry or folder",
}
}
@@ -285,6 +393,37 @@ impl WorkflowForm {
row(form.focus == 3, "Overwrite", yes_no(form.overwrite)),
row(form.focus == 4, "In place", yes_no(form.in_place)),
],
Self::Grep(form) => vec![
row(form.focus == 0, "Pattern", &form.pattern),
row(form.focus == 1, "Ignore case", yes_no(form.ignore_case)),
row(form.focus == 2, "Invert match", yes_no(form.invert_match)),
row(form.focus == 3, "Line numbers", yes_no(form.line_number)),
row(form.focus == 4, "Fixed string", yes_no(form.fixed_strings)),
],
Self::Remove(form) => vec![
row(form.focus == 0, "Target", &form.target),
row(form.focus == 1, "Recursive folder", yes_no(form.recursive)),
row(form.focus == 2, "Force", yes_no(form.force)),
row(
form.focus == 3,
"Confirm permanent removal",
yes_no(form.confirmed),
),
],
Self::Move(form) | Self::Copy(form) => vec![
row(form.focus == 0, "Source", &form.source),
row(form.focus == 1, "Destination", &form.destination),
row(
form.focus == 2,
"Destination is existing directory",
yes_no(form.destination_directory),
),
row(
form.focus == 3,
"Overwrite collision",
yes_no(form.overwrite),
),
],
}
}
@@ -387,6 +526,53 @@ impl WorkflowForm {
},
})
}
Self::Grep(form) => {
if form.pattern.is_empty() {
return Err("Search pattern is required".to_owned());
}
Ok(WorkflowSubmission::Grep(GrepRequest {
pattern: form.pattern.clone(),
ignore_case: form.ignore_case,
invert_match: form.invert_match,
line_number: form.line_number,
fixed_strings: form.fixed_strings,
}))
}
Self::Remove(form) => {
if form.target.trim().is_empty() {
return Err("Removal target is required".to_owned());
}
if !form.confirmed {
return Err("Explicitly confirm permanent removal".to_owned());
}
Ok(WorkflowSubmission::Remove(RemoveRequest {
entry: form.target.trim().to_owned(),
recursive: form.recursive,
force: form.force,
}))
}
Self::Move(form) => {
let (source, destination, overwrite) = transfer_submission(form)?;
Ok(WorkflowSubmission::Move {
request: MoveRequest {
source,
destination,
force: form.overwrite,
},
overwrite,
})
}
Self::Copy(form) => {
let (source, destination, overwrite) = transfer_submission(form)?;
Ok(WorkflowSubmission::Copy {
request: CopyRequest {
source,
destination,
force: form.overwrite,
},
overwrite,
})
}
}
}
@@ -394,6 +580,8 @@ impl WorkflowForm {
match self {
Self::Init(form) => form.recipients.len() + 1,
Self::Insert(_) | Self::Generate(_) => 5,
Self::Grep(_) => 5,
Self::Remove(_) | Self::Move(_) | Self::Copy(_) => 4,
}
}
@@ -403,6 +591,9 @@ impl WorkflowForm {
Self::Init(form) => &mut form.focus,
Self::Insert(form) => &mut form.focus,
Self::Generate(form) => &mut form.focus,
Self::Grep(form) => &mut form.focus,
Self::Remove(form) => &mut form.focus,
Self::Move(form) | Self::Copy(form) => &mut form.focus,
};
*focus = if forward {
(*focus + 1) % count
@@ -429,6 +620,17 @@ impl WorkflowForm {
form.overwrite = false;
}
}
Self::Grep(form) if form.focus == 1 => form.ignore_case ^= true,
Self::Grep(form) if form.focus == 2 => form.invert_match ^= true,
Self::Grep(form) if form.focus == 3 => form.line_number ^= true,
Self::Grep(form) if form.focus == 4 => form.fixed_strings ^= true,
Self::Remove(form) if form.focus == 1 => form.recursive ^= true,
Self::Remove(form) if form.focus == 2 => form.force ^= true,
Self::Remove(form) if form.focus == 3 => form.confirmed ^= true,
Self::Move(form) | Self::Copy(form) if form.focus == 2 => {
form.destination_directory ^= true
}
Self::Move(form) | Self::Copy(form) if form.focus == 3 => form.overwrite ^= true,
_ => self.character(' '),
}
}
@@ -460,6 +662,18 @@ impl WorkflowForm {
Self::Generate(form) if form.focus == 1 => {
form.length.pop();
}
Self::Grep(form) if form.focus == 0 => {
form.pattern.pop();
}
Self::Remove(form) if form.focus == 0 => {
form.target.pop();
}
Self::Move(form) | Self::Copy(form) if form.focus == 0 => {
form.source.pop();
}
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.pop();
}
_ => {}
}
}
@@ -482,11 +696,53 @@ impl WorkflowForm {
Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => {
form.length.push(character)
}
Self::Grep(form) if form.focus == 0 => form.pattern.push(character),
Self::Remove(form) if form.focus == 0 => form.target.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 0 => form.source.push(character),
Self::Move(form) | Self::Copy(form) if form.focus == 1 => {
form.destination.push(character)
}
_ => {}
}
}
}
fn transfer_form(source: String, mut destination: String, overwrite: bool) -> TransferForm {
let destination_directory = destination.ends_with('/');
while destination.ends_with('/') {
destination.pop();
}
TransferForm {
source,
destination,
destination_directory,
overwrite,
focus: 0,
}
}
fn transfer_submission(form: &TransferForm) -> Result<(String, String, OverwriteDecision), String> {
if form.source.trim().is_empty() {
return Err("Source path is required".to_owned());
}
if form.destination.trim().is_empty() {
return Err("Destination path is required".to_owned());
}
let mut destination = form.destination.trim().to_owned();
if form.destination_directory {
destination.push('/');
}
Ok((
form.source.trim().to_owned(),
destination,
if form.overwrite {
OverwriteDecision::Allow
} else {
OverwriteDecision::Decline
},
))
}
fn row(focused: bool, label: &str, value: &str) -> String {
format!("{} {label}: {value}", if focused { ">" } else { " " })
}
@@ -567,4 +823,89 @@ mod tests {
_ => panic!("wrong submission"),
}
}
#[test]
fn search_and_mutation_forms_preserve_every_explicit_storage_choice() {
let grep = WorkflowForm::grep(Some(GrepRequest {
pattern: "literal.*value".to_owned(),
ignore_case: true,
invert_match: true,
line_number: true,
fixed_strings: true,
}));
assert!(matches!(
grep.submission(),
Ok(WorkflowSubmission::Grep(GrepRequest {
ignore_case: true,
invert_match: true,
line_number: true,
fixed_strings: true,
..
}))
));
let mut remove = WorkflowForm::remove(
Some(RemoveRequest {
entry: "nested/folder".to_owned(),
recursive: true,
force: true,
}),
None,
);
assert!(remove.submission().unwrap_err().contains("confirm"));
for _ in 0..3 {
remove.handle_key(KeyCode::Tab, KeyModifiers::NONE);
}
remove.handle_key(KeyCode::Char(' '), KeyModifiers::NONE);
assert!(matches!(
remove.submission(),
Ok(WorkflowSubmission::Remove(RemoveRequest {
recursive: true,
force: true,
..
}))
));
let copied = WorkflowForm::copy_entry(
Some(CopyRequest {
source: "source/entry".to_owned(),
destination: "existing/folder/".to_owned(),
force: true,
}),
None,
);
assert!(
copied
.rows()
.join("\n")
.contains("Destination is existing directory: yes")
);
assert!(matches!(
copied.submission(),
Ok(WorkflowSubmission::Copy {
request: CopyRequest {
ref destination,
force: true,
..
},
overwrite: OverwriteDecision::Allow,
}) if destination == "existing/folder/"
));
let moved = WorkflowForm::move_entry(
Some(MoveRequest {
source: "old".to_owned(),
destination: "new".to_owned(),
force: false,
}),
None,
);
assert!(matches!(
moved.submission(),
Ok(WorkflowSubmission::Move {
overwrite: OverwriteDecision::Decline,
..
})
));
}
}