Implement TUI colon command mode
This commit is contained in:
32
apps/tui/COMMANDS.md
Normal file
32
apps/tui/COMMANDS.md
Normal 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.
|
||||||
@@ -154,6 +154,7 @@ impl ActionSpec {
|
|||||||
const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer];
|
const BROWSER_LIKE: &[Mode] = &[Mode::Browser, Mode::Viewer];
|
||||||
const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer];
|
const ENTRY_CONTEXT: &[Mode] = &[Mode::Browser, Mode::Viewer];
|
||||||
const UNLOCKED: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Editor];
|
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 QUITTABLE: &[Mode] = &[Mode::Browser, Mode::Viewer, Mode::Locked];
|
||||||
const LOCKABLE: &[Mode] = &[
|
const LOCKABLE: &[Mode] = &[
|
||||||
Mode::Browser,
|
Mode::Browser,
|
||||||
@@ -224,7 +225,7 @@ pub static ACTIONS: &[ActionSpec] = &[
|
|||||||
label: "command",
|
label: "command",
|
||||||
command: "command",
|
command: "command",
|
||||||
bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")),
|
bindings: keys!((KeyCode::Char(':'), KeyModifiers::NONE, ":")),
|
||||||
modes: UNLOCKED,
|
modes: COMMANDABLE,
|
||||||
},
|
},
|
||||||
ActionSpec {
|
ActionSpec {
|
||||||
action: Action::Cancel,
|
action: Action::Cancel,
|
||||||
|
|||||||
@@ -3,6 +3,9 @@
|
|||||||
use std::collections::BTreeSet;
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use ironstorage::{
|
use ironstorage::{
|
||||||
|
command::{
|
||||||
|
CommandRequest, OtpRequest, Presentation, help_text, otp_version_text, version_text,
|
||||||
|
},
|
||||||
config::Config,
|
config::Config,
|
||||||
crypto::KeyInfo,
|
crypto::KeyInfo,
|
||||||
document::{DocumentError, EntryDocument, EntryFieldId},
|
document::{DocumentError, EntryDocument, EntryFieldId},
|
||||||
@@ -14,6 +17,7 @@ use ironstorage::{
|
|||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
action::{Action, WorkflowAction},
|
action::{Action, WorkflowAction},
|
||||||
|
command::{CommandInvocation, CommandLine, operation_name},
|
||||||
editor::EntryEditor,
|
editor::EntryEditor,
|
||||||
sidebar::{Sidebar, SidebarIntent},
|
sidebar::{Sidebar, SidebarIntent},
|
||||||
viewer::EntryViewer,
|
viewer::EntryViewer,
|
||||||
@@ -138,9 +142,16 @@ pub enum AppEffect {
|
|||||||
editor: Box<EntryEditor>,
|
editor: Box<EntryEditor>,
|
||||||
},
|
},
|
||||||
OpenWorkflow(WorkflowAction),
|
OpenWorkflow(WorkflowAction),
|
||||||
|
RunCommand(CommandRequest),
|
||||||
ManualLock,
|
ManualLock,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||||
|
enum CommandOpenTarget {
|
||||||
|
Viewer,
|
||||||
|
Editor,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct App {
|
pub struct App {
|
||||||
mode: Mode,
|
mode: Mode,
|
||||||
@@ -154,6 +165,11 @@ pub struct App {
|
|||||||
viewer: Option<EntryViewer>,
|
viewer: Option<EntryViewer>,
|
||||||
editor: Option<EntryEditor>,
|
editor: Option<EntryEditor>,
|
||||||
discard_confirmation: bool,
|
discard_confirmation: bool,
|
||||||
|
command_confirmation: Option<CommandRequest>,
|
||||||
|
command_confirmation_message: Option<String>,
|
||||||
|
command_line: CommandLine,
|
||||||
|
command_help: Option<String>,
|
||||||
|
command_open_target: Option<CommandOpenTarget>,
|
||||||
editor_generation_pending: Option<EntryFieldId>,
|
editor_generation_pending: Option<EntryFieldId>,
|
||||||
authentication_pending: Option<String>,
|
authentication_pending: Option<String>,
|
||||||
remaining_lease: Option<std::time::Duration>,
|
remaining_lease: Option<std::time::Duration>,
|
||||||
@@ -185,6 +201,11 @@ impl App {
|
|||||||
viewer: None,
|
viewer: None,
|
||||||
editor: None,
|
editor: None,
|
||||||
discard_confirmation: false,
|
discard_confirmation: false,
|
||||||
|
command_confirmation: None,
|
||||||
|
command_confirmation_message: None,
|
||||||
|
command_line: CommandLine::default(),
|
||||||
|
command_help: None,
|
||||||
|
command_open_target: None,
|
||||||
editor_generation_pending: None,
|
editor_generation_pending: None,
|
||||||
authentication_pending: None,
|
authentication_pending: None,
|
||||||
remaining_lease: 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 {
|
pub fn focus(&self) -> PaneFocus {
|
||||||
self.focus
|
self.focus
|
||||||
}
|
}
|
||||||
@@ -245,6 +274,18 @@ impl App {
|
|||||||
self.discard_confirmation
|
self.discard_confirmation
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn command_confirmation_message(&self) -> Option<&str> {
|
||||||
|
self.command_confirmation_message.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_line(&self) -> &CommandLine {
|
||||||
|
&self.command_line
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn command_help(&self) -> Option<&str> {
|
||||||
|
self.command_help.as_deref()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn default_key(&self) -> Option<&KeyInfo> {
|
pub fn default_key(&self) -> Option<&KeyInfo> {
|
||||||
self.default_key.as_ref()
|
self.default_key.as_ref()
|
||||||
}
|
}
|
||||||
@@ -330,9 +371,16 @@ impl App {
|
|||||||
return ResultDisposition::Stale;
|
return ResultDisposition::Stale;
|
||||||
}
|
}
|
||||||
let field_count = document.fields().len();
|
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.focus = PaneFocus::Main;
|
||||||
self.status = format!("Opened {entry} ({field_count} fields)");
|
|
||||||
}
|
}
|
||||||
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
|
||||||
self.status = match disposition {
|
self.status = match disposition {
|
||||||
@@ -414,6 +462,7 @@ impl App {
|
|||||||
Err(error) => {
|
Err(error) => {
|
||||||
self.status = error;
|
self.status = error;
|
||||||
self.editor_generation_pending = None;
|
self.editor_generation_pending = None;
|
||||||
|
self.command_open_target = None;
|
||||||
if self.mode == Mode::Viewer && self.viewer.is_none() {
|
if self.mode == Mode::Viewer && self.viewer.is_none() {
|
||||||
self.mode = Mode::Browser;
|
self.mode = Mode::Browser;
|
||||||
self.focus = PaneFocus::Sidebar;
|
self.focus = PaneFocus::Sidebar;
|
||||||
@@ -447,9 +496,11 @@ impl App {
|
|||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
}
|
}
|
||||||
Action::Help => {
|
Action::Help => {
|
||||||
|
self.command_help = None;
|
||||||
self.transition(Transition::OpenHelp);
|
self.transition(Transition::OpenHelp);
|
||||||
}
|
}
|
||||||
Action::Command => {
|
Action::Command => {
|
||||||
|
self.command_line.clear();
|
||||||
self.transition(Transition::OpenCommand);
|
self.transition(Transition::OpenCommand);
|
||||||
}
|
}
|
||||||
Action::Cancel => {
|
Action::Cancel => {
|
||||||
@@ -457,7 +508,13 @@ impl App {
|
|||||||
self.cancel_editor();
|
self.cancel_editor();
|
||||||
} else if self.mode == Mode::Dialog && self.discard_confirmation {
|
} else if self.mode == Mode::Dialog && self.discard_confirmation {
|
||||||
self.keep_editing();
|
self.keep_editing();
|
||||||
|
} else if self.mode == Mode::Dialog && self.command_confirmation.is_some() {
|
||||||
|
self.cancel_command_confirmation();
|
||||||
} else {
|
} else {
|
||||||
|
if self.mode == Mode::Command {
|
||||||
|
self.command_line.clear();
|
||||||
|
self.status = "Command cancelled".to_owned();
|
||||||
|
}
|
||||||
self.transition(Transition::Dismiss);
|
self.transition(Transition::Dismiss);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -625,8 +682,20 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Action::ConfirmDiscard => self.discard_editor(),
|
Action::ConfirmDiscard => {
|
||||||
Action::KeepEditing => self.keep_editing(),
|
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::Initialize
|
||||||
| Action::InsertEntry
|
| Action::InsertEntry
|
||||||
| Action::GenerateEntry
|
| Action::GenerateEntry
|
||||||
@@ -654,6 +723,233 @@ impl App {
|
|||||||
.is_some_and(EntryEditor::is_input_active)
|
.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 {
|
fn editor_context_active(&self) -> bool {
|
||||||
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
|
self.mode == Mode::Editor || self.suspended_mode == Some(Mode::Editor)
|
||||||
}
|
}
|
||||||
@@ -844,6 +1140,7 @@ impl App {
|
|||||||
self.editor = None;
|
self.editor = None;
|
||||||
self.discard_confirmation = false;
|
self.discard_confirmation = false;
|
||||||
self.editor_generation_pending = None;
|
self.editor_generation_pending = None;
|
||||||
|
self.command_open_target = None;
|
||||||
self.status = message;
|
self.status = message;
|
||||||
if self.mode != Mode::Browser {
|
if self.mode != Mode::Browser {
|
||||||
self.mode = Mode::Browser;
|
self.mode = Mode::Browser;
|
||||||
@@ -880,10 +1177,13 @@ impl App {
|
|||||||
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => {
|
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenDialog) => {
|
||||||
Some(Mode::Dialog)
|
Some(Mode::Dialog)
|
||||||
}
|
}
|
||||||
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenHelp) => Some(Mode::Help),
|
(Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked, Transition::OpenHelp) => {
|
||||||
(Mode::Browser | Mode::Viewer | Mode::Editor, Transition::OpenCommand) => {
|
Some(Mode::Help)
|
||||||
Some(Mode::Command)
|
|
||||||
}
|
}
|
||||||
|
(
|
||||||
|
Mode::Browser | Mode::Viewer | Mode::Editor | Mode::Locked,
|
||||||
|
Transition::OpenCommand,
|
||||||
|
) => Some(Mode::Command),
|
||||||
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
|
(Mode::Dialog | Mode::Help | Mode::Command, Transition::Dismiss) => {
|
||||||
self.suspended_mode.take()
|
self.suspended_mode.take()
|
||||||
}
|
}
|
||||||
@@ -929,6 +1229,11 @@ impl App {
|
|||||||
self.viewer = None;
|
self.viewer = None;
|
||||||
self.editor = None;
|
self.editor = None;
|
||||||
self.discard_confirmation = false;
|
self.discard_confirmation = false;
|
||||||
|
self.command_confirmation = None;
|
||||||
|
self.command_confirmation_message = None;
|
||||||
|
self.command_line.clear();
|
||||||
|
self.command_help = None;
|
||||||
|
self.command_open_target = None;
|
||||||
self.editor_generation_pending = None;
|
self.editor_generation_pending = None;
|
||||||
self.status = "Locked".to_owned();
|
self.status = "Locked".to_owned();
|
||||||
} else if current == Mode::Locked {
|
} else if current == Mode::Locked {
|
||||||
@@ -972,6 +1277,24 @@ mod tests {
|
|||||||
use super::*;
|
use super::*;
|
||||||
use crate::viewer::test_support::fixture_document;
|
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]
|
#[test]
|
||||||
fn every_legal_transition_reaches_its_destination() {
|
fn every_legal_transition_reaches_its_destination() {
|
||||||
let cases = [
|
let cases = [
|
||||||
@@ -1187,4 +1510,85 @@ mod tests {
|
|||||||
));
|
));
|
||||||
assert!(app.status().contains("entry removal"));
|
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
826
apps/tui/src/command.rs
Normal 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}--")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
pub mod action;
|
pub mod action;
|
||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod command;
|
||||||
pub mod editor;
|
pub mod editor;
|
||||||
pub mod runtime;
|
pub mod runtime;
|
||||||
pub mod sidebar;
|
pub mod sidebar;
|
||||||
@@ -100,6 +101,17 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
{
|
{
|
||||||
apply_authentication_event(&mut app, coordinator, &executor, event);
|
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(
|
if !key.modifiers.intersects(
|
||||||
crossterm::event::KeyModifiers::CONTROL
|
crossterm::event::KeyModifiers::CONTROL
|
||||||
| crossterm::event::KeyModifiers::ALT
|
| crossterm::event::KeyModifiers::ALT
|
||||||
@@ -116,86 +128,16 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
match key_resolver.feed(app.mode(), key.code, key.modifiers) {
|
match key_resolver.feed(app.mode(), key.code, key.modifiers) {
|
||||||
KeyResolution::Action(action) => match app.dispatch(action) {
|
KeyResolution::Action(action) => {
|
||||||
AppEffect::RefreshTree => {
|
let effect = app.dispatch(action);
|
||||||
if let Some(config) = app.config().cloned() {
|
apply_app_effect(
|
||||||
let token = app.begin_latest_request();
|
&mut app,
|
||||||
executor.submit(token, move || {
|
effect,
|
||||||
load_tree(&config).map(AsyncPayload::Refreshed)
|
&mut authentication,
|
||||||
});
|
&executor,
|
||||||
}
|
&mut clipboard_cancellations,
|
||||||
}
|
);
|
||||||
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::Pending => {
|
KeyResolution::Pending => {
|
||||||
app.report_status("Key sequence pending; Esc cancels".to_owned());
|
app.report_status("Key sequence pending; Esc cancels".to_owned());
|
||||||
}
|
}
|
||||||
@@ -218,6 +160,86 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
|
|||||||
Ok(())
|
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 {
|
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
|
||||||
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -160,6 +160,47 @@ impl Sidebar {
|
|||||||
self.selected.as_ref()
|
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) {
|
pub fn set_viewport_height(&mut self, height: usize) {
|
||||||
self.viewport_height = height.max(1);
|
self.viewport_height = height.max(1);
|
||||||
self.ensure_selected_visible();
|
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]
|
#[test]
|
||||||
fn empty_tree_has_no_stale_selection() {
|
fn empty_tree_has_no_stale_selection() {
|
||||||
let mut sidebar = Sidebar::default();
|
let mut sidebar = Sidebar::default();
|
||||||
|
|||||||
@@ -78,6 +78,15 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if app.mode() == Mode::Help {
|
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 lines = help_actions(app.help_context_mode()).map(|spec| {
|
||||||
let bindings = spec
|
let bindings = spec
|
||||||
.bindings
|
.bindings
|
||||||
@@ -231,6 +240,10 @@ fn main_text(app: &App) -> String {
|
|||||||
Mode::Dialog if app.discard_confirmation() => {
|
Mode::Dialog if app.discard_confirmation() => {
|
||||||
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
|
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
|
||||||
}
|
}
|
||||||
|
Mode::Dialog if app.command_confirmation_message().is_some() => app
|
||||||
|
.command_confirmation_message()
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_owned(),
|
||||||
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
|
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
|
||||||
Mode::Command => "Enter a command on the bottom line.".to_owned(),
|
Mode::Command => "Enter a command on the bottom line.".to_owned(),
|
||||||
Mode::Help | Mode::Locked => String::new(),
|
Mode::Help | Mode::Locked => String::new(),
|
||||||
@@ -418,7 +431,11 @@ fn context_line(app: &App) -> Paragraph<'static> {
|
|||||||
|
|
||||||
fn prompt_line(app: &App) -> Paragraph<'static> {
|
fn prompt_line(app: &App) -> Paragraph<'static> {
|
||||||
match app.mode() {
|
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::Dialog => Paragraph::new("dialog> ").style(Style::default().fg(Color::Yellow)),
|
||||||
Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
|
Mode::Editor if app.editor().is_some_and(EntryEditor::is_input_active) => {
|
||||||
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
|
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
|
||||||
@@ -513,6 +530,51 @@ mod tests {
|
|||||||
assert!(!viewer_help.contains("insert entry"));
|
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]
|
#[test]
|
||||||
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
|
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
|
||||||
let mut app = App::new();
|
let mut app = App::new();
|
||||||
|
|||||||
Reference in New Issue
Block a user