Implement the authenticated structured entry viewer

This commit is contained in:
Hermes Agent
2026-08-10 06:45:39 +00:00
parent 57a79b7d21
commit cf7a6216ac
8 changed files with 758 additions and 28 deletions

View File

@@ -5,12 +5,16 @@ use std::collections::BTreeSet;
use ironstorage::{
config::Config,
crypto::KeyInfo,
document::EntryDocument,
presentation::ClipboardDisposition,
read::{FindResults, TreeModel},
repository::SecretBytes,
};
use crate::{
action::Action,
sidebar::{Sidebar, SidebarIntent},
viewer::EntryViewer,
};
#[derive(Clone, Copy, Debug, Eq, Ord, PartialEq, PartialOrd)]
@@ -56,14 +60,22 @@ pub struct StartupData {
pub key: KeyInfo,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Debug)]
pub enum AsyncPayload {
Startup(Box<StartupData>),
Refreshed(TreeModel),
Filtered { query: String, results: FindResults },
Filtered {
query: String,
results: FindResults,
},
DocumentLoaded {
entry: String,
document: Box<EntryDocument>,
},
ClipboardFinished(ClipboardDisposition),
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Debug)]
pub struct AsyncResult {
pub token: RequestToken,
pub payload: Result<AsyncPayload, String>,
@@ -75,11 +87,12 @@ pub enum ResultDisposition {
Stale,
}
#[derive(Clone, Debug, Eq, PartialEq)]
#[derive(Debug)]
pub enum AppEffect {
None,
RefreshTree,
AuthenticateEntry(String),
CopyFocused(SecretBytes),
ManualLock,
}
@@ -93,6 +106,7 @@ pub struct App {
default_key: Option<KeyInfo>,
sidebar: Sidebar,
selected_entry: Option<String>,
viewer: Option<EntryViewer>,
authentication_pending: Option<String>,
remaining_lease: Option<std::time::Duration>,
terminal_size: (u16, u16),
@@ -120,6 +134,7 @@ impl App {
default_key: None,
sidebar: Sidebar::default(),
selected_entry: None,
viewer: None,
authentication_pending: None,
remaining_lease: None,
terminal_size: (0, 0),
@@ -159,6 +174,10 @@ impl App {
self.selected_entry.as_deref()
}
pub fn viewer(&self) -> Option<&EntryViewer> {
self.viewer.as_ref()
}
pub fn default_key(&self) -> Option<&KeyInfo> {
self.default_key.as_ref()
}
@@ -239,7 +258,34 @@ impl App {
}
self.status = format!("Filter: {query} ({} matches)", results.matches().len());
}
Err(error) => self.status = error,
Ok(AsyncPayload::DocumentLoaded { entry, document }) => {
if self.mode != Mode::Viewer || self.selected_entry.as_deref() != Some(&entry) {
return ResultDisposition::Stale;
}
let field_count = document.fields().len();
self.viewer = Some(EntryViewer::new(*document));
self.focus = PaneFocus::Main;
self.status = format!("Opened {entry} ({field_count} fields)");
}
Ok(AsyncPayload::ClipboardFinished(disposition)) => {
self.status = match disposition {
ClipboardDisposition::RestoredPrevious => {
"Clipboard restored to its previous value".to_owned()
}
ClipboardDisposition::Cleared => "Clipboard secret cleared".to_owned(),
ClipboardDisposition::PreservedNewer => {
"Clipboard changed; the newer value was preserved".to_owned()
}
};
}
Err(error) => {
self.status = error;
if self.mode == Mode::Viewer && self.viewer.is_none() {
self.mode = Mode::Browser;
self.focus = PaneFocus::Sidebar;
self.selected_entry = None;
}
}
}
ResultDisposition::Applied
}
@@ -295,12 +341,68 @@ impl App {
Action::Filter => self.sidebar.begin_filter(),
Action::NextMatch => self.sidebar.next_match(),
Action::PreviousMatch => self.sidebar.previous_match(),
Action::FocusNext if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_next();
}
}
Action::FocusPrevious if self.mode == Mode::Viewer => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.focus_previous();
}
}
Action::FocusNext | Action::FocusPrevious => {
self.focus = match self.focus {
PaneFocus::Sidebar => PaneFocus::Main,
PaneFocus::Main => PaneFocus::Sidebar,
};
}
Action::Reveal => {
if self
.viewer
.as_mut()
.is_some_and(EntryViewer::reveal_focused)
{
self.status = "Focused sensitive field revealed".to_owned();
}
}
Action::Hide => {
if self.viewer.as_mut().is_some_and(EntryViewer::hide_revealed) {
self.status = "Sensitive field hidden".to_owned();
}
}
Action::Copy => {
if let Some(viewer) = self.viewer.as_ref() {
match viewer.copy_focused() {
Ok(value) => {
self.status = self.config.as_ref().map_or_else(
|| "Copying focused field…".to_owned(),
|config| {
format!(
"Copied focused field; cleanup in {}s",
config.clipboard_timeout().duration().as_secs()
)
},
);
return AppEffect::CopyFocused(value);
}
Err(error) => self.status = error.to_string(),
}
}
}
Action::ScrollDown => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.scroll_down(5);
}
}
Action::ScrollUp => {
if let Some(viewer) = self.viewer.as_mut() {
viewer.scroll_up(5);
}
}
Action::CloseEntry => {
self.transition(Transition::CloseEntry);
}
Action::Quit => {}
}
AppEffect::None
@@ -312,13 +414,19 @@ impl App {
}
self.authentication_pending = None;
self.selected_entry = Some(entry);
self.status = "Authenticated".to_owned();
self.transition(Transition::OpenEntry)
self.viewer = None;
self.status = "Authenticated; loading structured entry…".to_owned();
let transitioned = self.transition(Transition::OpenEntry);
if transitioned {
self.focus = PaneFocus::Main;
}
transitioned
}
pub fn authentication_failed(&mut self, message: String) {
self.authentication_pending = None;
self.selected_entry = None;
self.viewer = None;
self.status = message;
if self.mode != Mode::Browser {
self.mode = Mode::Browser;
@@ -371,16 +479,22 @@ impl App {
return false;
};
if matches!(destination, Mode::Dialog | Mode::Help | Mode::Command) {
if let Some(viewer) = self.viewer.as_mut() {
viewer.hide_revealed();
}
self.suspended_mode = Some(current);
} else if destination == Mode::Browser && matches!(current, Mode::Viewer | Mode::Editor) {
self.selected_entry = None;
self.viewer = None;
self.authentication_pending = None;
self.remaining_lease = None;
self.focus = PaneFocus::Sidebar;
} else if destination == Mode::Locked {
self.suspended_mode = None;
self.focus = PaneFocus::Sidebar;
self.invalidate_requests();
self.selected_entry = None;
self.viewer = None;
self.status = "Locked".to_owned();
} else if current == Mode::Locked {
self.status = "Authentication required".to_owned();
@@ -393,6 +507,15 @@ impl App {
self.generation = self.generation.wrapping_add(1);
self.pending.clear();
}
#[cfg(test)]
pub(crate) fn open_test_document(&mut self, entry: &str, document: EntryDocument) {
self.mode = Mode::Viewer;
self.focus = PaneFocus::Main;
self.selected_entry = Some(entry.to_owned());
self.viewer = Some(EntryViewer::new(document));
self.status = format!("Opened {entry}");
}
}
#[cfg(test)]
@@ -452,8 +575,14 @@ mod tests {
token,
payload: Err("completed".to_owned()),
};
assert_eq!(app.apply_result(result.clone()), ResultDisposition::Applied);
assert_eq!(app.apply_result(result), ResultDisposition::Stale);
assert_eq!(app.apply_result(result), ResultDisposition::Applied);
assert_eq!(
app.apply_result(AsyncResult {
token,
payload: Err("duplicate".to_owned()),
}),
ResultDisposition::Stale
);
let stale = app.begin_request();
assert!(app.transition(Transition::Lock));