diff --git a/apps/tui/COMMANDS.md b/apps/tui/COMMANDS.md new file mode 100644 index 0000000..e085ef3 --- /dev/null +++ b/apps/tui/COMMANDS.md @@ -0,0 +1,32 @@ +# TUI colon-command coverage + +The `:` prompt uses `ironstorage::command::parse_from`, so flags, aliases, and +validation are shared with the milestone-01 command contract. Quoting is parsed +in-process; no shell or CLI executable is started. Workflow-specific screens +consume the typed requests emitted by command mode. + +| Milestone-01 operation | TUI command | +| --- | --- | +| Initialize recipients | `:init GPG-ID…` | +| List/show | `:list [PATH]`, `:show [OPTIONS] [ENTRY]` | +| Find/grep | `:find TERM…`, `:grep [OPTIONS] PATTERN` | +| Insert/edit | `:insert [OPTIONS] ENTRY`, `:edit ENTRY` | +| Generate | `:generate [OPTIONS] ENTRY [LENGTH]` | +| Remove/move/copy | `:remove`, `:move`, `:copy` with their CLI arguments | +| Git init/status/log/diff/add/commit | `:git init`, `:git status`, `:git log`, `:git diff`, `:git add`, `:git commit` | +| Git remote/config | `:git remote …`, `:git config …` | +| Git fetch/pull/push/sync | `:git fetch`, `:git pull`, `:git push`, `:git sync` | +| OTP code | `:otp code [OPTIONS] ENTRY` | +| OTP insert/append | `:otp insert …`, `:otp append …` | +| OTP URI/validate | `:otp uri …`, `:otp validate URI` | +| OTP version | `:otp version` | +| Help/version | `:help [TOPIC]`, `:version` | +| TUI lease | `:lock`, `:unlock` | + +`:quit` is a TUI convenience. CLI shell-script generation is intentionally +unavailable inside an interactive TUI; Tab and Shift-Tab provide contextual +in-process completion instead. + +Remove requests always pass through a `y`/`n` confirmation before their typed +storage request is emitted. Command input containing OTP URIs, Git remote URLs, +or Git configuration values is omitted from history and masked while rendered. diff --git a/apps/tui/src/action.rs b/apps/tui/src/action.rs index 07ce3ad..a19e32d 100644 --- a/apps/tui/src/action.rs +++ b/apps/tui/src/action.rs @@ -154,6 +154,7 @@ impl ActionSpec { const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer]; const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer]; const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor]; +const COMMANDABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor, Mode::Locked]; const QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked]; const LOCKABLE: &[Mode] = &[ Mode::Browser, @@ -224,7 +225,7 @@ pub static ACTIONS: &[ActionSpec] = &[ label: "command", command: "command", bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")), - modes: UNLOCKED, + modes: COMMANDABLE, }, ActionSpec { action: Action::Cancel, diff --git a/apps/tui/src/app.rs b/apps/tui/src/app.rs index dc5bcdd..1e4e946 100644 --- a/apps/tui/src/app.rs +++ b/apps/tui/src/app.rs @@ -3,6 +3,9 @@ use std::collections::BTreeSet; use ironstorage::{ + command::{ + CommandRequest, OtpRequest, Presentation, help_text, otp_version_text, version_text, + }, config::Config, crypto::KeyInfo, document::{DocumentError, EntryDocument, EntryFieldId}, @@ -14,6 +17,7 @@ use ironstorage::{ use crate::{ action::{Action, WorkflowAction}, + command::{CommandInvocation, CommandLine, operation_name}, editor::EntryEditor, sidebar::{Sidebar, SidebarIntent}, viewer::EntryViewer, @@ -138,9 +142,16 @@ pub enum AppEffect { editor: Box, }, OpenWorkflow(WorkflowAction), + RunCommand(CommandRequest), ManualLock, } +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +enum CommandOpenTarget { + Viewer, + Editor, +} + #[derive(Debug)] pub struct App { mode: Mode, @@ -154,6 +165,11 @@ 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, editor_generation_pending: Option, authentication_pending: Option, remaining_lease: Option, @@ -185,6 +201,11 @@ 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, editor_generation_pending: None, authentication_pending: None, remaining_lease: None, @@ -209,6 +230,14 @@ impl App { } } + pub fn command_context_mode(&self) -> Mode { + if self.mode == Mode::Command { + self.suspended_mode.unwrap_or(Mode::Browser) + } else { + self.mode + } + } + pub fn focus(&self) -> PaneFocus { self.focus } @@ -245,6 +274,18 @@ 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 + } + + pub fn command_help(&self) -> Option<&str> { + self.command_help.as_deref() + } + pub fn default_key(&self) -> Option<&KeyInfo> { self.default_key.as_ref() } @@ -330,9 +371,16 @@ impl App { return ResultDisposition::Stale; } let field_count = document.fields().len(); - self.viewer = Some(EntryViewer::new(*document)); + if self.command_open_target.take() == Some(CommandOpenTarget::Editor) { + self.editor = Some(EntryEditor::new(*document)); + self.viewer = None; + self.mode = Mode::Editor; + self.status = format!("Editing {entry} ({field_count} fields)"); + } else { + self.viewer = Some(EntryViewer::new(*document)); + self.status = format!("Opened {entry} ({field_count} fields)"); + } self.focus = PaneFocus::Main; - self.status = format!("Opened {entry} ({field_count} fields)"); } Ok(AsyncPayload::ClipboardFinished(disposition)) => { self.status = match disposition { @@ -414,6 +462,7 @@ impl App { Err(error) => { self.status = error; self.editor_generation_pending = None; + self.command_open_target = None; if self.mode == Mode::Viewer && self.viewer.is_none() { self.mode = Mode::Browser; self.focus = PaneFocus::Sidebar; @@ -447,9 +496,11 @@ impl App { self.should_quit = true; } Action::Help => { + self.command_help = None; self.transition(Transition::OpenHelp); } Action::Command => { + self.command_line.clear(); self.transition(Transition::OpenCommand); } Action::Cancel => { @@ -457,7 +508,13 @@ impl App { 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::Command { + self.command_line.clear(); + self.status = "Command cancelled".to_owned(); + } self.transition(Transition::Dismiss); } } @@ -625,8 +682,20 @@ impl App { } } } - Action::ConfirmDiscard => self.discard_editor(), - Action::KeepEditing => self.keep_editing(), + 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 | Action::InsertEntry | Action::GenerateEntry @@ -654,6 +723,233 @@ impl App { .is_some_and(EntryEditor::is_input_active) } + pub fn handle_command_input( + &mut self, + code: crossterm::event::KeyCode, + modifiers: crossterm::event::KeyModifiers, + ) -> Option { + use crossterm::event::{KeyCode, KeyModifiers}; + + if self.mode != Mode::Command { + return None; + } + let control = modifiers.contains(KeyModifiers::CONTROL); + match (code, control) { + (KeyCode::Esc, _) => return Some(self.dispatch(Action::Cancel)), + (KeyCode::Enter, _) => return Some(self.submit_command()), + (KeyCode::Char('l' | 'z'), true) => return Some(self.dispatch(Action::Lock)), + (KeyCode::Tab, _) => { + let paths = self.sidebar.completion_paths(); + if !self.command_line.complete(&paths, false) { + self.status = "No command completion is available".to_owned(); + } + } + (KeyCode::BackTab, _) => { + let paths = self.sidebar.completion_paths(); + if !self.command_line.complete(&paths, true) { + self.status = "No command completion is available".to_owned(); + } + } + (KeyCode::Up, _) => self.command_line.previous_history(), + (KeyCode::Down, _) => self.command_line.next_history(), + (KeyCode::Left, _) => self.command_line.move_left(), + (KeyCode::Right, _) => self.command_line.move_right(), + (KeyCode::Home, _) | (KeyCode::Char('a'), true) => self.command_line.move_home(), + (KeyCode::End, _) | (KeyCode::Char('e'), true) => self.command_line.move_end(), + (KeyCode::Backspace, _) => self.command_line.backspace(), + (KeyCode::Delete, _) => self.command_line.delete(), + (KeyCode::Char(character), false) => self.command_line.insert(character), + _ => return Some(AppEffect::None), + } + Some(AppEffect::None) + } + + fn submit_command(&mut self) -> AppEffect { + let invocation = match self.command_line.submit() { + Ok(invocation) => invocation, + Err(error) => { + self.status = format!("Command error: {error}"); + return AppEffect::None; + } + }; + self.route_command(invocation) + } + + fn route_command(&mut self, invocation: CommandInvocation) -> AppEffect { + let context = self.command_context_mode(); + match invocation { + CommandInvocation::Display(text) => { + self.show_command_help(text); + AppEffect::None + } + CommandInvocation::Lock if context != Mode::Locked => { + self.transition(Transition::Dismiss); + self.transition(Transition::Lock); + AppEffect::ManualLock + } + CommandInvocation::Unlock if context == Mode::Locked => { + self.transition(Transition::Dismiss); + self.transition(Transition::Unlock); + AppEffect::None + } + CommandInvocation::Quit if context != Mode::Editor => { + self.transition(Transition::Dismiss); + self.should_quit = true; + AppEffect::None + } + CommandInvocation::Lock | CommandInvocation::Unlock | CommandInvocation::Quit => { + self.status = "Command is unavailable in the current mode".to_owned(); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Help { topic }) => { + self.show_command_help(help_text(topic)); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Version) => { + self.transition(Transition::Dismiss); + self.status = version_text().trim().to_owned(); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Help)) => { + self.show_command_help(help_text(Some(ironstorage::command::HelpTopic::Otp))); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Otp(OtpRequest::Version)) => { + self.transition(Transition::Dismiss); + self.status = format!("pass-otp {}", otp_version_text().trim()); + AppEffect::None + } + CommandInvocation::Storage(request) if context == Mode::Locked => { + self.status = format!( + "{} is unavailable while the password store is locked", + operation_name(&request) + ); + AppEffect::None + } + CommandInvocation::Storage(CommandRequest::Show(request)) + if request.presentation == Presentation::Terminal => + { + if context == Mode::Editor { + self.status = "show is unavailable while an editor is open".to_owned(); + return AppEffect::None; + } + self.transition(Transition::Dismiss); + if let Some(entry) = request.entry { + self.begin_command_open(entry, CommandOpenTarget::Viewer) + } else { + self.close_to_browser(); + self.status = "Showing the password-store root".to_owned(); + AppEffect::None + } + } + CommandInvocation::Storage(CommandRequest::Edit(request)) => { + if context == Mode::Editor { + if self.selected_entry.as_deref() == Some(&request.entry) { + self.transition(Transition::Dismiss); + self.status = format!("Already editing {}", request.entry); + } else { + self.status = "edit is unavailable while another editor is open".to_owned(); + } + return AppEffect::None; + } + self.transition(Transition::Dismiss); + self.begin_command_open(request.entry, CommandOpenTarget::Editor) + } + CommandInvocation::Storage(CommandRequest::List(request)) => { + if context == Mode::Editor { + self.status = "list is unavailable while an editor is open".to_owned(); + return AppEffect::None; + } + self.transition(Transition::Dismiss); + self.close_to_browser(); + if let Some(path) = request.path { + if self.sidebar.select_path(&path, true) { + self.status = format!("Selected directory {path}"); + } else { + self.status = + format!("Directory is unavailable in the loaded tree: {path}"); + } + } else { + self.status = "Showing the password-store root".to_owned(); + } + AppEffect::None + } + CommandInvocation::Storage(request) if context == Mode::Editor => { + self.status = format!( + "{} is unavailable while an editor is open", + operation_name(&request) + ); + 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) => { + let operation = operation_name(&request); + self.transition(Transition::Dismiss); + self.status = format!("Starting {operation} workflow…"); + AppEffect::RunCommand(request) + } + } + } + + fn show_command_help(&mut self, text: String) { + self.transition(Transition::Dismiss); + self.command_help = Some(text); + self.transition(Transition::OpenHelp); + self.status = "Command help; Esc returns".to_owned(); + } + + fn begin_command_open(&mut self, entry: String, target: CommandOpenTarget) -> AppEffect { + if self.selected_entry.as_deref() == Some(&entry) && self.mode == Mode::Viewer { + if target == CommandOpenTarget::Editor { + return self.dispatch(Action::EditEntry); + } + self.status = format!("Already viewing {entry}"); + return AppEffect::None; + } + self.authentication_pending = Some(entry.clone()); + self.command_open_target = Some(target); + self.status = format!("Authenticating to open {entry}…"); + AppEffect::AuthenticateEntry(entry) + } + + fn close_to_browser(&mut self) { + if matches!(self.mode, Mode::Viewer | Mode::Editor) { + self.transition(Transition::CloseEntry); + } + } + + 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) } @@ -844,6 +1140,7 @@ impl App { self.editor = None; self.discard_confirmation = false; self.editor_generation_pending = None; + self.command_open_target = None; self.status = message; if self.mode != Mode::Browser { self.mode = Mode::Browser; @@ -880,10 +1177,13 @@ impl App { (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => { Some(Mode::Dialog) } - (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenHelp) => Some(Mode::Help), - (Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenCommand) => { - Some(Mode::Command) + (Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, Transition::OpenHelp) => { + Some(Mode::Help) } + ( + Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, + Transition::OpenCommand, + ) => Some(Mode::Command), (Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => { self.suspended_mode.take() } @@ -929,6 +1229,11 @@ 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.status = "Locked".to_owned(); } else if current == Mode::Locked { @@ -972,6 +1277,24 @@ mod tests { use super::*; use crate::viewer::test_support::fixture_document; + fn enter_command(app: &mut App, command: &str) -> AppEffect { + assert!(matches!(app.dispatch(Action::Command), AppEffect::None)); + for character in command.chars() { + assert!(matches!( + app.handle_command_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ), + Some(AppEffect::None) + )); + } + app.handle_command_input( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ) + .expect("command mode consumes Enter") + } + #[test] fn every_legal_transition_reaches_its_destination() { let cases = [ @@ -1187,4 +1510,85 @@ mod tests { )); assert!(app.status().contains("entry removal")); } + + #[test] + fn colon_show_and_edit_route_to_authenticated_tui_panes() { + let mut app = App::new(); + assert!(matches!( + enter_command(&mut app, "show 'email/personal account'"), + AppEffect::AuthenticateEntry(entry) if entry == "email/personal account" + )); + assert_eq!(app.mode(), Mode::Browser); + + app.open_test_document("email/personal", fixture_document("email/personal")); + assert!(matches!( + enter_command(&mut app, "edit email/personal"), + AppEffect::None + )); + assert_eq!(app.mode(), Mode::Editor); + } + + #[test] + fn destructive_colon_commands_require_confirmation_and_can_be_cancelled() { + let mut app = App::new(); + assert!(matches!( + enter_command(&mut app, "remove -r old/folder"), + AppEffect::None + )); + assert_eq!(app.mode(), Mode::Dialog); + assert!( + app.command_confirmation_message() + .is_some_and(|message| message.contains("Confirm remove")) + ); + assert!(matches!( + app.dispatch(Action::ConfirmDiscard), + AppEffect::RunCommand(CommandRequest::Remove(request)) + if request.entry == "old/folder" && request.recursive + )); + 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)); + assert_eq!(app.mode(), Mode::Browser); + assert!(app.status().contains("cancelled")); + } + + #[test] + fn command_mode_cancellation_unavailable_actions_and_unlock_are_explicit() { + let mut app = App::new(); + app.open_test_document("email/personal", fixture_document("email/personal")); + app.dispatch(Action::EditEntry); + assert_eq!(app.mode(), Mode::Editor); + assert!(matches!( + enter_command(&mut app, "show another/entry"), + AppEffect::None + )); + assert_eq!(app.mode(), Mode::Command); + assert!(app.status().contains("unavailable while an editor is open")); + app.dispatch(Action::Cancel); + assert_eq!(app.mode(), Mode::Editor); + + app.forced_relock("test"); + assert_eq!(app.mode(), Mode::Locked); + assert!(matches!(enter_command(&mut app, "unlock"), AppEffect::None)); + assert_eq!(app.mode(), Mode::Browser); + } + + #[test] + fn secret_bearing_commands_never_enter_history_or_status() { + let mut app = App::new(); + let secret = "NEVER-RENDER-THIS"; + assert!(matches!( + enter_command( + &mut app, + &format!("otp validate otpauth://totp/test?secret={secret}"), + ), + AppEffect::RunCommand(CommandRequest::Otp(OtpRequest::Validate { .. })) + )); + assert!(app.command_line().history().is_empty()); + assert!(!app.status().contains(secret)); + } } diff --git a/apps/tui/src/command.rs b/apps/tui/src/command.rs new file mode 100644 index 0000000..0b290e3 --- /dev/null +++ b/apps/tui/src/command.rs @@ -0,0 +1,826 @@ +//! Editable, shell-free `:` command input over the shared storage command contract. + +use std::{error::Error, fmt}; + +use ironstorage::command::{CliAction, CommandRequest, GitRequest, OtpRequest, parse_from}; + +const HISTORY_LIMIT: usize = 100; + +const ROOT_COMMANDS: &[&str] = &[ + "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", + "git", "otp", "lock", "unlock", "help", "version", "quit", +]; +const GIT_COMMANDS: &[&str] = &[ + "init", "status", "log", "diff", "add", "commit", "remote", "config", "fetch", "pull", "push", + "sync", +]; +const GIT_REMOTE_COMMANDS: &[&str] = &["get-url", "add", "set-url", "remove"]; +const OTP_COMMANDS: &[&str] = &["code", "insert", "append", "uri", "validate", "version"]; +const HELP_TOPICS: &[&str] = &[ + "init", "list", "show", "find", "grep", "insert", "edit", "generate", "remove", "move", "copy", + "git", "otp", +]; + +/// Auditable mapping from every milestone-01 command family to the TUI command surface. +pub const COMMAND_COVERAGE: &[CommandCoverage] = &[ + coverage("initialize recipients", ":init GPG-ID…"), + coverage("list a directory", ":list [PATH]"), + coverage("show an entry", ":show [OPTIONS] [ENTRY]"), + coverage("find names", ":find TERM…"), + coverage("grep decrypted entries", ":grep [OPTIONS] PATTERN"), + coverage("insert an entry", ":insert [OPTIONS] ENTRY"), + coverage("edit an entry", ":edit ENTRY"), + coverage("generate a password", ":generate [OPTIONS] ENTRY [LENGTH]"), + coverage("remove entries or directories", ":remove [OPTIONS] ENTRY"), + coverage( + "move entries or directories", + ":move [OPTIONS] SOURCE DESTINATION", + ), + coverage( + "copy entries or directories", + ":copy [OPTIONS] SOURCE DESTINATION", + ), + coverage("initialize Git", ":git init"), + coverage("show Git status", ":git status"), + coverage("show Git log", ":git log [OPTIONS]"), + coverage("show Git diff", ":git diff [PATH]…"), + coverage("stage Git paths", ":git add PATH…"), + coverage("create a Git commit", ":git commit -m MESSAGE"), + coverage("manage Git remotes", ":git remote [COMMAND]"), + coverage( + "manage Git configuration", + ":git config (--get KEY | KEY VALUE)", + ), + coverage("fetch Git remote", ":git fetch [REMOTE]"), + coverage("pull Git remote", ":git pull [REMOTE] [BRANCH]"), + coverage("push Git remote", ":git push [REMOTE] [BRANCH]"), + coverage("synchronize Git remote", ":git sync [REMOTE]"), + coverage("generate an OTP code", ":otp code [OPTIONS] ENTRY"), + coverage("insert an OTP entry", ":otp insert [OPTIONS] [ENTRY]"), + coverage("append OTP data", ":otp append [OPTIONS] ENTRY"), + coverage("present an OTP URI", ":otp uri [OPTIONS] ENTRY"), + coverage("validate an OTP URI", ":otp validate URI"), + coverage("show pass-otp version", ":otp version"), + coverage("show help", ":help [TOPIC]"), + coverage("show version", ":version"), + coverage("lock the TUI", ":lock"), + coverage("unlock the TUI", ":unlock"), +]; + +const fn coverage(operation: &'static str, command: &'static str) -> CommandCoverage { + CommandCoverage { operation, command } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub struct CommandCoverage { + pub operation: &'static str, + pub command: &'static str, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub enum CommandInvocation { + Storage(CommandRequest), + Display(String), + Lock, + Unlock, + Quit, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct CommandError { + message: String, +} + +impl CommandError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } +} + +impl fmt::Display for CommandError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + formatter.write_str(&self.message) + } +} + +impl Error for CommandError {} + +#[derive(Clone, Debug, Default, Eq, PartialEq)] +pub struct CommandLine { + input: String, + cursor: usize, + history: Vec, + history_position: Option, + history_draft: String, + completion: Option, +} + +#[derive(Clone, Debug, Eq, PartialEq)] +struct CompletionCycle { + start: usize, + end: usize, + candidates: Vec, + selected: usize, +} + +impl CommandLine { + pub fn input(&self) -> &str { + &self.input + } + + pub fn cursor(&self) -> usize { + self.cursor + } + + pub fn history(&self) -> &[String] { + &self.history + } + + pub fn display(&self) -> String { + if is_secret_bearing(&self.input) { + let command = tokenize(&self.input) + .ok() + .and_then(|tokens| { + let keep = if tokens.first().is_some_and(|token| token == "otp") { + 2 + } else { + 1 + }; + (!tokens.is_empty()).then(|| { + format!( + "{} [hidden secret-bearing arguments]", + tokens[..tokens.len().min(keep)].join(" ") + ) + }) + }) + .unwrap_or_else(|| "[hidden secret-bearing command]".to_owned()); + return command; + } + let mut displayed = self.input.clone(); + displayed.insert(self.cursor, '▏'); + displayed + } + + pub fn clear(&mut self) { + self.input.clear(); + self.cursor = 0; + self.history_position = None; + self.history_draft.clear(); + self.completion = None; + } + + pub fn insert(&mut self, character: char) { + if character.is_control() { + return; + } + self.reset_navigation(); + self.input.insert(self.cursor, character); + self.cursor += character.len_utf8(); + } + + pub fn backspace(&mut self) { + self.reset_navigation(); + if let Some(previous) = previous_boundary(&self.input, self.cursor) { + self.input.drain(previous..self.cursor); + self.cursor = previous; + } + } + + pub fn delete(&mut self) { + self.reset_navigation(); + if let Some(next) = next_boundary(&self.input, self.cursor) { + self.input.drain(self.cursor..next); + } + } + + pub fn move_left(&mut self) { + self.completion = None; + if let Some(previous) = previous_boundary(&self.input, self.cursor) { + self.cursor = previous; + } + } + + pub fn move_right(&mut self) { + self.completion = None; + if let Some(next) = next_boundary(&self.input, self.cursor) { + self.cursor = next; + } + } + + pub fn move_home(&mut self) { + self.completion = None; + self.cursor = 0; + } + + pub fn move_end(&mut self) { + self.completion = None; + self.cursor = self.input.len(); + } + + pub fn previous_history(&mut self) { + self.completion = None; + if self.history.is_empty() { + return; + } + let position = match self.history_position { + None => { + self.history_draft.clone_from(&self.input); + self.history.len() - 1 + } + Some(position) => position.saturating_sub(1), + }; + self.history_position = Some(position); + self.input.clone_from(&self.history[position]); + self.cursor = self.input.len(); + } + + pub fn next_history(&mut self) { + self.completion = None; + let Some(position) = self.history_position else { + return; + }; + if position + 1 < self.history.len() { + self.history_position = Some(position + 1); + self.input.clone_from(&self.history[position + 1]); + } else { + self.history_position = None; + self.input.clone_from(&self.history_draft); + } + self.cursor = self.input.len(); + } + + pub fn complete(&mut self, paths: &[String], reverse: bool) -> bool { + if let Some(cycle) = self.completion.as_mut() { + cycle.selected = if reverse { + cycle + .selected + .checked_sub(1) + .unwrap_or(cycle.candidates.len() - 1) + } else { + (cycle.selected + 1) % cycle.candidates.len() + }; + let replacement = cycle.candidates[cycle.selected].clone(); + self.input + .replace_range(cycle.start..cycle.end, &replacement); + cycle.end = cycle.start + replacement.len(); + self.cursor = cycle.end; + return true; + } + + let fragment = completion_fragment(&self.input, self.cursor); + let preceding = tokenize(&self.input[..fragment.start]).unwrap_or_default(); + let candidates = completion_candidates(&preceding, &fragment.value, paths) + .into_iter() + .map(|candidate| quote_argument(&candidate)) + .collect::>(); + if candidates.is_empty() { + return false; + } + let selected = if reverse { candidates.len() - 1 } else { 0 }; + let replacement = if candidates.len() == 1 { + format!("{} ", candidates[0]) + } else { + candidates[selected].clone() + }; + self.input + .replace_range(fragment.start..fragment.end, &replacement); + self.cursor = fragment.start + replacement.len(); + if candidates.len() > 1 { + self.completion = Some(CompletionCycle { + start: fragment.start, + end: self.cursor, + candidates, + selected, + }); + } + true + } + + pub fn submit(&mut self) -> Result { + let raw = self.input.trim().to_owned(); + let result = parse_command(&raw); + if !raw.is_empty() && !is_secret_bearing(&raw) && self.history.last() != Some(&raw) { + self.history.push(raw); + if self.history.len() > HISTORY_LIMIT { + self.history.remove(0); + } + } + self.clear(); + result + } + + fn reset_navigation(&mut self) { + self.history_position = None; + self.history_draft.clear(); + self.completion = None; + } +} + +pub fn parse_command(input: &str) -> Result { + let input = input.trim().strip_prefix(':').unwrap_or(input.trim()); + if input.is_empty() { + return Err(CommandError::new("command is empty")); + } + let sensitive = is_secret_bearing(input); + let tokens = tokenize(input).map_err(|error| { + if sensitive { + CommandError::new("invalid secret-bearing command; arguments omitted") + } else { + error + } + })?; + let Some(command) = tokens.first().map(String::as_str) else { + return Err(CommandError::new("command is empty")); + }; + let builtin = match command { + "lock" => Some(CommandInvocation::Lock), + "unlock" => Some(CommandInvocation::Unlock), + "quit" | "q" => Some(CommandInvocation::Quit), + _ => None, + }; + if let Some(invocation) = builtin { + if tokens.len() != 1 { + return Err(CommandError::new(format!( + ":{command} does not accept arguments" + ))); + } + return Ok(invocation); + } + + let arguments = std::iter::once("ironstorage".to_owned()).chain(tokens); + match parse_from(arguments) { + Ok(CliAction::Display(text)) => Ok(CommandInvocation::Display(text)), + Ok(CliAction::Run(invocation)) => { + let (config, request) = invocation.into_parts(); + if config.is_some() { + return Err(CommandError::new( + "the TUI uses its loaded configuration; --config is unavailable in command mode", + )); + } + if matches!(request, CommandRequest::Completion { .. }) { + return Err(CommandError::new( + "shell completion scripts are unavailable inside the interactive TUI", + )); + } + Ok(CommandInvocation::Storage(request)) + } + Err(_error) if sensitive => Err(CommandError::new( + "invalid secret-bearing command; arguments omitted", + )), + Err(error) => Err(CommandError::new(error.to_string().trim().to_owned())), + } +} + +pub fn operation_name(request: &CommandRequest) -> &'static str { + match request { + CommandRequest::Init(_) => "initialization", + CommandRequest::List(_) => "list", + CommandRequest::Show(_) => "show", + CommandRequest::Find(_) => "find", + CommandRequest::Grep(_) => "grep", + CommandRequest::Insert(_) => "insert", + CommandRequest::Edit(_) => "edit", + CommandRequest::Generate(_) => "generate", + CommandRequest::Remove(_) => "remove", + CommandRequest::Move(_) => "move", + CommandRequest::Copy(_) => "copy", + CommandRequest::Git(request) => match request { + GitRequest::Init => "Git init", + GitRequest::Status => "Git status", + GitRequest::Log { .. } => "Git log", + GitRequest::Diff { .. } => "Git diff", + GitRequest::Add { .. } => "Git add", + GitRequest::Commit { .. } => "Git commit", + GitRequest::Remote(_) => "Git remote", + GitRequest::Config(_) => "Git config", + GitRequest::Fetch { .. } => "Git fetch", + GitRequest::Pull { .. } => "Git pull", + GitRequest::Push { .. } => "Git push", + GitRequest::Sync { .. } => "Git sync", + }, + CommandRequest::Otp(request) => match request { + OtpRequest::Code(_) => "OTP code", + OtpRequest::Insert(_) => "OTP insert", + OtpRequest::Append(_) => "OTP append", + OtpRequest::Uri(_) => "OTP URI", + OtpRequest::Validate { .. } => "OTP validation", + OtpRequest::Help => "OTP help", + OtpRequest::Version => "OTP version", + }, + CommandRequest::Completion { .. } => "completion", + CommandRequest::Help { .. } => "help", + CommandRequest::Version => "version", + } +} + +fn tokenize(input: &str) -> Result, CommandError> { + #[derive(Clone, Copy, Eq, PartialEq)] + enum Quote { + None, + Single, + Double, + } + + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut quote = Quote::None; + let mut escaped = false; + let mut started = false; + for character in input.chars() { + if escaped { + token.push(character); + escaped = false; + started = true; + continue; + } + match (quote, character) { + (Quote::None | Quote::Double, '\\') => { + escaped = true; + started = true; + } + (Quote::None, '\'') => { + quote = Quote::Single; + started = true; + } + (Quote::Single, '\'') => quote = Quote::None, + (Quote::None, '"') => { + quote = Quote::Double; + started = true; + } + (Quote::Double, '"') => quote = Quote::None, + (Quote::None, character) if character.is_whitespace() => { + if started { + tokens.push(std::mem::take(&mut token)); + started = false; + } + } + (_, character) => { + token.push(character); + started = true; + } + } + } + if escaped { + return Err(CommandError::new("command ends with an incomplete escape")); + } + if quote != Quote::None { + return Err(CommandError::new("command contains an unterminated quote")); + } + if started { + tokens.push(token); + } + Ok(tokens) +} + +#[derive(Debug)] +struct CompletionFragment { + start: usize, + end: usize, + value: String, +} + +fn completion_fragment(input: &str, cursor: usize) -> CompletionFragment { + let mut start = 0; + let mut quote = None; + let mut escaped = false; + for (index, character) in input[..cursor].char_indices() { + if escaped { + escaped = false; + continue; + } + match (quote, character) { + (Some('"'), '\\') | (None, '\\') => escaped = true, + (None, '\'' | '"') => quote = Some(character), + (Some(active), character) if active == character => quote = None, + (None, character) if character.is_whitespace() => { + start = index + character.len_utf8(); + } + _ => {} + } + } + let raw = &input[start..cursor]; + let value = tokenize(raw) + .ok() + .and_then(|mut tokens| tokens.pop()) + .unwrap_or_else(|| raw.trim_start_matches(['\'', '"']).replace("\\ ", " ")); + CompletionFragment { + start, + end: cursor, + value, + } +} + +fn completion_candidates(preceding: &[String], fragment: &str, paths: &[String]) -> Vec { + let candidates: &[&str] = match preceding { + [] => ROOT_COMMANDS, + [command] if command == "git" => GIT_COMMANDS, + [command, remote] if command == "git" && remote == "remote" => GIT_REMOTE_COMMANDS, + [command] if command == "otp" => OTP_COMMANDS, + [command] if command == "help" => HELP_TOPICS, + [command, ..] if command == "init" && fragment.starts_with('-') => &["--path"], + [command, ..] if command == "show" && fragment.starts_with('-') => &["--clip", "--qrcode"], + [command, ..] if command == "grep" && fragment.starts_with('-') => &[ + "--ignore-case", + "--invert-match", + "--line-number", + "--fixed-strings", + ], + [command, ..] if command == "generate" && fragment.starts_with('-') => &[ + "--no-symbols", + "--clip", + "--qrcode", + "--in-place", + "--force", + ], + [command, ..] if command == "insert" && fragment.starts_with('-') => { + &["--echo", "--multiline", "--force"] + } + [command, ..] if command == "remove" && fragment.starts_with('-') => { + &["--recursive", "--force"] + } + [command, ..] + if matches!(command.as_str(), "move" | "copy") && fragment.starts_with('-') => + { + &["--force"] + } + [git, subcommand, ..] + if git == "git" && subcommand == "log" && fragment.starts_with('-') => + { + &["--max-count"] + } + [git, subcommand, ..] + if git == "git" && subcommand == "commit" && fragment.starts_with('-') => + { + &["--message"] + } + [git, subcommand, ..] + if git == "git" && subcommand == "config" && fragment.starts_with('-') => + { + &["--get"] + } + [otp, subcommand, ..] + if otp == "otp" && subcommand == "code" && fragment.starts_with('-') => + { + &["--clip"] + } + [otp, subcommand, ..] + if otp == "otp" + && matches!(subcommand.as_str(), "insert" | "append") + && fragment.starts_with('-') => + { + &["--force", "--echo", "--secret", "--issuer", "--account"] + } + [otp, subcommand, ..] + if otp == "otp" && subcommand == "uri" && fragment.starts_with('-') => + { + &["--clip", "--qrcode"] + } + _ => &[], + }; + let mut matches = candidates + .iter() + .filter(|candidate| candidate.starts_with(fragment)) + .map(|candidate| (*candidate).to_owned()) + .collect::>(); + if candidates.is_empty() && command_accepts_path(preceding) { + matches.extend( + paths + .iter() + .filter(|path| path.starts_with(fragment)) + .cloned(), + ); + } + matches.sort(); + matches.dedup(); + matches +} + +fn command_accepts_path(tokens: &[String]) -> bool { + matches!( + tokens.first().map(String::as_str), + Some( + "list" + | "ls" + | "show" + | "insert" + | "edit" + | "generate" + | "remove" + | "rm" + | "move" + | "mv" + | "copy" + | "cp" + ) + ) || matches!( + tokens, + [command, subcommand, ..] + if (command == "otp" + && matches!(subcommand.as_str(), "code" | "show" | "insert" | "append" | "uri")) + || (command == "git" && matches!(subcommand.as_str(), "diff" | "add")) + ) +} + +fn quote_argument(argument: &str) -> String { + if !argument.is_empty() + && argument + .chars() + .all(|character| character.is_alphanumeric() || "/._-:@".contains(character)) + { + return argument.to_owned(); + } + format!("'{}'", argument.replace('\'', "'\\''")) +} + +fn is_secret_bearing(input: &str) -> bool { + let lowercase = input.to_ascii_lowercase(); + if lowercase.contains("otpauth://") { + return true; + } + let tokens = tokenize(input).unwrap_or_else(|_| { + input + .split_whitespace() + .map(str::to_owned) + .collect::>() + }); + matches!(tokens.as_slice(), [otp, validate, ..] if otp == "otp" && validate == "validate") + || matches!(tokens.as_slice(), [git, config, ..] if git == "git" && config == "config") + || matches!(tokens.as_slice(), [git, remote, action, ..] + if git == "git" && remote == "remote" && matches!(action.as_str(), "add" | "set-url")) +} + +fn previous_boundary(value: &str, cursor: usize) -> Option { + value[..cursor] + .char_indices() + .next_back() + .map(|(index, _)| index) +} + +fn next_boundary(value: &str, cursor: usize) -> Option { + value[cursor..].char_indices().nth(1).map_or_else( + || (cursor < value.len()).then_some(value.len()), + |(index, _)| Some(cursor + index), + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ironstorage::command::{GitRemoteRequest, RemoveRequest}; + + #[test] + fn quoting_and_paths_with_spaces_use_the_shared_request_parser() { + assert!(matches!( + parse_command("show 'team accounts/mail primary'"), + Ok(CommandInvocation::Storage(CommandRequest::Show(request))) + if request.entry.as_deref() == Some("team accounts/mail primary") + )); + assert!(matches!( + parse_command("git commit -m \"rotate signing key\""), + Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Commit { message }))) + if message == "rotate signing key" + )); + assert!(parse_command("show 'unfinished").is_err()); + } + + #[test] + fn editing_history_and_secret_omission_are_unicode_safe() { + let mut line = CommandLine::default(); + for character in "show café/mail".chars() { + line.insert(character); + } + line.move_left(); + line.backspace(); + line.insert('i'); + assert_eq!(line.input(), "show café/mail"); + assert!(line.submit().is_ok()); + assert_eq!(line.history(), &["show café/mail"]); + + for character in "otp validate otpauth://totp/a?secret=TOPSECRET".chars() { + line.insert(character); + } + assert!(!line.display().contains("TOPSECRET")); + assert!(line.submit().is_ok()); + assert_eq!(line.history(), &["show café/mail"]); + line.previous_history(); + assert_eq!(line.input(), "show café/mail"); + line.next_history(); + assert!(line.input().is_empty()); + } + + #[test] + fn completion_is_context_aware_and_quotes_paths() { + let paths = vec!["team accounts/mail primary".to_owned()]; + let mut line = CommandLine::default(); + for character in "gi".chars() { + line.insert(character); + } + assert!(line.complete(&paths, false)); + assert_eq!(line.input(), "git "); + for character in "pu".chars() { + line.insert(character); + } + assert!(line.complete(&paths, false)); + assert!(matches!(line.input(), "git pull" | "git push")); + + line.clear(); + for character in "show team".chars() { + line.insert(character); + } + assert!(line.complete(&paths, false)); + assert_eq!(line.input(), "show 'team accounts/mail primary' "); + } + + #[test] + fn invalid_flags_cancellation_inputs_and_unavailable_meta_actions_are_typed() { + assert!(parse_command("generate --clip --qrcode entry").is_err()); + assert!( + parse_command("completion bash") + .expect_err("interactive completion is unavailable") + .to_string() + .contains("unavailable") + ); + assert!(matches!( + parse_command(":lock"), + Ok(CommandInvocation::Lock) + )); + assert!(matches!( + parse_command(":unlock"), + Ok(CommandInvocation::Unlock) + )); + assert!(matches!( + parse_command(":quit"), + Ok(CommandInvocation::Quit) + )); + } + + #[test] + fn destructive_and_nested_requests_preserve_typed_storage_contracts() { + assert_eq!( + parse_command("remove -r folder"), + Ok(CommandInvocation::Storage(CommandRequest::Remove( + RemoveRequest { + entry: "folder".to_owned(), + recursive: true, + force: false, + } + ))) + ); + assert!(matches!( + parse_command("git remote set-url origin https://example.test/repo"), + Ok(CommandInvocation::Storage(CommandRequest::Git(GitRequest::Remote( + GitRemoteRequest::SetUrl { name, url } + )))) if name == "origin" && url == "https://example.test/repo" + )); + } + + #[test] + fn coverage_table_has_every_command_family_and_no_duplicates() { + assert!(COMMAND_COVERAGE.len() >= 33); + for (index, row) in COMMAND_COVERAGE.iter().enumerate() { + assert!(row.command.starts_with(':')); + assert!(!row.operation.is_empty()); + assert!(!COMMAND_COVERAGE[index + 1..].contains(row)); + } + for command in [ + ":init", + ":list", + ":show", + ":find", + ":grep", + ":insert", + ":edit", + ":generate", + ":remove", + ":move", + ":copy", + ":git", + ":otp", + ":lock", + ":unlock", + ":help", + ":version", + ] { + assert!( + COMMAND_COVERAGE + .iter() + .any(|row| row.command.starts_with(command)) + ); + } + } + + #[test] + fn command_mode_source_has_no_process_launch_boundary() { + let source = include_str!("command.rs"); + let process_type = ["process", "::Command"].concat(); + let constructor = ["Command", "::new("].concat(); + for forbidden in [process_type, constructor] { + assert!(!source.contains(&forbidden)); + } + for executable in ["pass ", "git ", "gpg ", "ironstorage "] { + assert!(!source.contains(&format!("{executable}--"))); + } + } +} diff --git a/apps/tui/src/lib.rs b/apps/tui/src/lib.rs index 4ebf7cc..3391a8e 100644 --- a/apps/tui/src/lib.rs +++ b/apps/tui/src/lib.rs @@ -5,6 +5,7 @@ pub mod action; pub mod app; +pub mod command; pub mod editor; pub mod runtime; pub mod sidebar; @@ -100,6 +101,17 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { { apply_authentication_event(&mut app, coordinator, &executor, event); } + if let Some(effect) = app.handle_command_input(key.code, key.modifiers) { + key_resolver.reset(); + apply_app_effect( + &mut app, + effect, + &mut authentication, + &executor, + &mut clipboard_cancellations, + ); + continue; + } if !key.modifiers.intersects( crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::ALT @@ -116,86 +128,16 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { } } else { match key_resolver.feed(app.mode(), key.code, key.modifiers) { - KeyResolution::Action(action) => match app.dispatch(action) { - AppEffect::RefreshTree => { - if let Some(config) = app.config().cloned() { - let token = app.begin_latest_request(); - executor.submit(token, move || { - load_tree(&config).map(AsyncPayload::Refreshed) - }); - } - } - AppEffect::AuthenticateEntry(entry) => { - if let Some(coordinator) = authentication.as_mut() { - coordinator.request(entry); - } else { - app.authentication_failed( - "operating-system secure storage is unavailable".to_owned(), - ); - } - } - AppEffect::CopyFocused(value) => { - if let Some(config) = app.config().cloned() { - let (cancel, cancellation) = mpsc::channel(); - clipboard_cancellations.register(cancel); - let token = app.begin_request(); - executor.submit(token, move || { - let mut clipboard = - ironstorage::presentation::NativeClipboardManager::system( - config.clipboard_timeout(), - ) - .map_err(|error| error.to_string())?; - clipboard - .copy_with(&value, |duration| { - match cancellation.recv_timeout(duration) { - Err(mpsc::RecvTimeoutError::Timeout) => { - ironstorage::presentation::ClipboardWait::Elapsed - } - Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { - ironstorage::presentation::ClipboardWait::Cancelled - } - } - }) - .map(AsyncPayload::ClipboardFinished) - .map_err(|error| error.to_string()) - }); - } - } - AppEffect::GenerateField(target) => { - let token = app.begin_request(); - executor.submit(token, move || { - ironstorage::generate::GeneratorConfig::pass_defaults() - .generate_secret(None, false) - .map(|password| AsyncPayload::GeneratedField { - target, - password, - }) - .map_err(|error| error.to_string()) - }); - } - AppEffect::SaveDocument { - config, - entry, - editor, - } => { - let token = app.begin_request(); - executor.submit(token, move || { - Ok(save_document(&config, entry, editor)) - }); - } - AppEffect::ManualLock => { - if let Some(coordinator) = authentication.as_mut() - && let Err(error) = coordinator.lock() - { - app.forced_relock("manual lock requested"); - app.report_status(format!( - "locked after secure-store cleanup failed: {error}" - )); - } - } - AppEffect::OpenWorkflow(_) => {} - AppEffect::None => {} - }, + KeyResolution::Action(action) => { + let effect = app.dispatch(action); + apply_app_effect( + &mut app, + effect, + &mut authentication, + &executor, + &mut clipboard_cancellations, + ); + } KeyResolution::Pending => { app.report_status("Key sequence pending; Esc cancels".to_owned()); } @@ -218,6 +160,86 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> { Ok(()) } +fn apply_app_effect( + app: &mut App, + effect: AppEffect, + authentication: &mut Option, + executor: &AsyncExecutor, + clipboard_cancellations: &mut ClipboardCancellations, +) { + match effect { + AppEffect::RefreshTree => { + if let Some(config) = app.config().cloned() { + let token = app.begin_latest_request(); + executor.submit(token, move || { + load_tree(&config).map(AsyncPayload::Refreshed) + }); + } + } + AppEffect::AuthenticateEntry(entry) => { + if let Some(coordinator) = authentication.as_mut() { + coordinator.request(entry); + } else { + app.authentication_failed( + "operating-system secure storage is unavailable".to_owned(), + ); + } + } + AppEffect::CopyFocused(value) => { + if let Some(config) = app.config().cloned() { + let (cancel, cancellation) = mpsc::channel(); + clipboard_cancellations.register(cancel); + let token = app.begin_request(); + executor.submit(token, move || { + let mut clipboard = ironstorage::presentation::NativeClipboardManager::system( + config.clipboard_timeout(), + ) + .map_err(|error| error.to_string())?; + clipboard + .copy_with(&value, |duration| { + match cancellation.recv_timeout(duration) { + Err(mpsc::RecvTimeoutError::Timeout) => { + ironstorage::presentation::ClipboardWait::Elapsed + } + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { + ironstorage::presentation::ClipboardWait::Cancelled + } + } + }) + .map(AsyncPayload::ClipboardFinished) + .map_err(|error| error.to_string()) + }); + } + } + AppEffect::GenerateField(target) => { + let token = app.begin_request(); + executor.submit(token, move || { + ironstorage::generate::GeneratorConfig::pass_defaults() + .generate_secret(None, false) + .map(|password| AsyncPayload::GeneratedField { target, password }) + .map_err(|error| error.to_string()) + }); + } + AppEffect::SaveDocument { + config, + entry, + editor, + } => { + let token = app.begin_request(); + executor.submit(token, move || Ok(save_document(&config, entry, editor))); + } + AppEffect::ManualLock => { + if let Some(coordinator) = authentication.as_mut() + && let Err(error) = coordinator.lock() + { + app.forced_relock("manual lock requested"); + app.report_status(format!("locked after secure-store cleanup failed: {error}")); + } + } + AppEffect::RunCommand(_) | AppEffect::OpenWorkflow(_) | AppEffect::None => {} + } +} + fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool { matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat) } diff --git a/apps/tui/src/sidebar.rs b/apps/tui/src/sidebar.rs index e25900b..6a00aab 100644 --- a/apps/tui/src/sidebar.rs +++ b/apps/tui/src/sidebar.rs @@ -160,6 +160,47 @@ impl Sidebar { self.selected.as_ref() } + pub fn select_path(&mut self, path: &str, directory: bool) -> bool { + let target = collect_ids(&self.full) + .into_iter() + .find(|id| id.path() == path && id.is_directory() == directory); + let Some(target) = target else { + return false; + }; + if !path.is_empty() { + let mut prefix = String::new(); + for component in path + .split('/') + .take(path.split('/').count().saturating_sub(1)) + { + if !prefix.is_empty() { + prefix.push('/'); + } + prefix.push_str(component); + self.expanded.insert(NodeId { + path: prefix.clone(), + directory: true, + }); + } + } + self.selected = Some(target); + self.ensure_selected_visible(); + true + } + + pub fn completion_paths(&self) -> Vec { + fn visit(nodes: &[SidebarNode], paths: &mut Vec) { + for node in nodes { + paths.push(node.id.path.clone()); + visit(&node.children, paths); + } + } + + let mut paths = Vec::new(); + visit(&self.full, &mut paths); + paths + } + pub fn set_viewport_height(&mut self, height: usize) { self.viewport_height = height.max(1); self.ensure_selected_visible(); @@ -574,6 +615,31 @@ mod tests { ); } + #[test] + fn command_selection_expands_ancestors_and_completion_uses_the_full_tree() { + let mut sidebar = sidebar(); + assert!(sidebar.select_path("personal", true)); + assert!(sidebar.select_path("personal/咖啡", false)); + assert_eq!(sidebar.selected().map(NodeId::path), Some("personal/咖啡")); + assert!( + sidebar + .visible_rows() + .iter() + .any(|row| row.id.path() == "personal/咖啡") + ); + assert_eq!( + sidebar.completion_paths(), + vec![ + "personal", + "personal/email", + "personal/咖啡", + "work", + "work/server" + ] + ); + assert!(!sidebar.select_path("personal/咖啡", true)); + } + #[test] fn empty_tree_has_no_stale_selection() { let mut sidebar = Sidebar::default(); diff --git a/apps/tui/src/ui.rs b/apps/tui/src/ui.rs index 551b1da..63abbdf 100644 --- a/apps/tui/src/ui.rs +++ b/apps/tui/src/ui.rs @@ -78,6 +78,15 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) { } if app.mode() == Mode::Help { + if let Some(help) = app.command_help() { + frame.render_widget( + Paragraph::new(help) + .block(Block::bordered().title("Command help")) + .wrap(Wrap { trim: false }), + area, + ); + return; + } let lines = help_actions(app.help_context_mode()).map(|spec| { let bindings = spec .bindings @@ -231,6 +240,10 @@ 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(), @@ -418,7 +431,11 @@ fn context_line(app: &App) -> Paragraph<'static> { fn prompt_line(app: &App) -> Paragraph<'static> { match app.mode() { - Mode::Command => Paragraph::new(":").style(Style::default().fg(Color::Yellow)), + 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 => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)), Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => { Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves") @@ -513,6 +530,51 @@ mod tests { assert!(!viewer_help.contains("insert entry")); } + #[test] + fn command_prompt_help_and_confirmation_are_rendered_without_secrets() { + let mut app = App::new(); + app.dispatch(crate::action::Action::Command); + for character in "otp validate otpauth://totp/test?secret=NEVER-SHOW".chars() { + app.handle_command_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + let prompt = render(120, 24, &app); + assert!(prompt.contains("hidden secret-bearing arguments")); + assert!(!prompt.contains("NEVER-SHOW")); + + app.dispatch(crate::action::Action::Cancel); + app.dispatch(crate::action::Action::Command); + for character in "help git".chars() { + app.handle_command_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + app.handle_command_input( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); + assert!(render(120, 30, &app).contains("Usage: git")); + + app.dispatch(crate::action::Action::Cancel); + app.dispatch(crate::action::Action::Command); + for character in "remove old/entry".chars() { + app.handle_command_input( + crossterm::event::KeyCode::Char(character), + crossterm::event::KeyModifiers::NONE, + ); + } + app.handle_command_input( + crossterm::event::KeyCode::Enter, + crossterm::event::KeyModifiers::NONE, + ); + let confirmation = render(120, 24, &app); + assert!(confirmation.contains("Confirm remove")); + assert!(confirmation.contains("confirm> y / n / Esc")); + } + #[test] fn hierarchy_selection_and_storage_indicators_have_stable_rendering() { let mut app = App::new();