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

@@ -80,3 +80,64 @@ The Apple project is generated with XcodeGen:
cd apple cd apple
xcodegen generate xcodegen generate
``` ```
## Building
Install a stable Rust toolchain satisfying the workspace manifest (Rust 1.92
or newer) before building. Commands below run from the repository root unless
they explicitly change directory. Cargo places debug artifacts in
`target/debug/` and optimized artifacts in `target/release/`.
Build every Rust crate and application in debug or release mode:
```sh
cargo build --workspace
cargo build --workspace --release
```
Build one frontend at a time:
```sh
# pass-compatible CLI: target/{debug,release}/ironstorage
cargo build --package ironstorage-cli
cargo build --package ironstorage-cli --release
# Ratatui terminal UI: target/{debug,release}/ironstorage-tui
cargo build --package ironstorage-tui
cargo build --package ironstorage-tui --release
# Iced desktop UI: target/{debug,release}/ironstorage-desktop
cargo build --package ironstorage-desktop
cargo build --package ironstorage-desktop --release
```
Build only the shared storage library or the Rust Apple bridge:
```sh
cargo build --package ironstorage --lib
cargo build --package ironstorage --lib --release
cargo build --package ironstorage-apple --lib
cargo build --package ironstorage-apple --lib --release
```
The iPhone application, AutoFill extension, and watchOS companion require
macOS, Xcode, XcodeGen, and the Rust Apple targets used by
`apple/build_rust_core.bash`. Generate the Xcode project after cloning or after
changing `apple/project.yml`, then build the simulator configuration. The Xcode
pre-build phase selects the matching Rust debug or release profile.
```sh
cd apple
xcodegen generate
# Debug simulator build
xcodebuild -project IronStorage.xcodeproj -scheme IronStorage \
-configuration Debug -sdk iphonesimulator build CODE_SIGNING_ALLOWED=NO
# Release simulator build
xcodebuild -project IronStorage.xcodeproj -scheme IronStorage \
-configuration Release -sdk iphonesimulator build CODE_SIGNING_ALLOWED=NO
```
For a signed device or archive build, select the desired application target in
Xcode and configure the development team and signing identities there.

View File

