Implement TUI search and mutation workflows
This commit is contained in:
61
README.md
61
README.md
@@ -80,3 +80,64 @@ The Apple project is generated with XcodeGen:
|
||||
cd apple
|
||||
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.
|
||||
|
||||
@@ -164,7 +164,6 @@ const LOCKABLE: &[Mode] = &[
|
||||
Mode::Help,
|
||||
Mode::Command,
|
||||
];
|
||||
const EDITOR_AND_OVERLAYS: &[Mode] = &[Mode::Editor, Mode::Help, Mode::Command, Mode::Dialog];
|
||||
#[cfg(test)]
|
||||
const ALL: &[Mode] = &[
|
||||
Mode::Browser,
|
||||
@@ -232,7 +231,13 @@ pub static ACTIONS: &[ActionSpec] = &[
|
||||
label: "back",
|
||||
command: "cancel",
|
||||
bindings: keys!((KeyCode::Esc, KeyModifiers::NONE, "Esc")),
|
||||
modes: EDITOR_AND_OVERLAYS,
|
||||
modes: &[
|
||||
Mode::Browser,
|
||||
Mode::Editor,
|
||||
Mode::Help,
|
||||
Mode::Command,
|
||||
Mode::Dialog,
|
||||
],
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::Lock,
|
||||
@@ -507,7 +512,7 @@ pub static ACTIONS: &[ActionSpec] = &[
|
||||
label: "grep decrypted entries",
|
||||
command: "grep",
|
||||
bindings: keys!((KeyCode::Char('\\'), KeyModifiers::NONE, "\\")),
|
||||
modes: &[Mode::Browser],
|
||||
modes: BROWSER_LIKE,
|
||||
},
|
||||
ActionSpec {
|
||||
action: Action::RemoveEntry,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -8,6 +8,7 @@ pub mod app;
|
||||
pub mod command;
|
||||
pub mod editor;
|
||||
pub mod runtime;
|
||||
pub mod search;
|
||||
pub mod sidebar;
|
||||
pub mod terminal;
|
||||
pub mod ui;
|
||||
@@ -263,7 +264,29 @@ fn apply_app_effect(
|
||||
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> {
|
||||
use ironstorage::{
|
||||
generate::{GeneratorConfig, PasswordGenerator},
|
||||
git::{AutomaticEntryCommitter, AutomaticPolicyCommitter, GitIdentity},
|
||||
git::{
|
||||
AutomaticEntryCommitter, AutomaticPolicyCommitter, AutomaticTreeCommitter, GitIdentity,
|
||||
},
|
||||
mutation::TreeMutator,
|
||||
recipient::RecipientPolicyManager,
|
||||
write::VaultWriter,
|
||||
};
|
||||
@@ -383,6 +409,9 @@ fn execute_workflow(
|
||||
let keys = ironstorage::crypto::KeyStore::load(config.key_material())
|
||||
.map_err(|error| error.to_string())?;
|
||||
let identity = GitIdentity::ironstorage();
|
||||
let mut selection = None;
|
||||
let mut grep = None;
|
||||
let mut refresh_tree = true;
|
||||
let (entry, mut status) = match submission {
|
||||
WorkflowSubmission::Init(request) => {
|
||||
let directory = request.path.as_deref().unwrap_or_default();
|
||||
@@ -428,13 +457,81 @@ fn execute_workflow(
|
||||
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
|
||||
.then(|| {
|
||||
ironstorage::read::VaultReader::new(&repository, &keys)
|
||||
.list(&ironstorage::repository::DirectoryPath::root())
|
||||
.map_err(|error| {
|
||||
status.push_str(&format!("; sidebar refresh failed: {error}"));
|
||||
})
|
||||
.ok();
|
||||
.ok()
|
||||
})
|
||||
.flatten();
|
||||
let document = entry
|
||||
.as_deref()
|
||||
.map(|entry| {
|
||||
@@ -451,8 +548,10 @@ fn execute_workflow(
|
||||
.flatten();
|
||||
Ok(WorkflowSuccess {
|
||||
tree,
|
||||
selection,
|
||||
entry,
|
||||
document,
|
||||
grep,
|
||||
status,
|
||||
})
|
||||
}
|
||||
@@ -567,7 +666,8 @@ mod tests {
|
||||
|
||||
use ironstorage::{
|
||||
command::{
|
||||
GenerateRequest, GeneratedPresentation, InitRequest, InsertInput, InsertRequest,
|
||||
CopyRequest, GenerateRequest, GeneratedPresentation, GrepRequest, InitRequest,
|
||||
InsertInput, InsertRequest, MoveRequest, RemoveRequest,
|
||||
},
|
||||
crypto::{KeyInfo, SecretProvider, SecretProviderError},
|
||||
repository::SecretBytes,
|
||||
@@ -575,7 +675,7 @@ mod tests {
|
||||
};
|
||||
|
||||
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]
|
||||
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]
|
||||
fn write_workflows_use_storage_rules_refresh_and_commit_in_isolation() {
|
||||
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) {
|
||||
fs::create_dir_all(destination).expect("create destination");
|
||||
for entry in fs::read_dir(source).expect("read source") {
|
||||
|
||||
50
apps/tui/src/search.rs
Normal file
50
apps/tui/src/search.rs
Normal 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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -112,6 +112,12 @@ impl Sidebar {
|
||||
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) {
|
||||
let previous_index = self.selected_index().unwrap_or(0);
|
||||
self.filter_query.clear();
|
||||
|
||||
@@ -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)),
|
||||
};
|
||||
frame.render_widget(
|
||||
@@ -252,10 +255,6 @@ fn main_text(app: &App) -> String {
|
||||
Mode::Dialog if app.discard_confirmation() => {
|
||||
"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::Command => "Enter a command on the bottom line.".to_owned(),
|
||||
Mode::Help | Mode::Locked => String::new(),
|
||||
@@ -404,6 +403,37 @@ fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
|
||||
.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 {
|
||||
match mode {
|
||||
Mode::Browser => "Browser",
|
||||
@@ -437,6 +467,9 @@ fn context_line(app: &App) -> Paragraph<'static> {
|
||||
if app.mode() == Mode::Dialog && app.workflow().is_some() {
|
||||
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())
|
||||
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
|
||||
.collect::<Vec<_>>()
|
||||
@@ -448,9 +481,6 @@ fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||
match app.mode() {
|
||||
Mode::Command => Paragraph::new(format!(":{}", app.command_line().display()))
|
||||
.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() => {
|
||||
Paragraph::new("form> ").style(Style::default().fg(Color::Yellow))
|
||||
}
|
||||
@@ -589,8 +619,9 @@ mod tests {
|
||||
crossterm::event::KeyModifiers::NONE,
|
||||
);
|
||||
let confirmation = render(120, 24, &app);
|
||||
assert!(confirmation.contains("Confirm remove"));
|
||||
assert!(confirmation.contains("confirm> y / n / Esc"));
|
||||
assert!(confirmation.contains("Remove entry or folder"));
|
||||
assert!(confirmation.contains("Confirm permanent removal: no"));
|
||||
assert!(confirmation.contains("Ctrl-S submit"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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,
|
||||
..
|
||||
})
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -275,6 +275,36 @@ pub enum AutomaticPolicyCommitter {
|
||||
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 {
|
||||
pub fn for_directory(
|
||||
repository: &Repository,
|
||||
|
||||
@@ -84,6 +84,7 @@ impl TreeCommitter for NoGitTreeCommitter {
|
||||
pub struct MutationOutcome {
|
||||
action: MutationAction,
|
||||
entries: Vec<EntryPath>,
|
||||
selection: Option<MutationSelection>,
|
||||
}
|
||||
|
||||
impl MutationOutcome {
|
||||
@@ -93,6 +94,35 @@ impl MutationOutcome {
|
||||
pub fn entries(&self) -> &[EntryPath] {
|
||||
&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> {
|
||||
@@ -172,6 +202,7 @@ impl<'a> TreeMutator<'a> {
|
||||
Ok(MutationOutcome {
|
||||
action: MutationAction::Remove,
|
||||
entries: entries.into_iter().map(|entry| entry.source).collect(),
|
||||
selection: None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -507,9 +538,14 @@ impl<'a> TreeMutator<'a> {
|
||||
if moving {
|
||||
self.repository.cleanup_empty_directories(&source_root)?;
|
||||
}
|
||||
let selection = destination_entry.map_or_else(
|
||||
|| MutationSelection::Directory(destination_root),
|
||||
MutationSelection::Entry,
|
||||
);
|
||||
Ok(MutationOutcome {
|
||||
action,
|
||||
entries: entries.into_iter().map(|entry| entry.destination).collect(),
|
||||
selection: Some(selection),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -294,6 +294,10 @@ impl GrepResults {
|
||||
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.
|
||||
pub fn render_plain(&self) -> SecretBytes {
|
||||
let mut rendered = Vec::new();
|
||||
|
||||
@@ -8,9 +8,10 @@ use ironstorage::{
|
||||
command::{CopyRequest, MoveRequest, RemoveRequest},
|
||||
crypto::{KeyInfo, KeyStore, SecretProvider, SecretProviderError},
|
||||
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,
|
||||
};
|
||||
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 mutator = TreeMutator::new(&repository, &keys);
|
||||
let source = repository.read_entry(&EntryPath::parse("email/personal")?)?;
|
||||
mutator.copy(
|
||||
let outcome = mutator.copy(
|
||||
&CopyRequest {
|
||||
source: "email/personal".into(),
|
||||
destination: "team/personal".into(),
|
||||
@@ -135,6 +136,12 @@ fn copy_preserves_source_and_reencrypts_for_destination_policy() -> TestResult {
|
||||
&mut provider,
|
||||
&mut committer,
|
||||
)?;
|
||||
assert_eq!(
|
||||
outcome.selection(),
|
||||
Some(&MutationSelection::Entry(EntryPath::parse(
|
||||
"team/personal"
|
||||
)?))
|
||||
);
|
||||
assert_eq!(
|
||||
repository.read_entry(&EntryPath::parse("email/personal")?)?,
|
||||
source
|
||||
@@ -168,7 +175,7 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
|
||||
let mut provider = Secrets::all(&fixture);
|
||||
let mutator = TreeMutator::new(&repository, &keys);
|
||||
|
||||
mutator.copy(
|
||||
let copied = mutator.copy(
|
||||
&CopyRequest {
|
||||
source: "email/personal".into(),
|
||||
destination: "archive/".into(),
|
||||
@@ -179,9 +186,15 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
|
||||
&mut provider,
|
||||
&mut Committer::default(),
|
||||
)?;
|
||||
assert_eq!(
|
||||
copied.selection(),
|
||||
Some(&MutationSelection::Entry(EntryPath::parse(
|
||||
"archive/personal"
|
||||
)?))
|
||||
);
|
||||
assert!(store.path().join("archive/personal.gpg").is_file());
|
||||
|
||||
mutator.copy(
|
||||
let copied_directory = mutator.copy(
|
||||
&CopyRequest {
|
||||
source: "team/".into(),
|
||||
destination: "archive/".into(),
|
||||
@@ -192,6 +205,12 @@ fn existing_and_trailing_slash_destinations_have_directory_semantics() -> TestRe
|
||||
&mut provider,
|
||||
&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("team/service.gpg").is_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
|
||||
....`, 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
|
||||
entry and subtree mutations, recursive requirements, cancellation, forced
|
||||
overwrite, directory destinations, ambiguity, nested signed recipient
|
||||
|
||||
Reference in New Issue
Block a user