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

@@ -11,6 +11,7 @@ use ratatui::{
use crate::{
action::available_actions,
app::{App, Mode, PaneFocus},
viewer::EntryViewer,
};
const MINIMUM_WIDTH: u16 = 40;
@@ -124,13 +125,23 @@ fn render_content(frame: &mut Frame, app: &App, area: Rect) {
.wrap(Wrap { trim: false }),
panes[0],
);
frame.render_widget(
let main = if app.mode() == Mode::Viewer {
app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
Paragraph::new(viewer_lines(viewer))
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
},
)
} else {
Paragraph::new(main_text(app))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: true }),
};
frame.render_widget(
main.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
))
.wrap(Wrap { trim: false }),
panes[1],
);
}
@@ -208,6 +219,76 @@ fn main_text(app: &App) -> String {
}
}
fn viewer_lines(viewer: &EntryViewer) -> Vec<Line<'_>> {
let focused = viewer.focused_index();
if viewer.document().fields().is_empty() {
return vec![Line::from("This entry is empty.")];
}
viewer
.document()
.fields()
.iter()
.enumerate()
.map(|(index, field)| {
let metadata = field.metadata();
let label = metadata.name().map_or_else(
|| match metadata.kind() {
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
kind => format!("{kind:?}").to_ascii_lowercase(),
},
str::to_owned,
);
let value = if metadata.sensitivity()
== ironstorage::document::EntrySensitivity::Sensitive
&& !viewer.is_revealed(field.id())
{
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
} else if field.value().is_empty() {
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
} else {
match std::str::from_utf8(field.value()) {
Ok(value) => Span::raw(value),
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
}
};
let mut spans = vec![
Span::styled(
format!("{label}: "),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
value,
];
if let Some(otp) = metadata.otp() {
let timing = otp.period().map_or_else(
|| format!("counter {}", otp.counter().unwrap_or_default()),
|period| format!("period {period}s"),
);
spans.push(Span::styled(
format!(
" [{:?}, {}, {:?}, {}, {} digits, {timing}]",
otp.kind(),
otp.issuer().unwrap_or("no issuer"),
otp.algorithm(),
otp.account(),
otp.digits(),
),
Style::default().fg(Color::DarkGray),
));
}
let line = Line::from(spans);
if focused == Some(index) {
line.style(Style::default().bg(Color::Blue).fg(Color::White))
} else {
line
}
})
.collect()
}
fn mode_title(mode: Mode) -> &'static str {
match mode {
Mode::Browser => "Browser",
@@ -264,6 +345,7 @@ mod tests {
use super::*;
use crate::app::Transition;
use crate::sidebar::TestTreeNode;
use crate::viewer::test_support::fixture_document;
fn render(width: u16, height: u16, app: &App) -> String {
let backend = TestBackend::new(width, height);
@@ -384,4 +466,77 @@ mod tests {
let output = render(100, 20, &app);
assert!(output.contains("locks in 30s"));
}
#[test]
fn viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() {
let mut app = App::new();
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
for width in [60, 100, 140] {
let output = render(width, 20, &app);
assert!(output.contains("password: ••••••••"));
assert!(output.contains("login:"));
assert!(output.contains('用'));
assert!(output.contains("@example.test"));
assert!(output.contains("notes: ••••••••"));
assert!(!output.contains("pässwörd-猫"));
}
assert!(!format!("{app:?}").contains("pässwörd-猫"));
}
#[test]
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::Reveal);
let revealed = render(100, 20, &app);
assert!(revealed.contains("correct horse fixture"));
app.dispatch(crate::action::Action::FocusNext);
let hidden = render(100, 20, &app);
assert!(!hidden.contains("correct horse fixture"));
app.dispatch(crate::action::Action::FocusPrevious);
app.dispatch(crate::action::Action::Reveal);
app.dispatch(crate::action::Action::Help);
app.dispatch(crate::action::Action::Cancel);
let after_overlay = render(100, 20, &app);
assert!(!after_overlay.contains("correct horse fixture"));
app.dispatch(crate::action::Action::Reveal);
app.forced_relock("test lock");
let locked = render(100, 20, &app);
assert!(!locked.contains("correct horse fixture"));
assert!(locked.contains("locked"));
}
#[test]
fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() {
let mut app = App::new();
app.open_test_document("otp/totp", fixture_document("otp/totp"));
app.dispatch(crate::action::Action::FocusNext);
let output = render(120, 20, &app);
assert!(output.contains("Totp"));
assert!(output.contains("IronStorage"));
assert!(output.contains("alice@example.test"));
assert!(output.contains("period 30s"));
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
}
#[test]
fn closing_a_document_preserves_the_sidebar_selection() {
let mut app = App::new();
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "email/personal".to_owned(),
name: "personal".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
}]);
let selected = app.sidebar().selected().cloned();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::CloseEntry);
assert_eq!(app.mode(), Mode::Browser);
assert_eq!(app.sidebar().selected(), selected.as_ref());
assert!(app.viewer().is_none());
}
}