Implement TUI colon command mode

This commit is contained in:
Hermes Agent
2026-08-10 08:28:55 +00:00
parent 639ae0d08e
commit d66d9b0f08
7 changed files with 1502 additions and 89 deletions

32
apps/tui/COMMANDS.md Normal file
View File

@@ -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.

View File

@@ -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,

View File

@@ -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<EntryEditor>,
},
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<EntryViewer>,
editor: Option<EntryEditor>,
discard_confirmation: bool,
command_confirmation: Option<CommandRequest>,
command_confirmation_message: Option<String>,
command_line: CommandLine,
command_help: Option<String>,
command_open_target: Option<CommandOpenTarget>,
editor_generation_pending: Option<EntryFieldId>,
authentication_pending: Option<String>,
remaining_lease: Option<std::time::Duration>,
@@ -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<AppEffect> {
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<CommandRequest> {
if self.mode != Mode::Dialog {
return None;
}
let request = self.command_confirmation.take()?;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = format!("Starting confirmed {} workflow…", operation_name(&request));
Some(request)
}
fn cancel_command_confirmation(&mut self) {
if self.mode != Mode::Dialog || self.command_confirmation.is_none() {
return;
}
self.command_confirmation = None;
self.command_confirmation_message = None;
self.transition(Transition::Dismiss);
self.status = "Destructive command cancelled".to_owned();
}
fn editor_context_active(&self) -> bool {
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
}
@@ -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));
}
}

826
apps/tui/src/command.rs Normal file
View File

@@ -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<String>) -> 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<String>,
history_position: Option<usize>,
history_draft: String,
completion: Option<CompletionCycle>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
struct CompletionCycle {
start: usize,
end: usize,
candidates: Vec<String>,
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::<Vec<_>>();
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<CommandInvocation, CommandError> {
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<CommandInvocation, CommandError> {
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<Vec<String>, 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<String> {
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::<Vec<_>>();
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::<Vec<_>>()
});
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<usize> {
value[..cursor]
.char_indices()
.next_back()
.map(|(index, _)| index)
}
fn next_boundary(value: &str, cursor: usize) -> Option<usize> {
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}--")));
}
}
}

View File

@@ -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<AuthenticationCoordinator>,
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)
}

View File

@@ -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<String> {
fn visit(nodes: &[SidebarNode], paths: &mut Vec<String>) {
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();

View File

@@ -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();