@@ -164,7 +164,6 @@ const LOCKABLE: &[Mode] = &[
Mode::Help, Mode::Help,
Mode::Command, Mode::Command,
]; ];
const EDITOR_AND_OVERLAYS: &[Mode] = &[Mode::Editor, Mode::Help, Mode::Command, Mode::Dialog];
#[cfg(test)] #[cfg(test)]
const ALL: &[Mode] = &[ const ALL: &[Mode] = &[
Mode::Browser, Mode::Browser,
@@ -232,7 +231,13 @@ pub static ACTIONS: &[ActionSpec] = &[
label: "back", label: "back",
command: "cancel", command: "cancel",
bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")), bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
modes: EDITOR_AND_OVERLAYS, modes: &[
Mode::Browser,
Mode::Editor,
Mode::Help,
Mode::Command,
Mode::Dialog,
],
}, },
ActionSpec { ActionSpec {
action: Action::Lock, action: Action::Lock,
@@ -507,7 +512,7 @@ pub static ACTIONS: &[ActionSpec] = &[
label: "grep decrypted entries", label: "grep decrypted entries",
command: "grep", command: "grep",
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")), bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
modes: &[Mode::Browser], modes: BROWSER_LIKE,
}, },
ActionSpec { ActionSpec {
action: Action::RemoveEntry, action: Action::RemoveEntry,

View File

@@ -10,7 +10,7 @@ use ironstorage::{
crypto::KeyInfo, crypto::KeyInfo,
document::{DocumentError, EntryDocument, EntryFieldId}, document::{DocumentError, EntryDocument, EntryFieldId},
presentation::ClipboardDisposition, presentation::ClipboardDisposition,
read::{FindResults, TreeModel}, read::{FindResults, GrepResults, TreeModel},
repository::SecretBytes, repository::SecretBytes,
write::WriteOutcome, write::WriteOutcome,
}; };
@@ -19,6 +19,7 @@ use crate::{
action::{Action, WorkflowAction}, action::{Action, WorkflowAction},
command::{CommandInvocation, CommandLine, operation_name}, command::{CommandInvocation, CommandLine, operation_name},
editor::EntryEditor, editor::EntryEditor,
search::GrepView,
sidebar::{Sidebar, SidebarIntent}, sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer, viewer::EntryViewer,
workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission}, workflow::{WorkflowForm, WorkflowInput, WorkflowSubmission},
@@ -71,8 +72,10 @@ pub struct StartupData {
#[derive(Debug)] #[derive(Debug)]
pub struct WorkflowSuccess { pub struct WorkflowSuccess {
pub tree: Option<TreeModel>, pub tree: Option<TreeModel>,
pub selection: Option<(String, bool)>,
pub entry: Option<String>, pub entry: Option<String>,
pub document: Option<Box<EntryDocument>>, pub document: Option<Box<EntryDocument>>,
pub grep: Option<GrepResults>,
pub status: String, pub status: String,
} }
@@ -84,6 +87,10 @@ pub enum AsyncPayload {
query: String, query: String,
results: FindResults, results: FindResults,
}, },
Found {
query: String,
results: FindResults,
},
DocumentLoaded { DocumentLoaded {
entry: String, entry: String,
document: Box<EntryDocument>, document: Box<EntryDocument>,
@@ -178,8 +185,6 @@ pub struct App {
viewer: Option<EntryViewer>, viewer: Option<EntryViewer>,
editor: Option<EntryEditor>, editor: Option<EntryEditor>,
discard_confirmation: bool, discard_confirmation: bool,
command_confirmation: Option<CommandRequest>,
command_confirmation_message: Option<String>,
command_line: CommandLine, command_line: CommandLine,
command_help: Option<String>, command_help: Option<String>,
command_open_target: Option<CommandOpenTarget>, command_open_target: Option<CommandOpenTarget>,
@@ -187,6 +192,7 @@ pub struct App {
authentication_pending: Option<String>, authentication_pending: Option<String>,
workflow: Option<WorkflowForm>, workflow: Option<WorkflowForm>,
workflow_pending: bool, workflow_pending: bool,
grep_view: Option<GrepView>,
remaining_lease: Option<std::time::Duration>, remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16), terminal_size: (u16, u16),
ticks: u64, ticks: u64,
@@ -217,8 +223,6 @@ impl App {
viewer: None, viewer: None,
editor: None, editor: None,
discard_confirmation: false, discard_confirmation: false,
command_confirmation: None,
command_confirmation_message: None,
command_line: CommandLine::default(), command_line: CommandLine::default(),
command_help: None, command_help: None,
command_open_target: None, command_open_target: None,
@@ -226,6 +230,7 @@ impl App {
authentication_pending: None, authentication_pending: None,
workflow: None, workflow: None,
workflow_pending: false, workflow_pending: false,
grep_view: None,
remaining_lease: None, remaining_lease: None,
terminal_size: (0, 0), terminal_size: (0, 0),
ticks: 0, ticks: 0,
@@ -292,10 +297,6 @@ impl App {
self.discard_confirmation self.discard_confirmation
} }
pub fn command_confirmation_message(&self) -> Option<&str> {
self.command_confirmation_message.as_deref()
}
pub fn command_line(&self) -> &CommandLine { pub fn command_line(&self) -> &CommandLine {
&self.command_line &self.command_line
} }
@@ -316,6 +317,10 @@ impl App {
self.workflow.as_ref() 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> { pub fn remaining_lease(&self) -> Option<std::time::Duration> {
self.remaining_lease self.remaining_lease
} }
@@ -381,6 +386,8 @@ impl App {
} }
Ok(AsyncPayload::Refreshed(tree)) => { Ok(AsyncPayload::Refreshed(tree)) => {
self.sidebar.replace_tree(&tree); self.sidebar.replace_tree(&tree);
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
self.status = "Password store refreshed".to_owned(); self.status = "Password store refreshed".to_owned();
} }
Ok(AsyncPayload::Filtered { query, results }) => { Ok(AsyncPayload::Filtered { query, results }) => {
@@ -389,6 +396,17 @@ impl App {
} }
self.status = format!("Filter: {query} ({} matches)", results.matches().len()); 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 }) => { Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) { if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
return ResultDisposition::Stale; return ResultDisposition::Stale;
@@ -491,19 +509,33 @@ impl App {
} }
self.workflow = None; self.workflow = None;
self.suspended_mode = 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.selected_entry = Some(entry);
self.viewer = Some(EntryViewer::new(*document)); self.viewer = Some(EntryViewer::new(*document));
self.editor = None; self.editor = None;
self.grep_view = None;
self.mode = Mode::Viewer; self.mode = Mode::Viewer;
self.focus = PaneFocus::Main; self.focus = PaneFocus::Main;
} else { } else {
self.selected_entry = None; self.selected_entry = None;
self.viewer = None; self.viewer = None;
self.editor = None; self.editor = None;
self.grep_view = None;
self.mode = Mode::Browser; self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar; self.focus = PaneFocus::Sidebar;
} }
if let Some((path, directory)) = success.selection {
self.sidebar.select_path(&path, directory);
}
self.status = success.status; self.status = success.status;
} }
Err(error) => { Err(error) => {
@@ -556,12 +588,14 @@ impl App {
self.transition(Transition::OpenCommand); self.transition(Transition::OpenCommand);
} }
Action::Cancel => { 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(); self.cancel_editor();
} else if self.mode == Mode::Dialog && self.discard_confirmation { } else if self.mode == Mode::Dialog && self.discard_confirmation {
self.keep_editing(); 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() { } else if self.mode == Mode::Dialog && self.workflow.is_some() {
self.cancel_workflow(); self.cancel_workflow();
} else { } else {
@@ -580,6 +614,15 @@ impl App {
self.transition(Transition::Unlock); self.transition(Transition::Unlock);
} }
Action::Refresh => return AppEffect::RefreshTree, 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::Next => self.sidebar.move_next(),
Action::Previous => self.sidebar.move_previous(), Action::Previous => self.sidebar.move_previous(),
Action::PageDown => self.sidebar.page_down(), Action::PageDown => self.sidebar.page_down(),
@@ -589,6 +632,22 @@ impl App {
Action::Parent => self.sidebar.collapse_or_parent(), Action::Parent => self.sidebar.collapse_or_parent(),
Action::Child => self.sidebar.move_child(), Action::Child => self.sidebar.move_child(),
Action::Activate => { 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() if self.authentication_pending.is_none()
&& let SidebarIntent::OpenEntry(path) = self.sidebar.activate() && let SidebarIntent::OpenEntry(path) = self.sidebar.activate()
{ {
@@ -606,7 +665,11 @@ impl App {
return AppEffect::AuthenticateEntry(path); 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::NextMatch => self.sidebar.next_match(),
Action::PreviousMatch => self.sidebar.previous_match(), Action::PreviousMatch => self.sidebar.previous_match(),
Action::FocusNext if self.mode == Mode::Viewer => { Action::FocusNext if self.mode == Mode::Viewer => {
@@ -739,15 +802,11 @@ impl App {
Action::ConfirmDiscard => { Action::ConfirmDiscard => {
if self.discard_confirmation { if self.discard_confirmation {
self.discard_editor(); self.discard_editor();
} else if let Some(request) = self.confirm_command() {
return AppEffect::RunCommand(request);
} }
} }
Action::KeepEditing => { Action::KeepEditing => {
if self.discard_confirmation { if self.discard_confirmation {
self.keep_editing(); self.keep_editing();
} else {
self.cancel_command_confirmation();
} }
} }
Action::Initialize Action::Initialize
@@ -765,6 +824,10 @@ impl App {
WorkflowAction::Initialize WorkflowAction::Initialize
| WorkflowAction::InsertEntry | WorkflowAction::InsertEntry
| WorkflowAction::GenerateEntry | WorkflowAction::GenerateEntry
| WorkflowAction::Grep
| WorkflowAction::RemoveEntry
| WorkflowAction::MoveEntry
| WorkflowAction::CopyEntry
) { ) {
self.open_workflow(workflow, None); self.open_workflow(workflow, None);
return AppEffect::None; return AppEffect::None;
@@ -980,26 +1043,22 @@ impl App {
); );
AppEffect::None 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::Init(_))
| CommandInvocation::Storage(request @ CommandRequest::Insert(_)) | 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); self.transition(Transition::Dismiss);
let workflow = match &request { let workflow = match &request {
CommandRequest::Init(_) => WorkflowAction::Initialize, CommandRequest::Init(_) => WorkflowAction::Initialize,
CommandRequest::Insert(_) => WorkflowAction::InsertEntry, CommandRequest::Insert(_) => WorkflowAction::InsertEntry,
CommandRequest::Generate(_) => WorkflowAction::GenerateEntry, CommandRequest::Generate(_) => WorkflowAction::GenerateEntry,
CommandRequest::Grep(_) => WorkflowAction::Grep,
CommandRequest::Remove(_) => WorkflowAction::RemoveEntry,
CommandRequest::Move(_) => WorkflowAction::MoveEntry,
CommandRequest::Copy(_) => WorkflowAction::CopyEntry,
_ => unreachable!(), _ => unreachable!(),
}; };
self.open_workflow(workflow, Some(request)); self.open_workflow(workflow, Some(request));
@@ -1036,6 +1095,8 @@ impl App {
} }
fn close_to_browser(&mut self) { fn close_to_browser(&mut self) {
self.grep_view = None;
self.focus = PaneFocus::Sidebar;
if matches!(self.mode, Mode::Viewer | Mode::Editor) { if matches!(self.mode, Mode::Viewer | Mode::Editor) {
self.transition(Transition::CloseEntry); self.transition(Transition::CloseEntry);
} }
@@ -1061,6 +1122,43 @@ impl App {
WorkflowForm::generate(Some(request)) WorkflowForm::generate(Some(request))
} }
(WorkflowAction::GenerateEntry, None) => WorkflowForm::generate(None), (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, _ => return,
}; };
self.workflow = Some(form); self.workflow = Some(form);
@@ -1088,27 +1186,6 @@ impl App {
self.status = format!("Authentication failed: {message}. Form retained."); 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 { fn editor_context_active(&self) -> bool {
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor) self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
} }
@@ -1284,6 +1361,7 @@ impl App {
self.selected_entry = Some(entry); self.selected_entry = Some(entry);
self.viewer = None; self.viewer = None;
self.editor = None; self.editor = None;
self.grep_view = None;
self.status = "Authenticated; loading structured entry…".to_owned(); self.status = "Authenticated; loading structured entry…".to_owned();
let transitioned = self.transition(Transition::OpenEntry); let transitioned = self.transition(Transition::OpenEntry);
if transitioned { if transitioned {
@@ -1388,14 +1466,13 @@ impl App {
self.viewer = None; self.viewer = None;
self.editor = None; self.editor = None;
self.discard_confirmation = false; self.discard_confirmation = false;
self.command_confirmation = None;
self.command_confirmation_message = None;
self.command_line.clear(); self.command_line.clear();
self.command_help = None; self.command_help = None;
self.command_open_target = None; self.command_open_target = None;
self.editor_generation_pending = None; self.editor_generation_pending = None;
self.workflow = None; self.workflow = None;
self.workflow_pending = false; self.workflow_pending = false;
self.grep_view = None;
self.status = "Locked".to_owned(); self.status = "Locked".to_owned();
} else if current == Mode::Locked { } else if current == Mode::Locked {
self.status = "Authentication required".to_owned(); self.status = "Authentication required".to_owned();
@@ -1664,11 +1741,11 @@ mod tests {
assert!(app.workflow().is_some()); assert!(app.workflow().is_some());
app.dispatch(Action::Cancel); app.dispatch(Action::Cancel);
app.open_test_document("email/personal", fixture_document("email/personal")); app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(matches!( assert!(matches!(app.dispatch(Action::RemoveEntry), AppEffect::None));
app.dispatch(Action::RemoveEntry), assert_eq!(app.mode(), Mode::Dialog);
AppEffect::OpenWorkflow(WorkflowAction::RemoveEntry) let rows = app.workflow().expect("remove form").rows().join("\n");
)); assert!(rows.contains("email/personal"));
assert!(app.status().contains("entry removal")); assert!(rows.contains("Confirm permanent removal: no"));
} }
#[test] #[test]
@@ -1724,26 +1801,101 @@ mod tests {
AppEffect::None AppEffect::None
)); ));
assert_eq!(app.mode(), Mode::Dialog); assert_eq!(app.mode(), Mode::Dialog);
assert!( let rows = app.workflow().expect("remove form").rows().join("\n");
app.command_confirmation_message() assert!(rows.contains("old/folder"));
.is_some_and(|message| message.contains("Confirm remove")) 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!( assert!(matches!(
app.dispatch(Action::ConfirmDiscard), app.handle_workflow_input(
AppEffect::RunCommand(CommandRequest::Remove(request)) crossterm::event::KeyCode::Char('s'),
if request.entry == "old/folder" && request.recursive 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_eq!(app.mode(), Mode::Browser);
assert!(matches!( assert!(matches!(
enter_command(&mut app, "remove old/other"), enter_command(&mut app, "remove old/other"),
AppEffect::None 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_eq!(app.mode(), Mode::Browser);
assert!(app.status().contains("cancelled")); 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] #[test]
fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() { fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() {
let mut app = App::new(); let mut app = App::new();

View File

@@ -8,6 +8,7 @@ pub mod app;
pub mod command; pub mod command;
pub mod editor; pub mod editor;
pub mod runtime; pub mod runtime;
pub mod search;
pub mod sidebar; pub mod sidebar;
pub mod terminal; pub mod terminal;
pub mod ui; pub mod ui;
@@ -263,7 +264,29 @@ fn apply_app_effect(
app.report_status(format!("locked after secure-store cleanup failed: {error}")); app.report_status(format!("locked after secure-store cleanup failed: {error}"));
} }
} }
AppEffect::RunCommand(_) | AppEffect::OpenWorkflow(_) | AppEffect::None => {} AppEffect::RunCommand(ironstorage::command::CommandRequest::Find(request)) => {
if let Some(config) = app.config().cloned() {
let query = request.terms.join(" ");
let token = app.begin_latest_request();
executor.submit(token, move || {
let repository = ironstorage::repository::Repository::open(config.vault())
.map_err(|error| error.to_string())?;
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?;
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&request.terms)
.map_err(|error| error.to_string())?;
Ok(AsyncPayload::Found { query, results })
});
}
}
AppEffect::RunCommand(request) => {
app.report_status(format!(
"{} is not implemented by this terminal workflow yet",
crate::command::operation_name(&request)
));
}
AppEffect::OpenWorkflow(_) | AppEffect::None => {}
} }
} }
@@ -373,7 +396,10 @@ fn execute_workflow(
) -> Result<WorkflowSuccess, String> { ) -> Result<WorkflowSuccess, String> {
use ironstorage::{ use ironstorage::{
generate::{GeneratorConfig, PasswordGenerator}, generate::{GeneratorConfig, PasswordGenerator},
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity}, git::{
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
},
mutation::TreeMutator,
recipient::RecipientPolicyManager, recipient::RecipientPolicyManager,
write::VaultWriter, write::VaultWriter,
}; };
@@ -383,6 +409,9 @@ fn execute_workflow(
let keys = ironstorage::crypto::KeyStore::load(config.key_material()) let keys = ironstorage::crypto::KeyStore::load(config.key_material())
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
let identity = GitIdentity::ironstorage(); let identity = GitIdentity::ironstorage();
let mut selection = None;
let mut grep = None;
let mut refresh_tree = true;
let (entry, mut status) = match submission { let (entry, mut status) = match submission {
WorkflowSubmission::Init(request) => { WorkflowSubmission::Init(request) => {
let directory = request.path.as_deref().unwrap_or_default(); let directory = request.path.as_deref().unwrap_or_default();
@@ -428,13 +457,81 @@ fn execute_workflow(
format!("Generated password for {entry}"), format!("Generated password for {entry}"),
) )
} }
WorkflowSubmission::Grep(request) => {
let results = ironstorage::read::VaultReader::new(&repository, &keys)
.grep(&request, provider)
.map_err(|error| error.to_string())?;
let count = results.entries().len();
grep = Some(results);
refresh_tree = false;
(
None,
if count == 0 {
"Decrypted search completed with no matches".to_owned()
} else {
format!("Decrypted search found {count} matching entries")
},
)
}
WorkflowSubmission::Remove(request) => {
let target = request.entry.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &target, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.remove(
&request,
ironstorage::write::OverwriteDecision::Allow,
&mut committer,
)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(
None,
format!(
"Removed {target} ({} encrypted entries)",
outcome.entries().len()
),
)
}
WorkflowSubmission::Move { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.move_tree(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Moved {source} to {destination}"))
}
WorkflowSubmission::Copy { request, overwrite } => {
let source = request.source.clone();
let destination = request.destination.clone();
let mut committer = AutomaticTreeCommitter::for_source(&repository, &source, identity)
.map_err(|error| error.to_string())?;
let outcome = TreeMutator::new(&repository, &keys)
.copy(&request, overwrite, None, provider, &mut committer)
.map_err(|error| error.to_string())?;
selection = outcome
.selection()
.map(|selection| (selection.display_path(), selection.is_directory()));
(None, format!("Copied {source} to {destination}"))
}
}; };
let tree = ironstorage::read::VaultReader::new(&repository, &keys) let tree = refresh_tree
.list(&ironstorage::repository::DirectoryPath::root()) .then(|| {
.map_err(|error| { ironstorage::read::VaultReader::new(&repository, &keys)
status.push_str(&format!("; sidebar refresh failed: {error}")); .list(&ironstorage::repository::DirectoryPath::root())
.map_err(|error| {
status.push_str(&format!("; sidebar refresh failed: {error}"));
})
.ok()
}) })
.ok(); .flatten();
let document = entry let document = entry
.as_deref() .as_deref()
.map(|entry| { .map(|entry| {
@@ -451,8 +548,10 @@ fn execute_workflow(
.flatten(); .flatten();
Ok(WorkflowSuccess { Ok(WorkflowSuccess {
tree, tree,
selection,
entry, entry,
document, document,
grep,
status, status,
}) })
} }
@@ -567,7 +666,8 @@ mod tests {
use ironstorage::{ use ironstorage::{
command::{ command::{
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest, CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
}, },
crypto::{KeyInfo, SecretProvider, SecretProviderError}, crypto::{KeyInfo, SecretProvider, SecretProviderError},
repository::SecretBytes, repository::SecretBytes,
@@ -575,7 +675,7 @@ mod tests {
}; };
use super::*; use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from}; use crate::{action::Action, editor::EntryEditor, viewer::test_support::fixture_document_from};
#[test] #[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() { fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
@@ -646,6 +746,14 @@ mod tests {
} }
} }
struct UnavailableSecrets;
impl SecretProvider for UnavailableSecrets {
fn secret_for(&mut self, _key: &KeyInfo) -> Result<SecretBytes, SecretProviderError> {
Err(SecretProviderError::Unavailable)
}
}
#[test] #[test]
fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() { fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() {
let temporary = tempfile::tempdir().expect("temporary workflow store"); let temporary = tempfile::tempdir().expect("temporary workflow store");
@@ -790,6 +898,259 @@ mod tests {
); );
} }
#[test]
fn decrypted_search_handles_cancel_empty_failure_and_result_navigation() {
let temporary = tempfile::tempdir().expect("temporary search store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut app = App::new();
app.sidebar_mut()
.replace_tree(&load_tree(&config).expect("tree"));
let repository =
ironstorage::repository::Repository::open(config.vault()).expect("repository");
let keys =
ironstorage::crypto::KeyStore::load(config.key_material()).expect("key material");
let terms = vec!["personal".to_owned()];
let find = ironstorage::read::VaultReader::new(&repository, &keys)
.find(&terms)
.expect("name find");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::Found {
query: "personal".to_owned(),
results: find,
}),
}),
crate::app::ResultDisposition::Applied
);
assert_eq!(app.sidebar().filter_query(), "personal");
app.sidebar_mut().next_match();
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some("email/personal")
);
app.sidebar_mut().clear_filter_results();
assert!(matches!(app.dispatch(Action::Grep), AppEffect::None));
assert_eq!(app.mode(), crate::app::Mode::Dialog);
app.handle_workflow_input(
crossterm::event::KeyCode::Esc,
crossterm::event::KeyModifiers::NONE,
);
assert_eq!(app.mode(), crate::app::Mode::Browser);
assert!(app.status().contains("cancelled"));
let request = |pattern: &str| GrepRequest {
pattern: pattern.to_owned(),
ignore_case: false,
invert_match: false,
line_number: true,
fixed_strings: true,
};
let empty = execute_workflow(
&config,
WorkflowSubmission::Grep(request("not-present-anywhere")),
&mut FixtureSecrets,
)
.expect("empty grep");
assert!(
empty
.grep
.as_ref()
.is_some_and(|results| results.is_empty())
);
assert!(empty.status.contains("no matches"));
let failure = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut UnavailableSecrets,
)
.expect_err("decryption must need a secret");
assert!(!failure.contains("fixture-alice-passphrase"));
let found = execute_workflow(
&config,
WorkflowSubmission::Grep(request("password")),
&mut FixtureSecrets,
)
.expect("matching grep");
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(AsyncPayload::WorkflowFinished(Ok(Box::new(found)))),
}),
crate::app::ResultDisposition::Applied
);
let first = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("first result")
.path()
.to_string();
app.dispatch(Action::Next);
let second = app
.grep_view()
.and_then(crate::search::GrepView::selected_entry)
.expect("second result")
.path()
.to_string();
assert_ne!(first, second);
assert!(matches!(
app.dispatch(Action::Activate),
AppEffect::AuthenticateEntry(ref entry) if entry == &second
));
assert_eq!(
app.sidebar().selected().map(|selected| selected.path()),
Some(second.as_str())
);
assert!(app.grep_view().is_none());
}
#[test]
fn existing_tree_mutations_commit_refresh_and_preserve_failed_collisions() {
let temporary = tempfile::tempdir().expect("temporary mutation store");
let fixtures = Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../crates/storage/tests/fixtures/compatibility");
let store = temporary.path().join("store");
copy_directory(&fixtures.join("stores/basic"), &store);
let repository = ironstorage::repository::Repository::open(&store).expect("repository");
let identity = ironstorage::git::GitIdentity::ironstorage();
let git = ironstorage::git::GitRepository::init(&repository, identity.clone())
.expect("initialize git");
let initial_commits = git.log(None).expect("initial log").len();
drop(git);
let config_path = temporary.path().join("config.toml");
fs::write(
&config_path,
format!(
"vault = {:?}\ndefault_key = \"7E5C5241B25F6FFAAD717EBFA132DCB2DC23AE30\"\nkey_material = {:?}\n",
store,
fixtures.join("keys"),
),
)
.expect("write config");
let config = ironstorage::config::Config::load(Some(&config_path)).expect("config");
let mut secrets = FixtureSecrets;
let copied = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/copied".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy across recipient boundary");
assert_eq!(copied.selection, Some(("team/copied".to_owned(), false)));
assert!(copied.tree.is_some());
assert_eq!(
fixture_document_from(&store, "team/copied")
.password()
.expect("copied password")
.value(),
b"correct horse fixture"
);
let collision_ciphertext =
fs::read(store.join("team/service.gpg")).expect("collision ciphertext");
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
let commits_before_collision = git.log(None).expect("log").len();
drop(git);
let collision = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "email/personal".to_owned(),
destination: "team/service".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
);
assert!(collision.is_err());
assert_eq!(
fs::read(store.join("team/service.gpg")).expect("unchanged collision"),
collision_ciphertext
);
let git =
ironstorage::git::GitRepository::open(&repository, identity.clone()).expect("open git");
assert_eq!(git.log(None).expect("log").len(), commits_before_collision);
drop(git);
let moved = execute_workflow(
&config,
WorkflowSubmission::Move {
request: MoveRequest {
source: "team/copied".to_owned(),
destination: "shared/".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("move using explicit destination directory semantics");
assert_eq!(moved.selection, Some(("shared/copied".to_owned(), false)));
assert!(store.join("shared/copied.gpg").is_file());
assert!(!store.join("team/copied.gpg").exists());
let copied_directory = execute_workflow(
&config,
WorkflowSubmission::Copy {
request: CopyRequest {
source: "team".to_owned(),
destination: "archive".to_owned(),
force: false,
},
overwrite: OverwriteDecision::Decline,
},
&mut secrets,
)
.expect("copy folder");
assert_eq!(
copied_directory.selection,
Some(("archive".to_owned(), true))
);
assert!(store.join("archive/service.gpg").is_file());
execute_workflow(
&config,
WorkflowSubmission::Remove(RemoveRequest {
entry: "archive".to_owned(),
recursive: true,
force: false,
}),
&mut secrets,
)
.expect("recursive remove");
assert!(!store.join("archive").exists());
let git = ironstorage::git::GitRepository::open(&repository, identity).expect("open git");
assert_eq!(git.log(None).expect("log").len(), initial_commits + 4);
}
fn copy_directory(source: &Path, destination: &Path) { fn copy_directory(source: &Path, destination: &Path) {
fs::create_dir_all(destination).expect("create destination"); fs::create_dir_all(destination).expect("create destination");
for entry in fs::read_dir(source).expect("read source") { for entry in fs::read_dir(source).expect("read source") {

50
apps/tui/src/search.rs Normal file
View File

@@ -0,0 +1,50 @@
//! Presentation state for storage-provided decrypted search results.
use ironstorage::read::{GrepEntry, GrepResults};
#[derive(Debug)]
pub struct GrepView {
results: GrepResults,
selected: Option<usize>,
}
impl GrepView {
pub fn new(results: GrepResults) -> Self {
let selected = (!results.entries().is_empty()).then_some(0);
Self { results, selected }
}
pub fn entries(&self) -> &[GrepEntry] {
self.results.entries()
}
pub fn selected_index(&self) -> Option<usize> {
self.selected
}
pub fn selected_entry(&self) -> Option<&GrepEntry> {
self.selected
.and_then(|index| self.results.entries().get(index))
}
pub fn includes_line_numbers(&self) -> bool {
self.results.includes_line_numbers()
}
pub fn next(&mut self) {
let count = self.results.entries().len();
if count != 0 {
self.selected = Some(self.selected.map_or(0, |index| (index + 1) % count));
}
}
pub fn previous(&mut self) {
let count = self.results.entries().len();
if count != 0 {
self.selected = Some(
self.selected
.map_or(0, |index| index.checked_sub(1).unwrap_or(count - 1)),
);
}
}
}

View File

@@ -112,6 +112,12 @@ impl Sidebar {
true true
} }
pub fn apply_find_results(&mut self, query: &str, results: &FindResults) {
self.filter_query = query.to_owned();
self.editing_filter = false;
let _applied = self.apply_filter_results(query, results);
}
pub fn clear_filter_results(&mut self) { pub fn clear_filter_results(&mut self) {
let previous_index = self.selected_index().unwrap_or(0); let previous_index = self.selected_index().unwrap_or(0);
self.filter_query.clear(); self.filter_query.clear();

View File

@@ -170,6 +170,9 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
)) ))
}, },
), ),
Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
app.grep_view().expect("checked decrypted search results"),
)),
_ => Paragraph::new(main_text(app)), _ => Paragraph::new(main_text(app)),
}; };
frame.render_widget( frame.render_widget(
@@ -252,10 +255,6 @@ fn main_text(app: &App) -> String {
Mode::Dialog if app.discard_confirmation() => { Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned() "Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
} }
Mode::Dialog if app.command_confirmation_message().is_some() => app
.command_confirmation_message()
.unwrap_or_default()
.to_owned(),
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(), Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(), Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(), Mode::Help | Mode::Locked => String::new(),
@@ -404,6 +403,37 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
.collect() .collect()
} }
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
if view.entries().is_empty() {
return vec![Line::from("No decrypted entries matched.")];
}
let mut lines = Vec::new();
for (index, entry) in view.entries().iter().enumerate() {
let selected = view.selected_index() == Some(index);
lines.push(Line::styled(
format!("{} {}", if selected { ">" } else { " " }, entry.path()),
if selected {
Style::default().bg(Color::Blue).fg(Color::White)
} else {
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD)
},
));
for matched in entry.lines() {
let contents = std::str::from_utf8(matched.contents().expose())
.unwrap_or("[non-UTF-8 matched line]");
let prefix = if view.includes_line_numbers() {
format!("{}: ", matched.number())
} else {
String::new()
};
lines.push(Line::raw(format!(" {prefix}{contents}")));
}
}
lines
}
fn mode_title(mode: Mode) -> &'static str { fn mode_title(mode: Mode) -> &'static str {
match mode { match mode {
Mode::Browser => "Browser", Mode::Browser => "Browser",
@@ -437,6 +467,9 @@ fn context_line(app: &App) -> Paragraph<'static> {
if app.mode() == Mode::Dialog && app.workflow().is_some() { if app.mode() == Mode::Dialog && app.workflow().is_some() {
return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel"); return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel");
} }
if app.grep_view().is_some() {
return Paragraph::new("j/k result Enter open entry Esc close decrypted results");
}
let text = context_actions(app.mode()) let text = context_actions(app.mode())
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label)) .map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>() .collect::<Vec<_>>()
@@ -448,9 +481,6 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
match app.mode() { match app.mode() {
Mode::Command => Paragraph::new(format!(":{}", app.command_line().display())) Mode::Command => Paragraph::new(format!(":{}", app.command_line().display()))
.style(Style::default().fg(Color::Yellow)), .style(Style::default().fg(Color::Yellow)),
Mode::Dialog if app.command_confirmation_message().is_some() => {
Paragraph::new("confirm> y / n / Esc").style(Style::default().fg(Color::Yellow))
}
Mode::Dialog if app.workflow().is_some() => { Mode::Dialog if app.workflow().is_some() => {
Paragraph::new("form> ").style(Style::default().fg(Color::Yellow)) Paragraph::new("form> ").style(Style::default().fg(Color::Yellow))
} }
@@ -589,8 +619,9 @@ mod tests {
crossterm::event::KeyModifiers::NONE, crossterm::event::KeyModifiers::NONE,
); );
let confirmation = render(120, 24, &app); let confirmation = render(120, 24, &app);
assert!(confirmation.contains("Confirm remove")); assert!(confirmation.contains("Remove entry or folder"));
assert!(confirmation.contains("confirm> y / n / Esc")); assert!(confirmation.contains("Confirm permanent removal: no"));
assert!(confirmation.contains("Ctrl-S submit"));
} }
#[test] #[test]

