Implement TUI colon command mode
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user