From ce3e4a9f79744d31e08d78b276430998af0bf47f Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Mon, 10 Aug 2026 10:11:34 +0000 Subject: [PATCH] Implement TUI search and mutation workflows --- README.md | 61 ++++ apps/tui/src/action.rs | 11 +- apps/tui/src/app.rs | 284 +++++++++++++----- apps/tui/src/lib.rs | 379 ++++++++++++++++++++++++- apps/tui/src/search.rs | 50 ++++ apps/tui/src/sidebar.rs | 6 + apps/tui/src/ui.rs | 49 +++- apps/tui/src/workflow.rs | 343 +++++++++++++++++++++- crates/storage/src/git.rs | 30 ++ crates/storage/src/mutation.rs | 36 +++ crates/storage/src/read.rs | 4 + crates/storage/tests/tree_mutations.rs | 29 +- docs/tree-mutations.md | 6 + 13 files changed, 1195 insertions(+), 93 deletions(-) create mode 100644 apps/tui/src/search.rs diff --git a/README.md b/README.md index 8da3e18..9da542c 100644 --- a/README.md +++ b/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. diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index a19e32d..11edf79 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -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, diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index 31e8191..91805e9 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -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, + pub selection: Option<(String, bool)>, pub entry: Option, pub document: Option>, + pub grep: Option, pub status: String, } @@ -84,6 +87,10 @@ pub enum AsyncPayload { query: String, results: FindResults, }, + Found { + query: String, + results: FindResults, + }, DocumentLoaded { entry: String, document: Box, @@ -178,8 +185,6 @@ pub struct App { viewer: Option, editor: Option, discard_confirmation: bool, - command_confirmation: Option, - command_confirmation_message: Option, command_line: CommandLine, command_help: Option, command_open_target: Option, @@ -187,6 +192,7 @@ pub struct App { authentication_pending: Option, workflow: Option, workflow_pending: bool, + grep_view: Option, remaining_lease: Option, 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 { 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 { - 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(); diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 940c06c..b2cd42f 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -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 { 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) - .list(&ironstorage::repository::DirectoryPath::root()) - .map_err(|error| { - status.push_str(&format!("; sidebar refresh failed: {error}")); + 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 { + 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") { diff --git a/apps/tui/src/search.rs b/apps/tui/src/search.rs new file mode 100644 index 0000000..157bb47 --- /dev/null +++ b/apps/tui/src/search.rs @@ -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, +} + +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 { + 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)), + ); + } + } +} diff --git a/apps/tui/src/sidebar.rs b/apps/tui/src/sidebar.rs index 6a00aab..6e6f7af 100644 --- a/apps/tui/src/sidebar.rs +++ b/apps/tui/src/sidebar.rs @@ -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(); diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index dadb209..d7e7b9c 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -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> { .collect() } +fn grep_lines(view: &crate::search::GrepView) -> Vec> { + 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::>() @@ -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] diff --git a/apps/tui/src/workflow.rs b/apps/tui/src/workflow.rs index 9cb1ee4..3be2f48 100644 --- a/apps/tui/src/workflow.rs +++ b/apps/tui/src/workflow.rs @@ -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) -> 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, 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, 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, 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, + .. + }) + )); + } } diff --git a/crates/storage/src/git.rs b/crates/storage/src/git.rs index 90e33bc..dd54129 100644 --- a/crates/storage/src/git.rs +++ b/crates/storage/src/git.rs @@ -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), + None(crate::mutation::NoGitTreeCommitter), +} + +impl AutomaticTreeCommitter { + pub fn for_source( + repository: &Repository, + source: &str, + identity: GitIdentity, + ) -> Result { + 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, diff --git a/crates/storage/src/mutation.rs b/crates/storage/src/mutation.rs index 0d37bd6..4869c9c 100644 --- a/crates/storage/src/mutation.rs +++ b/crates/storage/src/mutation.rs @@ -84,6 +84,7 @@ impl TreeCommitter for NoGitTreeCommitter { pub struct MutationOutcome { action: MutationAction, entries: Vec, + selection: Option, } 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), }) } diff --git a/crates/storage/src/read.rs b/crates/storage/src/read.rs index 102aa40..911157d 100644 --- a/crates/storage/src/read.rs +++ b/crates/storage/src/read.rs @@ -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(); diff --git a/crates/storage/tests/tree_mutations.rs b/crates/storage/tests/tree_mutations.rs index 99a4ac3..0b4f727 100644 --- a/crates/storage/tests/tree_mutations.rs +++ b/crates/storage/tests/tree_mutations.rs @@ -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()); diff --git a/docs/tree-mutations.md b/docs/tree-mutations.md index d90f504..27a4fb1 100644 --- a/docs/tree-mutations.md +++ b/docs/tree-mutations.md @@ -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