View File

@@ -4,7 +4,10 @@ use std::{fmt, num::NonZeroUsize};
use crossterm::event::{KeyCode, KeyModifiers}; use crossterm::event::{KeyCode, KeyModifiers};
use ironstorage::{ use ironstorage::{
command::{GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest}, command::{
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest, InsertInput,
InsertRequest, MoveRequest, RemoveRequest,
},
crypto::KeyInfo, crypto::KeyInfo,
write::{InsertContent, OverwriteDecision}, write::{InsertContent, OverwriteDecision},
}; };
@@ -104,11 +107,43 @@ pub struct GenerateForm {
focus: usize, 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)] #[derive(Debug)]
pub enum WorkflowForm { pub enum WorkflowForm {
Init(InitForm), Init(InitForm),
Insert(InsertForm), Insert(InsertForm),
Generate(GenerateForm), Generate(GenerateForm),
Grep(GrepForm),
Remove(RemoveForm),
Move(TransferForm),
Copy(TransferForm),
} }
#[derive(Debug)] #[derive(Debug)]
@@ -123,6 +158,16 @@ pub enum WorkflowSubmission {
request: GenerateRequest, request: GenerateRequest,
overwrite: OverwriteDecision, overwrite: OverwriteDecision,
}, },
Grep(GrepRequest),
Remove(RemoveRequest),
Move {
request: MoveRequest,
overwrite: OverwriteDecision,
},
Copy {
request: CopyRequest,
overwrite: OverwriteDecision,
},
} }
#[derive(Clone, Debug, Eq, PartialEq)] #[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 { pub fn title(&self) -> &'static str {
match self { match self {
Self::Init(_) => "Initialize recipients", Self::Init(_) => "Initialize recipients",
Self::Insert(_) => "Insert entry", Self::Insert(_) => "Insert entry",
Self::Generate(_) => "Generate password", 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 == 3, "Overwrite", yes_no(form.overwrite)),
row(form.focus == 4, "In place", yes_no(form.in_place)), 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 { match self {
Self::Init(form) => form.recipients.len() + 1, Self::Init(form) => form.recipients.len() + 1,
Self::Insert(_) | Self::Generate(_) => 5, 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::Init(form) => &mut form.focus,
Self::Insert(form) => &mut form.focus, Self::Insert(form) => &mut form.focus,
Self::Generate(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 = if forward {
(*focus + 1) % count (*focus + 1) % count
@@ -429,6 +620,17 @@ impl WorkflowForm {
form.overwrite = false; 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(' '), _ => self.character(' '),
} }
} }
@@ -460,6 +662,18 @@ impl WorkflowForm {
Self::Generate(form) if form.focus == 1 => { Self::Generate(form) if form.focus == 1 => {
form.length.pop(); 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() => { Self::Generate(form) if form.focus == 1 && character.is_ascii_digit() => {
form.length.push(character) 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 { fn row(focused: bool, label: &str, value: &str) -> String {
format!("{} {label}: {value}", if focused { ">" } else { " " }) format!("{} {label}: {value}", if focused { ">" } else { " " })
} }
@@ -567,4 +823,89 @@ mod tests {
_ => panic!("wrong submission"), _ => 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,
..
})
));
}
} }

View File

@@ -275,6 +275,36 @@ pub enum AutomaticPolicyCommitter {
None(crate::recipient::NoGitCommitter), None(crate::recipient::NoGitCommitter),
} }
/// Storage-owned selection of pass-compatible automatic Git commits for a
/// remove, move, or copy transaction.
pub enum AutomaticTreeCommitter {
Git(Box<GitRepository>),
None(crate::mutation::NoGitTreeCommitter),
}
impl AutomaticTreeCommitter {
pub fn for_source(
repository: &Repository,
source: &str,
identity: GitIdentity,
) -> Result<Self, GitError> {
match GitRepository::open_innermost(repository, Path::new(source), identity) {
Ok(git) => Ok(Self::Git(Box::new(git))),
Err(GitError::NotRepository) => Ok(Self::None(crate::mutation::NoGitTreeCommitter)),
Err(error) => Err(error),
}
}
}
impl TreeCommitter for AutomaticTreeCommitter {
fn commit(&mut self, change: &TreeCommit) -> Result<(), TreeCommitError> {
match self {
Self::Git(git) => TreeCommitter::commit(git.as_mut(), change),
Self::None(committer) => committer.commit(change),
}
}
}
impl AutomaticPolicyCommitter { impl AutomaticPolicyCommitter {
pub fn for_directory( pub fn for_directory(
repository: &Repository, repository: &Repository,

View File

@@ -84,6 +84,7 @@ impl TreeCommitter for NoGitTreeCommitter {
pub struct MutationOutcome { pub struct MutationOutcome {
action: MutationAction, action: MutationAction,
entries: Vec<EntryPath>, entries: Vec<EntryPath>,
selection: Option<MutationSelection>,
} }
impl MutationOutcome { impl MutationOutcome {
@@ -93,6 +94,35 @@ impl MutationOutcome {
pub fn entries(&self) -> &[EntryPath] { pub fn entries(&self) -> &[EntryPath] {
&self.entries &self.entries
} }
pub fn selection(&self) -> Option<&MutationSelection> {
self.selection.as_ref()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum MutationSelection {
Entry(EntryPath),
Directory(DirectoryPath),
}
impl MutationSelection {
pub fn path(&self) -> &std::path::Path {
match self {
Self::Entry(path) => path.as_path(),
Self::Directory(path) => path.as_path(),
}
}
pub fn is_directory(&self) -> bool {
matches!(self, Self::Directory(_))
}
pub fn display_path(&self) -> String {
match self {
Self::Entry(path) => path.to_string(),
Self::Directory(path) => path.to_string(),
}
}
} }
pub struct TreeMutator<'a> { pub struct TreeMutator<'a> {
@@ -172,6 +202,7 @@ impl<'a> TreeMutator<'a> {
Ok(MutationOutcome { Ok(MutationOutcome {
action: MutationAction::Remove, action: MutationAction::Remove,
entries: entries.into_iter().map(|entry| entry.source).collect(), entries: entries.into_iter().map(|entry| entry.source).collect(),
selection: None,
}) })
} }
@@ -507,9 +538,14 @@ impl<'a> TreeMutator<'a> {
if moving { if moving {
self.repository.cleanup_empty_directories(&source_root)?; self.repository.cleanup_empty_directories(&source_root)?;
} }
let selection = destination_entry.map_or_else(
|| MutationSelection::Directory(destination_root),
MutationSelection::Entry,
);
Ok(MutationOutcome { Ok(MutationOutcome {
action, action,
entries: entries.into_iter().map(|entry| entry.destination).collect(), entries: entries.into_iter().map(|entry| entry.destination).collect(),
selection: Some(selection),
}) })
} }

View File

@@ -294,6 +294,10 @@ impl GrepResults {
self.entries.is_empty() self.entries.is_empty()
} }
pub fn includes_line_numbers(&self) -> bool {
self.line_numbers
}
/// Render matched bytes without requiring the CLI to reconstruct entry semantics. /// Render matched bytes without requiring the CLI to reconstruct entry semantics.
pub fn render_plain(&self) -> SecretBytes { pub fn render_plain(&self) -> SecretBytes {
let mut rendered = Vec::new(); let mut rendered = Vec::new();

View File

@@ -8,9 +8,10 @@ use ironstorage::{
command::{CopyRequest, MoveRequest, RemoveRequest}, command::{CopyRequest, MoveRequest, RemoveRequest},
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError}, crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
mutation::{ mutation::{
MutationAction, MutationError, TreeCommit, TreeCommitError, TreeCommitter, TreeMutator, MutationAction, MutationError, MutationSelection, TreeCommit, TreeCommitError,
TreeCommitter, TreeMutator,
}, },
repository::{EntryPath, Repository, SecretBytes}, repository::{DirectoryPath, EntryPath, Repository, SecretBytes},
write::OverwriteDecision, write::OverwriteDecision,
}; };
use support::compatibility::{FixtureSet, TestResult}; use support::compatibility::{FixtureSet, TestResult};
@@ -124,7 +125,7 @@ fn copy_preserves_source_and_reencrypts_for_destination_policy() -> TestResult {
let mut committer = Committer::default(); let mut committer = Committer::default();
let mutator = TreeMutator::new(&repository, &keys); let mutator = TreeMutator::new(&repository, &keys);
let source = repository.read_entry(&EntryPath::parse("email/personal")?)?; let source = repository.read_entry(&EntryPath::parse("email/personal")?)?;
mutator.copy( let outcome = mutator.copy(
&CopyRequest { &CopyRequest {
source: "email/personal".into(), source: "email/personal".into(),
destination: "team/personal".into(), destination: "team/personal".into(),
@@ -135,6 +136,12 @@ fn copy_preserves_source_and_reencrypts_for_destination_policy() -> TestResult {
&mut provider, &mut provider,
&mut committer, &mut committer,
)?; )?;
assert_eq!(
outcome.selection(),
Some(&MutationSelection::Entry(EntryPath::parse(
"team/personal"
)?))
);
assert_eq!( assert_eq!(
repository.read_entry(&EntryPath::parse("email/personal")?)?, repository.read_entry(&EntryPath::parse("email/personal")?)?,
source source
@@ -168,7 +175,7 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
let mut provider = Secrets::all(&fixture); let mut provider = Secrets::all(&fixture);
let mutator = TreeMutator::new(&repository, &keys); let mutator = TreeMutator::new(&repository, &keys);
mutator.copy( let copied = mutator.copy(
&CopyRequest { &CopyRequest {
source: "email/personal".into(), source: "email/personal".into(),
destination: "archive/".into(), destination: "archive/".into(),
@@ -179,9 +186,15 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
&mut provider, &mut provider,
&mut Committer::default(), &mut Committer::default(),
)?; )?;
assert_eq!(
copied.selection(),
Some(&MutationSelection::Entry(EntryPath::parse(
"archive/personal"
)?))
);
assert!(store.path().join("archive/personal.gpg").is_file()); assert!(store.path().join("archive/personal.gpg").is_file());
mutator.copy( let copied_directory = mutator.copy(
&CopyRequest { &CopyRequest {
source: "team/".into(), source: "team/".into(),
destination: "archive/".into(), destination: "archive/".into(),
@@ -192,6 +205,12 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
&mut provider, &mut provider,
&mut Committer::default(), &mut Committer::default(),
)?; )?;
assert_eq!(
copied_directory.selection(),
Some(&MutationSelection::Directory(DirectoryPath::parse(
"archive/team"
)?))
);
assert!(store.path().join("archive/team/service.gpg").is_file()); assert!(store.path().join("archive/team/service.gpg").is_file());
assert!(store.path().join("team/service.gpg").is_file()); assert!(store.path().join("team/service.gpg").is_file());

View File

@@ -29,6 +29,12 @@ The commit callback must report failure only when it has not created a commit;
it receives the compatible intent `Remove ... from store.`, `Rename ... to it receives the compatible intent `Remove ... from store.`, `Rename ... to
....`, or `Copy ... to ....`. ....`, or `Copy ... to ....`.
`MutationOutcome` also returns a typed post-operation selection for transfers,
identifying the actual destination entry or directory after container
semantics have been applied. Presentation layers use this result to refresh
selection without reimplementing path or destination rules. Removals omit a
target so a refreshed tree can retain the nearest surviving row.
Compatibility tests materialize the shared upstream-format fixtures and cover Compatibility tests materialize the shared upstream-format fixtures and cover
entry and subtree mutations, recursive requirements, cancellation, forced entry and subtree mutations, recursive requirements, cancellation, forced
overwrite, directory destinations, ambiguity, nested signed recipient overwrite, directory destinations, ambiguity, nested signed recipient