Define the Mutt-like TUI keymap

This commit is contained in:
Hermes Agent
2026-08-10 08:03:20 +00:00
parent c82c792699
commit 639ae0d08e
4 changed files with 519 additions and 80 deletions

View File

@@ -22,7 +22,7 @@ use crossterm::event::{self, Event, KeyEventKind};
use ratatui::DefaultTerminal;
use crate::{
action::resolve_key,
action::{KeyResolution, KeyResolver},
app::{App, AppEffect, AsyncPayload, EditorSaveFailure, EditorSaveFailureKind, StartupData},
runtime::{AsyncExecutor, AuthenticationCoordinator, AuthenticationEvent},
};
@@ -54,6 +54,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
let mut clipboard_cancellations = ClipboardCancellations::default();
let mut authentication = None;
let mut authentication_initialized = false;
let mut key_resolver = KeyResolver::default();
let startup = app.begin_latest_request();
executor.submit(startup, || {
load_startup().map(|startup| AsyncPayload::Startup(Box::new(startup)))
@@ -93,7 +94,7 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
}
match event::read()? {
Event::Key(key) if key.kind == KeyEventKind::Press => {
Event::Key(key) if is_dispatchable_key_kind(key.kind) => {
if let Some(coordinator) = authentication.as_mut()
&& let Some(event) = coordinator.touch_user_activity()
{
@@ -105,37 +106,40 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
| crossterm::event::KeyModifiers::SUPER,
) && app.handle_editor_input(key.code)
{
key_resolver.reset();
continue;
}
if app.sidebar().is_editing_filter() {
key_resolver.reset();
if handle_filter_key(&mut app, key.code) {
submit_filter(&mut app, &executor);
}
} else if let Some(action) = resolve_key(app.mode(), key.code, key.modifiers) {
match app.dispatch(action) {
AppEffect::RefreshTree => {
if let Some(config) = app.config().cloned() {
let token = app.begin_latest_request();
executor.submit(token, move || {
load_tree(&config).map(AsyncPayload::Refreshed)
});
} else {
match key_resolver.feed(app.mode(), key.code, key.modifiers) {
KeyResolution::Action(action) => match app.dispatch(action) {
AppEffect::RefreshTree => {
if let Some(config) = app.config().cloned() {
let token = app.begin_latest_request();
executor.submit(token, move || {
load_tree(&config).map(AsyncPayload::Refreshed)
});
}
}
}
AppEffect::AuthenticateEntry(entry) => {
if let Some(coordinator) = authentication.as_mut() {
coordinator.request(entry);
} else {
app.authentication_failed(
"operating-system secure storage is unavailable".to_owned(),
);
AppEffect::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 || {
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(),
@@ -155,40 +159,49 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
.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::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 => {
app.report_status("Key sequence pending; Esc cancels".to_owned());
}
KeyResolution::Unavailable => {
app.report_status("Key is unavailable in the current mode".to_owned());
}
AppEffect::None => {}
}
}
}
@@ -205,6 +218,10 @@ pub fn run(terminal: &mut DefaultTerminal) -> io::Result<()> {
Ok(())
}
fn is_dispatchable_key_kind(kind: KeyEventKind) -> bool {
matches!(kind, KeyEventKind::Press | KeyEventKind::Repeat)
}
fn load_startup() -> Result<StartupData, String> {
let config = ironstorage::config::Config::load(None).map_err(|error| error.to_string())?;
let repository = ironstorage::repository::Repository::open(config.vault())
@@ -381,6 +398,13 @@ mod tests {
use super::*;
use crate::{editor::EntryEditor, viewer::test_support::fixture_document_from};
#[test]
fn press_and_terminal_repeat_events_dispatch_but_release_does_not() {
assert!(is_dispatchable_key_kind(KeyEventKind::Press));
assert!(is_dispatchable_key_kind(KeyEventKind::Repeat));
assert!(!is_dispatchable_key_kind(KeyEventKind::Release));
}
#[test]
fn editor_save_encrypts_and_automatically_commits_entirely_in_storage() {
let temporary = tempfile::tempdir().expect("temporary editor store");