Files
IronStorage/apps/tui/src/ui.rs

2062 lines
73 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Responsive Ratatui rendering for the application shell.
use ratatui::{
Frame,
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph, Wrap},
};
use zeroize::Zeroizing;
use crate::{
action::{context_actions, help_actions},
app::{App, Mode, PaneFocus},
editor::EntryEditor,
viewer::EntryViewer,
};
const MINIMUM_WIDTH: u16 = 40;
const MINIMUM_HEIGHT: u16 = 8;
const LARGE_OTP_HEIGHT: u16 = 8;
const LARGE_OTP_ROWS: usize = 5;
const ACCENT_COLOR: Color = Color::Cyan;
const SELECTED_FOREGROUND: Color = Color::Black;
const SELECTED_BACKGROUND: Color = Color::White;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum ColorCapability {
Color,
Monochrome,
}
impl ColorCapability {
pub fn detect() -> Self {
Self::from_environment(
std::env::var_os("TERM").as_deref(),
std::env::var_os("NO_COLOR").is_some(),
)
}
fn from_environment(term: Option<&std::ffi::OsStr>, no_color: bool) -> Self {
if no_color || term.is_some_and(|term| term == "dumb") {
Self::Monochrome
} else {
Self::Color
}
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum LayoutClass {
TooSmall,
Narrow,
Normal,
Wide,
}
pub fn layout_class(area: Rect) -> LayoutClass {
if area.width < MINIMUM_WIDTH || area.height < MINIMUM_HEIGHT {
LayoutClass::TooSmall
} else if area.width < 80 {
LayoutClass::Narrow
} else if area.width < 120 {
LayoutClass::Normal
} else {
LayoutClass::Wide
}
}
pub fn draw(frame: &mut Frame, app: &App) {
draw_with_color_capability(frame, app, ColorCapability::Color);
}
pub fn draw_with_color_capability(frame: &mut Frame, app: &App, capability: ColorCapability) {
draw_inner(frame, app, capability);
if capability == ColorCapability::Monochrome {
for cell in &mut frame.buffer_mut().content {
if cell.fg == SELECTED_FOREGROUND && cell.bg == SELECTED_BACKGROUND {
cell.modifier.insert(Modifier::REVERSED);
}
cell.set_fg(Color::Reset).set_bg(Color::Reset);
}
}
}
fn draw_inner(frame: &mut Frame, app: &App, capability: ColorCapability) {
let area = frame.area();
if layout_class(area) == LayoutClass::TooSmall {
frame.render_widget(
Paragraph::new(format!(
"Terminal too small ({}×{}). Need at least {MINIMUM_WIDTH}×{MINIMUM_HEIGHT}.",
area.width, area.height
))
.block(Block::bordered().title(ironstorage::PRODUCT_NAME))
.wrap(Wrap { trim: true }),
area,
);
return;
}
let rows = Layout::vertical([
Constraint::Min(3),
Constraint::Length(1),
Constraint::Length(1),
Constraint::Length(1),
])
.split(area);
render_content(frame, app, rows[0], capability);
frame.render_widget(status_line(app), rows[1]);
frame.render_widget(context_line(app), rows[2]);
frame.render_widget(prompt_line(app), rows[3]);
}
fn render_content(frame: &mut Frame, app: &App, area: Rect, capability: ColorCapability) {
if app.mode() == Mode::Locked {
frame.render_widget(
Paragraph::new("The password store is locked. Authentication is required.")
.block(pane_block("Locked", true, capability))
.wrap(Wrap { trim: true }),
area,
);
return;
}
if let Some(popup) = app.qr_popup() {
let matrix = popup.matrix();
let padded_width = matrix.width() + 8;
let needed_width =
u16::try_from(padded_width.saturating_mul(2).saturating_add(2)).unwrap_or(u16::MAX);
let needed_height = u16::try_from(
padded_width
.next_multiple_of(2)
.saturating_div(2)
.saturating_add(2),
)
.unwrap_or(u16::MAX);
let text = if area.width < needed_width || area.height < needed_height {
format!(
"Terminal too small for OTP QR (need {needed_width}×{needed_height}); resize or Esc to close."
)
} else {
let rendered = matrix.render_terminal();
String::from_utf8_lossy(rendered.expose()).into_owned()
};
frame.render_widget(
Paragraph::new(text)
.block(pane_block(
format!("{} — Esc closes", popup.title()),
true,
capability,
))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if let Some(uri) = app.uri_popup() {
frame.render_widget(
Paragraph::new(String::from_utf8_lossy(uri.expose()).into_owned())
.block(pane_block("OTP URI — Esc closes", true, capability))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if app.mode() == Mode::Help {
if let Some(help) = app.command_help() {
frame.render_widget(
Paragraph::new(help)
.block(pane_block("Command help", true, capability))
.wrap(Wrap { trim: false }),
area,
);
return;
}
let entries = help_actions(app.help_context_mode())
.map(|spec| {
let bindings = spec
.bindings
.iter()
.map(|binding| binding.display)
.collect::<Vec<_>>()
.join(", ");
format!(
"{bindings:>10} {:<20} :{:<16} {}",
spec.label,
spec.command,
spec.help()
)
})
.collect::<Vec<_>>();
let lines = if area.width >= 120 {
entries
.chunks(2)
.map(|chunk| Line::raw(chunk.join(" ")))
.collect::<Vec<_>>()
} else {
entries.into_iter().map(Line::raw).collect::<Vec<_>>()
};
frame.render_widget(
Paragraph::new(lines)
.block(pane_block(
format!("Contextual help — {}", mode_title(app.help_context_mode())),
true,
capability,
))
.wrap(Wrap { trim: false }),
area,
);
return;
}
if app.mode() == Mode::Dialog
&& let Some(workflow) = app.workflow()
{
frame.render_widget(
Paragraph::new(workflow.rows().join("\n"))
.block(pane_block(workflow.title(), true, capability))
.wrap(Wrap { trim: false }),
area,
);
return;
}
let class = layout_class(frame.area());
let (direction, constraints) = match class {
LayoutClass::Narrow if app.mode() == Mode::Viewer && area.height < 10 => (
Direction::Vertical,
[Constraint::Length(0), Constraint::Min(1)],
),
LayoutClass::Narrow => (
Direction::Vertical,
[Constraint::Percentage(40), Constraint::Percentage(60)],
),
LayoutClass::Normal => (
Direction::Horizontal,
[Constraint::Percentage(35), Constraint::Percentage(65)],
),
LayoutClass::Wide => (
Direction::Horizontal,
[Constraint::Percentage(25), Constraint::Percentage(75)],
),
LayoutClass::TooSmall => return,
};
let panes = Layout::new(direction, constraints).split(area);
frame.render_widget(
Paragraph::new(sidebar_lines(app))
.block(pane_block(
"Passwords",
app.focus() == PaneFocus::Sidebar,
capability,
))
.wrap(Wrap { trim: false }),
panes[0],
);
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& large_otp_fits(app, viewer, panes[1])
{
let main_rows =
Layout::vertical([Constraint::Length(LARGE_OTP_HEIGHT), Constraint::Min(3)])
.split(panes[1]);
render_large_otp(
frame,
app.otp_display().expect("large OTP was checked"),
main_rows[0],
app.focus() == PaneFocus::Main,
capability,
);
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, main_rows[1]);
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
capability,
))
.wrap(Wrap { trim: false }),
main_rows[1],
);
return;
}
if app.mode() == Mode::Viewer
&& let Some(viewer) = app.viewer()
&& let Some(display) = matching_totp_display(app, viewer)
{
let (otp_area, details_area) = if panes[1].height >= 6 {
let rows =
Layout::vertical([Constraint::Length(3), Constraint::Min(3)]).split(panes[1]);
(rows[0], Some(rows[1]))
} else {
(panes[1], None)
};
if let Some(details_area) = details_area {
render_compact_otp(
frame,
display,
otp_area,
app.focus() == PaneFocus::Main,
capability,
);
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll(viewer, &lines, details_area);
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
capability,
))
.wrap(Wrap { trim: false }),
details_area,
);
} else {
render_compact_viewer(frame, app, viewer, display, otp_area, capability);
}
return;
}
let main = match app.mode() {
Mode::Viewer => app.viewer().map_or_else(
|| Paragraph::new(main_text(app)),
|viewer| {
let lines = viewer_lines(viewer, app.otp_display());
let scroll = viewer_scroll(viewer, &lines, panes[1]);
Paragraph::new(lines).scroll((scroll, 0))
},
),
Mode::Editor => app.editor().map_or_else(
|| Paragraph::new(main_text(app)),
|editor| {
let focused = editor.focused_index().unwrap_or_default();
let scroll = editor
.document()
.fields()
.iter()
.take(focused)
.map(|field| {
std::str::from_utf8(field.contents().expose())
.map_or(1, |value| value.split('\n').count())
})
.sum::<usize>();
Paragraph::new(editor_lines(editor))
.scroll((u16::try_from(scroll).unwrap_or(u16::MAX), 0))
},
),
Mode::Browser if app.git_view().is_some() => {
Paragraph::new(git_lines(app.git_view().expect("checked Git view")))
}
Mode::Browser if app.grep_view().is_some() => Paragraph::new(grep_lines(
app.grep_view().expect("checked decrypted search results"),
)),
_ => Paragraph::new(main_text(app)),
};
frame.render_widget(
main.block(pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
capability,
))
.wrap(Wrap { trim: false }),
panes[1],
);
}
fn large_otp_fits(app: &App, viewer: &EntryViewer, area: Rect) -> bool {
let Some(display) = matching_totp_display(app, viewer) else {
return false;
};
let code = display.code().expose();
let glyph_width = code.len().saturating_mul(4).saturating_sub(1);
!code.is_empty()
&& code.iter().all(u8::is_ascii_digit)
&& usize::from(area.width.saturating_sub(2)) >= glyph_width
&& area.height >= LARGE_OTP_HEIGHT.saturating_add(3)
}
fn matching_totp_display<'a>(
app: &'a App,
viewer: &EntryViewer,
) -> Option<&'a crate::app::OtpDisplay> {
let display = app.otp_display()?;
if app.selected_entry() != Some(display.entry())
|| display.remaining_seconds().is_none()
|| display.period_seconds().is_none()
{
return None;
}
let field_id = display.field()?;
viewer
.document()
.fields()
.iter()
.any(|field| {
field.id() == field_id
&& field
.metadata()
.otp()
.is_some_and(|otp| otp.kind() == ironstorage::otp::OtpKind::Totp)
})
.then_some(display)
}
fn render_large_otp(
frame: &mut Frame,
display: &crate::app::OtpDisplay,
area: Rect,
focused: bool,
capability: ColorCapability,
) {
let glyphs = (0..LARGE_OTP_ROWS)
.map(|row| otp_ascii_row(display.code().expose(), row))
.collect::<Vec<_>>();
let remaining = display.remaining_seconds().unwrap_or_default();
let period = display.period_seconds().unwrap_or(1);
let inner_width = usize::from(area.width.saturating_sub(2));
let mut lines = glyphs
.iter()
.map(|row| large_otp_line(row))
.collect::<Vec<_>>();
lines.push(Line::styled(
countdown_bar(remaining, period, inner_width),
Style::default().fg(ACCENT_COLOR),
));
frame.render_widget(
Paragraph::new(lines)
.alignment(Alignment::Center)
.block(pane_block(
format!("TOTP code — {remaining}s remaining"),
focused,
capability,
)),
area,
);
}
fn large_otp_line(row: &str) -> Line<'_> {
let digit_style = Style::default()
.fg(Color::Black)
.bg(Color::Green)
.add_modifier(Modifier::BOLD);
Line::from(
row.split(' ')
.enumerate()
.flat_map(|(index, digits)| {
[
(index != 0).then(|| Span::raw(" ")),
(!digits.is_empty()).then(|| Span::styled(digits, digit_style)),
]
.into_iter()
.flatten()
})
.collect::<Vec<_>>(),
)
}
fn render_compact_otp(
frame: &mut Frame,
display: &crate::app::OtpDisplay,
area: Rect,
focused: bool,
capability: ColorCapability,
) {
frame.render_widget(
Paragraph::new(compact_otp_line(display))
.alignment(Alignment::Center)
.block(pane_block("TOTP code", focused, capability)),
area,
);
}
fn compact_otp_line(display: &crate::app::OtpDisplay) -> Line<'_> {
let code = String::from_utf8_lossy(display.code().expose());
let remaining = display.remaining_seconds().unwrap_or_default();
Line::from(vec![
Span::styled(
code,
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
Span::raw(format!("{remaining}s remaining")),
])
}
fn render_compact_viewer(
frame: &mut Frame,
app: &App,
viewer: &EntryViewer,
display: &crate::app::OtpDisplay,
area: Rect,
capability: ColorCapability,
) {
frame.render_widget(
pane_block(
mode_title(app.mode()),
app.focus() == PaneFocus::Main,
capability,
),
area,
);
let inner = area.inner(Margin::new(1, 1));
if inner.height == 0 {
return;
}
let rows = Layout::vertical([Constraint::Length(1), Constraint::Min(0)]).split(inner);
frame.render_widget(Paragraph::new(compact_otp_line(display)), rows[0]);
if rows[1].height == 0 {
return;
}
let lines = viewer_lines(viewer, None);
let scroll = viewer_scroll_in_content(viewer, &lines, rows[1].width, rows[1].height);
frame.render_widget(
Paragraph::new(lines)
.scroll((scroll, 0))
.wrap(Wrap { trim: false }),
rows[1],
);
}
fn viewer_scroll(viewer: &EntryViewer, lines: &[Line<'_>], area: Rect) -> u16 {
viewer_scroll_in_content(
viewer,
lines,
area.width.saturating_sub(2),
area.height.saturating_sub(2),
)
}
fn viewer_scroll_in_content(
viewer: &EntryViewer,
lines: &[Line<'_>],
width: u16,
height: u16,
) -> u16 {
let row_heights = lines
.iter()
.map(|line| {
Paragraph::new(line.clone())
.wrap(Wrap { trim: false })
.line_count(width)
.max(1)
})
.collect::<Vec<_>>();
let focused = viewer.focused_index().unwrap_or_default();
let field_heights = viewer
.document()
.fields()
.iter()
.map(|field| {
std::str::from_utf8(field.value()).map_or(1, |value| value.split('\n').count())
})
.collect::<Vec<_>>();
let focused_line = field_heights.iter().take(focused).sum();
let focused_lines = field_heights.get(focused).copied().unwrap_or(1);
let focused_start = row_heights.iter().take(focused_line).sum();
let focused_height = row_heights
.iter()
.skip(focused_line)
.take(focused_lines)
.sum();
let total_rows = row_heights.iter().sum();
let scroll = viewer.ensure_focus_visible(
focused_start,
focused_height,
total_rows,
usize::from(height),
);
u16::try_from(scroll).unwrap_or(u16::MAX)
}
fn otp_ascii_row(code: &[u8], row: usize) -> Zeroizing<String> {
let mut rendered = Zeroizing::new(String::with_capacity(
code.len().saturating_mul(4).saturating_sub(1),
));
for (index, digit) in code.iter().copied().enumerate() {
if index != 0 {
rendered.push(' ');
}
rendered.push_str(otp_digit_glyph(digit, row));
}
rendered
}
fn otp_digit_glyph(digit: u8, row: usize) -> &'static str {
const GLYPHS: [[&str; LARGE_OTP_ROWS]; 10] = [
["000", "0 0", "0 0", "0 0", "000"],
[" 1 ", "11 ", " 1 ", " 1 ", "111"],
["222", " 2", "222", "2 ", "222"],
["333", " 3", "333", " 3", "333"],
["4 4", "4 4", "444", " 4", " 4"],
["555", "5 ", "555", " 5", "555"],
["666", "6 ", "666", "6 6", "666"],
["777", " 7", " 7 ", " 7 ", " 7 "],
["888", "8 8", "888", "8 8", "888"],
["999", "9 9", "999", " 9", "999"],
];
let index = usize::from(digit.saturating_sub(b'0'));
GLYPHS
.get(index)
.and_then(|glyph| glyph.get(row))
.copied()
.unwrap_or(" ")
}
fn countdown_bar(remaining: u64, period: u64, width: usize) -> String {
let label = format!("{remaining}s");
let bar_width = width.saturating_sub(label.len().saturating_add(1));
let period = period.max(1);
let remaining = remaining.min(period);
let filled = if remaining == 0 {
0
} else {
usize::try_from((u128::from(remaining) * bar_width as u128).div_ceil(u128::from(period)))
.unwrap_or(bar_width)
.min(bar_width)
};
format!(
"{}{} {label}",
"".repeat(filled),
"".repeat(bar_width.saturating_sub(filled))
)
}
fn pane_block<'a>(
title: impl Into<Line<'a>>,
focused: bool,
capability: ColorCapability,
) -> Block<'a> {
let style = if focused {
match capability {
ColorCapability::Color => Style::default().fg(ACCENT_COLOR),
ColorCapability::Monochrome => Style::default().add_modifier(Modifier::BOLD),
}
} else {
Style::default()
};
Block::default()
.borders(Borders::ALL)
.title(title)
.border_style(style)
}
fn selected_style() -> Style {
Style::default()
.fg(SELECTED_FOREGROUND)
.bg(SELECTED_BACKGROUND)
.add_modifier(Modifier::BOLD)
}
fn selected_line<'a>(mut spans: Vec<Span<'a>>) -> Line<'a> {
let style = selected_style();
for span in &mut spans {
span.style = style;
}
Line::from(spans).style(style)
}
fn sidebar_lines(app: &App) -> Vec<Line<'static>> {
let selected = app.sidebar().selected();
let rows = app.sidebar().visible_window();
if rows.is_empty() {
if app.is_busy() {
return vec![Line::from("Loading password-store tree…")];
}
return vec![Line::from("No password entries.")];
}
rows.into_iter()
.map(|row| {
let marker = if row.id.is_directory() {
if row.expanded { "" } else { "" }
} else {
""
};
let mut indicators = String::new();
if row.indicators.is_locked() {
indicators.push_str(" L");
}
if row.indicators.is_changed() {
indicators.push_str(" *");
}
if row.indicators.has_conflict() {
indicators.push_str(" !");
}
let spans = vec![Span::raw(format!(
"{}{} {}{}",
" ".repeat(row.depth),
marker,
row.name,
indicators
))];
if selected == Some(&row.id) {
selected_line(spans)
} else {
Line::from(spans)
}
})
.collect()
}
fn main_text(app: &App) -> String {
match app.mode() {
Mode::Browser => "Select an entry from the sidebar.".to_owned(),
Mode::Viewer => app.selected_entry().map_or_else(
|| "Structured entry viewer".to_owned(),
|path| format!("Opening {path}"),
),
Mode::Editor => "Saving structured entry…".to_owned(),
Mode::Dialog if app.discard_confirmation() => {
"Discard all unsaved edits? Press y to discard, n or Esc to keep editing.".to_owned()
}
Mode::Dialog if app.hotp_confirmation() => {
"Generate this HOTP code? This advances and commits its counter. Press y to continue, n or Esc to cancel.".to_owned()
}
Mode::Dialog => "Complete or cancel the active dialog.".to_owned(),
Mode::Command => "Enter a command on the bottom line.".to_owned(),
Mode::Help | Mode::Locked => String::new(),
}
}
fn viewer_lines<'a>(
viewer: &'a EntryViewer,
otp_display: Option<&'a crate::app::OtpDisplay>,
) -> Vec<Line<'a>> {
let focused = viewer.focused_index();
let fields = viewer.document().display_fields();
if fields.is_empty() {
return vec![Line::from("This entry is empty.")];
}
let mut lines = Vec::new();
for (index, field) in fields.into_iter().enumerate() {
let metadata = field.metadata();
let label = metadata.name().map_or_else(
|| match metadata.kind() {
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
kind => format!("{kind:?}").to_ascii_lowercase(),
},
str::to_owned,
);
let values = std::str::from_utf8(field.value()).ok();
let value_lines = values.map_or(1, |value| value.split('\n').count());
for line_index in 0..value_lines {
let mut spans = Vec::new();
if line_index == 0 {
spans.push(Span::styled(
format!("{label}: "),
Style::default()
.fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
));
} else {
spans.push(Span::raw(" "));
}
match values {
Some("") if line_index == 0 => spans.push(Span::styled(
"(empty)",
Style::default().fg(Color::DarkGray),
)),
Some(value) => spans.push(Span::raw(
value
.split('\n')
.nth(line_index)
.unwrap_or_default()
.strip_suffix('\r')
.unwrap_or_else(|| value.split('\n').nth(line_index).unwrap_or_default()),
)),
None => spans.push(Span::styled(
"[non-UTF-8 value]",
Style::default().fg(Color::Yellow),
)),
}
if line_index == 0
&& 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),
));
if let Some(display) = otp_display
&& display
.field()
.is_none_or(|display_field| display_field == field.id())
{
let code = String::from_utf8_lossy(display.code().expose());
let validity = display.remaining_seconds().map_or_else(
|| {
display
.counter()
.map_or(String::new(), |counter| format!(", counter {counter}"))
},
|remaining| format!(", {remaining}s remaining"),
);
spans.insert(
0,
Span::styled(
format!("code {code}{validity} "),
Style::default()
.fg(Color::Green)
.add_modifier(Modifier::BOLD),
),
);
}
}
lines.push(if focused == Some(index) {
selected_line(spans)
} else {
Line::from(spans)
});
}
}
lines
}
fn editor_lines(editor: &EntryEditor) -> Vec<Line<'_>> {
let focused = editor.focused_index();
if editor.document().fields().is_empty() {
return vec![Line::from(
"This entry is empty. Press a to add its first field.",
)];
}
let mut lines = Vec::new();
for (index, field) in editor.document().fields().iter().enumerate() {
let selected = focused == Some(index);
let contents = editor
.focused_contents(field.id())
.unwrap_or_else(|| field.contents().expose());
let label = field.metadata().name().map_or_else(
|| format!("{:?}", field.metadata().kind()),
|name| format!("{:?} ({name})", field.metadata().kind()),
);
if let Ok(value) = std::str::from_utf8(contents) {
let mut offset = 0;
for (line_index, value_line) in value.split('\n').enumerate() {
let value_line = value_line.strip_suffix('\r').unwrap_or(value_line);
let mut spans = vec![Span::styled(
if line_index == 0 {
format!("#{:02} {label}: ", index + 1)
} else {
" ".to_owned()
},
Style::default()
.fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
)];
let cursor = editor.cursor().saturating_sub(offset);
if selected
&& editor.is_input_active()
&& editor.cursor() >= offset
&& cursor <= value_line.len()
&& value_line.is_char_boundary(cursor)
{
let (before, after) = value_line.split_at(cursor);
spans.push(Span::raw(before));
spans.push(Span::styled("", Style::default().fg(Color::Yellow)));
spans.push(Span::raw(after));
} else if value.is_empty() {
spans.push(Span::styled(
"(empty)",
Style::default().fg(Color::DarkGray),
));
} else {
spans.push(Span::raw(value_line));
}
lines.push(if selected {
selected_line(spans)
} else {
Line::from(spans)
});
offset += value_line.len() + 1;
}
} else {
let spans = vec![
Span::styled(
format!("#{:02} {label}: ", index + 1),
Style::default()
.fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
),
Span::styled(
"[non-UTF-8 field; editing will preserve bytes]",
Style::default().fg(Color::Yellow),
),
];
lines.push(if selected {
selected_line(spans)
} else {
Line::from(spans)
});
}
}
lines
}
fn grep_lines(view: &crate::search::GrepView) -> Vec<Line<'_>> {
if view.entries().is_empty() {
return vec![Line::from("No decrypted entries matched.")];
}
let mut lines = Vec::new();
for (index, entry) in view.entries().iter().enumerate() {
let selected = view.selected_index() == Some(index);
let entry_line = vec![Span::raw(format!(
"{} {}",
if selected { ">" } else { " " },
entry.path()
))];
lines.push(if selected {
selected_line(entry_line)
} else {
Line::from(entry_line).style(
Style::default()
.fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
)
});
for matched in entry.lines() {
let contents = std::str::from_utf8(matched.contents().expose())
.unwrap_or("[non-UTF-8 matched line]");
let prefix = if view.includes_line_numbers() {
format!("{}: ", matched.number())
} else {
String::new()
};
let matched = vec![Span::raw(format!(" {prefix}{contents}"))];
lines.push(if selected {
selected_line(matched)
} else {
Line::from(matched)
});
}
}
lines
}
fn git_lines(view: &crate::app::GitView) -> Vec<Line<'_>> {
let snapshot = view.snapshot();
let status = snapshot.status();
let mut lines = vec![
Line::styled(
view.message(),
Style::default()
.fg(ACCENT_COLOR)
.add_modifier(Modifier::BOLD),
),
Line::raw(format!("repository: {}", snapshot.root().display())),
Line::raw(format!("branch: {}", snapshot.branch())),
Line::raw(format!(
"worktree: {}",
if status.is_clean() {
"clean".to_owned()
} else {
format!(
"{} staged, {} unstaged",
status.staged().len(),
status.unstaged().len()
)
}
)),
];
if let Some(remote) = snapshot.remote() {
lines.push(Line::raw(format!(
"remote: {} {}",
remote.name(),
remote.url()
)));
lines.push(Line::raw(format!(
"relation: {} ahead, {} behind",
remote.ahead(),
remote.behind()
)));
} else {
lines.push(Line::styled(
"remote: not configured",
Style::default().fg(Color::Yellow),
));
}
if !view.conflicts().is_empty() {
lines.push(Line::styled(
"merge conflicts:",
Style::default().fg(Color::Red).add_modifier(Modifier::BOLD),
));
lines.extend(view.conflicts().iter().map(|conflict| {
Line::raw(format!(
" {:?}: {}",
conflict.kind(),
conflict.path().display()
))
}));
lines.push(Line::raw(
"Use :git resolve-local or :git resolve-remote for all listed paths.",
));
}
if let Some(details) = view.details() {
lines.push(Line::raw(""));
lines.extend(
String::from_utf8_lossy(details.expose())
.lines()
.map(|line| Line::raw(line.to_owned())),
);
} else if !snapshot.recent().is_empty() {
lines.push(Line::raw(""));
lines.push(Line::styled(
"recent commits:",
Style::default().add_modifier(Modifier::BOLD),
));
lines.extend(snapshot.recent().iter().map(|entry| {
Line::raw(format!(
" {} {}",
&entry.id()[..entry.id().len().min(12)],
entry.message()
))
}));
}
lines
}
fn mode_title(mode: Mode) -> &'static str {
match mode {
Mode::Browser => "Browser",
Mode::Viewer => "Viewer",
Mode::Editor => "Editor",
Mode::Dialog => "Dialog",
Mode::Help => "Help",
Mode::Command => "Command",
Mode::Locked => "Locked",
}
}
fn status_line(app: &App) -> Paragraph<'_> {
Paragraph::new(status_content(app))
}
fn status_content(app: &App) -> Line<'_> {
let busy = if app.is_busy() { " [working]" } else { "" };
let warning = app
.remaining_lease()
.filter(|remaining| remaining.as_secs() <= 30)
.map_or_else(String::new, |remaining| {
format!(" [locks in {}s]", remaining.as_secs())
});
let clipboard = if app.clipboard_pending() {
app.clipboard_remaining_seconds().map_or_else(
|| " [copying to clipboard]".to_owned(),
|remaining| format!(" [clipboard clears in {remaining}s]"),
)
} else {
String::new()
};
Line::from(vec![
Span::styled(" status ", selected_style()),
Span::raw(format!(" {}{busy}{warning}{clipboard}", app.status())),
])
}
fn context_line(app: &App) -> Paragraph<'static> {
if app.mode() == Mode::Dialog && app.workflow().is_some() {
return Paragraph::new("Tab/Shift-Tab focus Space toggle Ctrl-S submit Esc cancel");
}
if app.grep_view().is_some() {
return Paragraph::new("j/k result Enter open entry Esc close decrypted results");
}
let text = context_actions(app.mode())
.map(|spec| format!("{} {}", spec.bindings[0].display, spec.label))
.collect::<Vec<_>>()
.join(" ");
Paragraph::new(text)
}
fn prompt_line(app: &App) -> Paragraph<'static> {
match app.mode() {
Mode::Command => Paragraph::new(format!(":{}", app.command_line().display()))
.style(Style::default().fg(Color::Yellow)),
Mode::Dialog if app.workflow().is_some() => {
Paragraph::new("form> ").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) => {
Paragraph::new("-- INSERT -- Esc stops input; Tab changes field; C-s saves")
.style(Style::default().fg(Color::Yellow))
}
_ if app.sidebar().is_editing_filter() => {
Paragraph::new(format!("/{}", app.sidebar().filter_query()))
.style(Style::default().fg(Color::Yellow))
}
_ => Paragraph::new(""),
}
}
#[cfg(test)]
mod tests {
use std::ffi::OsStr;
use ratatui::{Terminal, backend::TestBackend, buffer::Buffer};
use super::*;
use crate::app::Transition;
use crate::sidebar::TestTreeNode;
use crate::viewer::test_support::{
fixture_document, fixture_document_from_plaintext, fixture_grep,
};
fn render(width: u16, height: u16, app: &App) -> String {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal.draw(|frame| draw(frame, app)).expect("draw");
let buffer = terminal.backend().buffer();
(0..height)
.map(|y| {
(0..width)
.map(|x| buffer[(x, y)].symbol())
.collect::<String>()
})
.collect::<Vec<_>>()
.join("\n")
}
fn render_buffer(width: u16, height: u16, app: &App, capability: ColorCapability) -> Buffer {
let backend = TestBackend::new(width, height);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| draw_with_color_capability(frame, app, capability))
.expect("draw");
terminal.backend().buffer().clone()
}
fn border_symbols(buffer: &Buffer, area: Rect) -> Vec<String> {
let right = area.right().saturating_sub(1);
let bottom = area.bottom().saturating_sub(1);
let mut symbols = Vec::new();
for x in area.left()..=right {
symbols.push(buffer[(x, area.top())].symbol().to_owned());
symbols.push(buffer[(x, bottom)].symbol().to_owned());
}
for y in area.top().saturating_add(1)..bottom {
symbols.push(buffer[(area.left(), y)].symbol().to_owned());
symbols.push(buffer[(right, y)].symbol().to_owned());
}
symbols
}
#[test]
fn sizes_have_explicit_layout_classes() {
assert_eq!(layout_class(Rect::new(0, 0, 39, 20)), LayoutClass::TooSmall);
assert_eq!(layout_class(Rect::new(0, 0, 40, 7)), LayoutClass::TooSmall);
assert_eq!(layout_class(Rect::new(0, 0, 60, 20)), LayoutClass::Narrow);
assert_eq!(layout_class(Rect::new(0, 0, 100, 20)), LayoutClass::Normal);
assert_eq!(layout_class(Rect::new(0, 0, 140, 20)), LayoutClass::Wide);
}
#[test]
fn large_otp_inverts_only_the_digits_that_form_each_glyph() {
let line = large_otp_line("1 22");
assert_eq!(line.to_string(), "1 22");
for span in line.spans {
if span.content == " " {
assert_eq!(span.style, Style::default());
} else {
assert_eq!(span.style.fg, Some(Color::Black));
assert_eq!(span.style.bg, Some(Color::Green));
assert!(span.style.add_modifier.contains(Modifier::BOLD));
}
}
}
#[test]
fn color_capability_has_a_complete_monochrome_fallback() {
assert_eq!(
ColorCapability::from_environment(Some(OsStr::new("xterm-256color")), false),
ColorCapability::Color
);
assert_eq!(
ColorCapability::from_environment(Some(OsStr::new("dumb")), false),
ColorCapability::Monochrome
);
assert_eq!(
ColorCapability::from_environment(Some(OsStr::new("xterm")), true),
ColorCapability::Monochrome
);
let app = App::new();
let backend = TestBackend::new(100, 20);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Monochrome))
.expect("monochrome draw");
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.all(|cell| { cell.fg == Color::Reset && cell.bg == Color::Reset })
);
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
terminal
.draw(|frame| draw_with_color_capability(frame, &app, ColorCapability::Color))
.expect("color draw");
assert!(
terminal
.backend()
.buffer()
.content
.iter()
.any(|cell| { cell.fg != Color::Reset || cell.bg != Color::Reset })
);
}
#[test]
fn pane_focus_changes_only_the_shared_accent_border_style() {
let mut app = App::new();
let sidebar = Rect::new(0, 0, 35, 17);
let main = Rect::new(35, 0, 65, 17);
let sidebar_focused = render_buffer(100, 20, &app, ColorCapability::Color);
assert_eq!(sidebar_focused[(0, 0)].fg, ACCENT_COLOR);
assert_eq!(sidebar_focused[(0, 0)].bg, Color::Reset);
assert!(!sidebar_focused[(0, 0)].modifier.contains(Modifier::BOLD));
assert_eq!(sidebar_focused[(35, 0)].fg, Color::Reset);
assert!(!sidebar_focused[(35, 0)].modifier.contains(Modifier::BOLD));
app.dispatch(crate::action::Action::FocusNext);
let main_focused = render_buffer(100, 20, &app, ColorCapability::Color);
assert_eq!(main_focused[(0, 0)].fg, Color::Reset);
assert!(!main_focused[(0, 0)].modifier.contains(Modifier::BOLD));
assert_eq!(main_focused[(35, 0)].fg, ACCENT_COLOR);
assert_eq!(main_focused[(35, 0)].bg, Color::Reset);
assert!(!main_focused[(35, 0)].modifier.contains(Modifier::BOLD));
assert_eq!(
border_symbols(&sidebar_focused, sidebar),
border_symbols(&main_focused, sidebar)
);
assert_eq!(
border_symbols(&sidebar_focused, main),
border_symbols(&main_focused, main)
);
let main_monochrome = render_buffer(100, 20, &app, ColorCapability::Monochrome);
assert_eq!(main_monochrome[(0, 0)].fg, Color::Reset);
assert_eq!(main_monochrome[(35, 0)].fg, Color::Reset);
assert!(!main_monochrome[(0, 0)].modifier.contains(Modifier::BOLD));
assert!(main_monochrome[(35, 0)].modifier.contains(Modifier::BOLD));
app.dispatch(crate::action::Action::Help);
let help = render_buffer(100, 20, &app, ColorCapability::Color);
assert_eq!(help[(0, 0)].fg, ACCENT_COLOR);
assert_eq!(help[(0, 0)].bg, Color::Reset);
}
#[test]
fn minimum_size_message_is_clear() {
let output = render(39, 8, &App::new());
assert!(output.contains("Terminal too small"));
assert!(output.contains("40×8"));
}
#[test]
fn normal_shell_contains_every_required_region() {
let output = render(100, 20, &App::new());
assert!(output.contains("Passwords"));
assert!(output.contains("Browser"));
assert!(output.contains("status"));
assert!(output.contains("? help"));
}
#[test]
fn narrow_and_wide_shells_render_without_losing_panes() {
for width in [60, 140] {
let output = render(width, 20, &App::new());
assert!(output.contains("Passwords"));
assert!(output.contains("Browser"));
}
}
#[test]
fn help_is_generated_from_the_action_registry() {
let mut app = App::new();
assert!(app.transition(Transition::OpenHelp));
let output = render(140, 35, &app);
for spec in crate::action::help_actions(Mode::Browser) {
assert!(output.contains(spec.label));
assert!(output.contains(spec.command));
}
assert!(output.contains("d d"));
assert!(output.contains("confirmation"));
assert!(output.contains("g p"));
assert!(!output.contains("edit field"));
app.dispatch(crate::action::Action::Cancel);
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::Help);
let viewer_help = render(140, 35, &app);
assert!(viewer_help.contains("edit entry"));
assert!(viewer_help.contains("copy field"));
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("Remove entry or folder"));
assert!(confirmation.contains("Confirm permanent removal: no"));
assert!(confirmation.contains("Ctrl-S submit"));
}
#[test]
fn workflow_dialog_is_keyboard_discoverable_and_masks_inserted_secrets() {
let mut app = App::new();
app.dispatch(crate::action::Action::InsertEntry);
for character in "nested/account".chars() {
app.handle_workflow_input(
crossterm::event::KeyCode::Char(character),
crossterm::event::KeyModifiers::NONE,
);
}
app.handle_workflow_input(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
app.handle_workflow_input(
crossterm::event::KeyCode::Tab,
crossterm::event::KeyModifiers::NONE,
);
for character in "never-render-this".chars() {
app.handle_workflow_input(
crossterm::event::KeyCode::Char(character),
crossterm::event::KeyModifiers::NONE,
);
}
let output = render(100, 24, &app);
assert!(output.contains("Insert entry"));
assert!(output.contains("nested/account"));
assert!(output.contains("Ctrl-S submit"));
assert!(output.contains("••••••••"));
assert!(!output.contains("never-render-this"));
}
#[test]
fn hierarchy_selection_and_storage_indicators_have_stable_rendering() {
let mut app = App::new();
app.resize(100, 20);
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "personal".to_owned(),
name: "personal".to_owned(),
directory: true,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![TestTreeNode {
path: "personal/咖啡".to_owned(),
name: "咖啡".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::new(true, true, true),
children: vec![],
}],
}]);
app.sidebar_mut().move_child();
let output = render(100, 20, &app);
assert!(output.contains("▼ personal"));
assert!(output.contains('咖'));
assert!(output.contains("L * !"));
}
#[test]
fn scrolling_focus_filter_prompt_and_resize_are_rendered() {
let mut app = App::new();
app.resize(60, 10);
app.sidebar_mut().replace_test_tree(
(0..20)
.map(|index| TestTreeNode {
path: format!("entry-{index:02}"),
name: format!("entry-{index:02}"),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
})
.collect(),
);
app.sidebar_mut().move_last();
app.sidebar_mut().begin_filter();
app.sidebar_mut().push_filter_character('咖');
let narrow = render(60, 10, &app);
assert!(narrow.contains("entry-19"));
assert!(narrow.contains("/咖"));
assert!(!narrow.contains("entry-00"));
app.dispatch(crate::action::Action::FocusNext);
let wide = render(140, 20, &app);
assert!(wide.contains("Browser"));
}
#[test]
fn viewer_scroll_changes_only_when_focus_crosses_the_rendered_viewport() {
let mut document = fixture_document("email/personal");
for index in 0..5 {
let insertion = document.fields().len();
document
.add(
insertion,
ironstorage::document::EntryFieldDraft::field(
format!("extra-{index}"),
format!("value-{index}").into_bytes(),
)
.expect("field draft"),
)
.expect("append test field");
}
let mut app = App::new();
app.open_test_document("many/fields", document);
for action in [
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
crate::action::Action::Next,
crate::action::Action::FocusNext,
] {
render(100, 20, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.dispatch(action);
}
let mut small = App::new();
let mut document = fixture_document("email/personal");
for index in 0..5 {
let insertion = document.fields().len();
document
.add(
insertion,
ironstorage::document::EntryFieldDraft::field(
format!("extra-{index}"),
format!("value-{index}").into_bytes(),
)
.expect("field draft"),
)
.expect("append test field");
}
small.open_test_document("many/fields", document);
for (expected_scroll, action) in [
(0, crate::action::Action::Next),
(0, crate::action::Action::FocusNext),
(0, crate::action::Action::Next),
(1, crate::action::Action::FocusNext),
(2, crate::action::Action::Next),
] {
let output = render(40, 8, &small);
assert!(output.contains("Viewer"));
assert_eq!(small.viewer().expect("viewer").scroll(), expected_scroll);
small.dispatch(action);
}
small.dispatch(crate::action::Action::Previous);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::FocusPrevious);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::Previous);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 2);
small.dispatch(crate::action::Action::FocusPrevious);
render(40, 8, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 1);
render(100, 20, &small);
assert_eq!(small.viewer().expect("viewer").scroll(), 0);
}
#[test]
fn wrapped_viewer_fields_keep_the_selected_field_on_screen() {
let mut document = fixture_document("email/personal");
document
.add(
1,
ironstorage::document::EntryFieldDraft::field(
"url",
"xxxxxx ".repeat(15).into_bytes(),
)
.expect("field draft"),
)
.expect("prepend wrapped field");
let mut app = App::new();
app.open_test_document("many/wrapped-fields", document);
render(40, 8, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.dispatch(crate::action::Action::FocusNext);
app.dispatch(crate::action::Action::FocusNext);
let wrapped = render(40, 8, &app);
assert!(wrapped.contains("url: xxxxxx"));
let wrapped_scroll = app.viewer().expect("viewer").scroll();
app.dispatch(crate::action::Action::FocusNext);
render(40, 8, &app);
assert_eq!(
app.viewer()
.and_then(crate::viewer::EntryViewer::focused_field)
.map(|field| field.metadata().kind()),
Some(ironstorage::document::EntryFieldKind::Url)
);
assert!(app.viewer().expect("viewer").scroll() > wrapped_scroll);
render(140, 20, &app);
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
}
#[test]
fn lease_warning_is_concise_and_never_contains_entry_state() {
let mut app = App::new();
app.update_remaining_lease(Some(std::time::Duration::from_secs(30)));
let output = render(100, 20, &app);
assert!(output.contains("locks in 30s"));
}
#[test]
fn status_renders_the_active_clipboard_deadline() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
let presentation = match app.dispatch(crate::action::Action::Copy) {
crate::app::AppEffect::CopyFocused { presentation, .. } => presentation,
effect => panic!("unexpected effect: {effect:?}"),
};
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::ClipboardStarted {
presentation,
deadline: std::time::Instant::now() + std::time::Duration::from_secs(45),
}),
});
let output = render(100, 20, &app);
assert!(output.contains("Secret copied"));
assert!(output.contains("clipboard clears in 45s"));
}
#[test]
fn viewer_renders_storage_sensitive_fields_and_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: pässwörd-猫"));
assert!(output.contains("login:"));
assert!(output.contains('用'));
assert!(output.contains("@example.test"));
assert!(output.contains("notes:"));
}
assert!(!format!("{app:?}").contains("pässwörd-猫"));
}
#[test]
fn multiline_fields_render_copy_and_select_as_one_tui_field() {
let (_store, document) = fixture_document_from_plaintext(
"documents/multiline",
b"password\ncomments: Recovery codes:\none\ntwo\nurl: https://example.test\n",
);
assert_eq!(document.fields().len(), 3);
let mut app = App::new();
app.open_test_document("documents/multiline", document);
app.dispatch(crate::action::Action::FocusNext);
app.dispatch(crate::action::Action::FocusNext);
let viewer = app.viewer().expect("viewer");
assert_eq!(
viewer.copy_focused().expect("multiline copy").expose(),
b"Recovery codes:\none\ntwo"
);
let lines = viewer_lines(viewer, None);
assert_eq!(lines.len(), 5);
for line in &lines[2..5] {
assert!(line.spans.iter().all(|span| {
span.style.fg == Some(SELECTED_FOREGROUND)
&& span.style.bg == Some(SELECTED_BACKGROUND)
}));
}
let rendered = render(80, 14, &app);
for expected in ["comments: Recovery codes:", "one", "two"] {
assert!(rendered.contains(expected), "missing {expected}");
}
}
#[test]
fn authenticated_viewer_renders_every_value_until_entry_close_or_relock() {
let (_store, document) = fixture_document_from_plaintext(
"metadata/account",
concat!(
"vault-secret\n",
"autoType_enabled: true\n",
"icon: Internet\n",
"icon: Custom Icon\n",
"title: Café\n",
"custom: hidden-value\n",
"free-form note value\n",
"empty:\n",
"unicode: 密碼-猫\n",
"otp: otpauth://totp/IronStorage:alice?secret=JBSWY3DPEHPK3PXP&issuer=IronStorage\n",
"long: abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz\n",
)
.as_bytes(),
);
let mut app = App::new();
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "metadata/account".to_owned(),
name: "account".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
}]);
assert!(matches!(
app.dispatch(crate::action::Action::Activate),
crate::app::AppEffect::AuthenticateEntry(entry) if entry == "metadata/account"
));
assert!(app.authentication_granted("metadata/account".to_owned()));
let token = app.begin_request();
assert_eq!(
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::DocumentLoaded {
entry: "metadata/account".to_owned(),
document: Box::new(document),
}),
}),
crate::app::ResultDisposition::Applied
);
let all_values = viewer_lines(app.viewer().expect("viewer"), None)
.iter()
.flat_map(|line| line.spans.iter())
.map(|span| span.content.as_ref())
.collect::<String>();
for expected in [
"vault-secret",
"autoType_enabled: true",
"icon: Internet",
"icon: Custom Icon",
"title: Café",
"custom: hidden-value",
"free-form note value",
"empty: (empty)",
"unicode: 密碼-猫",
"JBSWY3DPEHPK3PXP",
"abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz",
] {
assert!(all_values.contains(expected), "missing {expected}");
}
assert!(render(140, 30, &app).contains("vault-secret"));
app.dispatch(crate::action::Action::FocusNext);
assert!(render(140, 30, &app).contains("vault-secret"));
app.dispatch(crate::action::Action::Help);
app.dispatch(crate::action::Action::Cancel);
assert!(render(140, 30, &app).contains("vault-secret"));
app.dispatch(crate::action::Action::CloseEntry);
assert!(!render(140, 30, &app).contains("vault-secret"));
app.open_test_document(
"metadata/account",
fixture_document_from_plaintext("metadata/account-locked", b"lock-secret\n").1,
);
app.forced_relock("Authentication expired");
let locked = render(140, 20, &app);
assert!(!locked.contains("lock-secret"));
assert!(!locked.contains("autoType_enabled: true"));
}
#[test]
fn focus_changes_keep_values_visible_and_lock_removes_them() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
assert!(render(100, 20, &app).contains("correct horse fixture"));
app.dispatch(crate::action::Action::FocusNext);
assert!(render(100, 20, &app).contains("correct horse fixture"));
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_and_authenticated_uri_are_visible() {
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 otp_code_qr_resize_and_lock_lifecycle_clear_after_lock() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let wrong_field = document.fields()[0].id();
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(wrong_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
assert!(!render(120, 20, &app).contains("123456"));
app.dispatch(crate::action::Action::FocusNext);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 72,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
app.dispatch(crate::action::Action::FocusPrevious);
let large = render(120, 20, &app);
assert!(large.contains("TOTP code — 12s remaining"));
assert!(large.contains("222 333"));
assert!(!large.contains("code 123456"));
assert!(large.contains("JBSWY3DPEHPK3PXP"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
let full_bar = large.matches('█').count();
let compact = render(60, 16, &app);
assert!(compact.contains("123456 — 12s remaining"));
assert!(!compact.contains("TOTP code —"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
app.observe_time(66);
let progressed = render(120, 20, &app);
assert!(progressed.contains("TOTP code — 6s remaining"));
let progressed_bar = progressed.matches('█').count();
assert!(progressed_bar < full_bar);
assert!(progressed_bar > 0);
app.observe_time(72);
let expired = render(120, 20, &app);
assert!(expired.contains("0s remaining"));
assert_eq!(expired.matches('█').count(), 0);
assert_eq!(
app.begin_totp_refresh(),
Some(("otp/totp".to_owned(), otp_field))
);
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"654321".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 102,
period: 30,
},
observed_at: 72,
clipboard: false,
tree: None,
}),
});
let refreshed = render(120, 20, &app);
assert!(refreshed.contains("TOTP code — 30s remaining"));
assert!(refreshed.contains("666 555"));
assert!(refreshed.contains("333 222"));
assert!(!refreshed.contains("222 333"));
assert!(refreshed.matches('█').count() > full_bar);
let minimum = render(40, 8, &app);
assert!(minimum.contains("654321 — 30s remaining"));
assert!(minimum.contains("password:"));
assert!(!minimum.contains("••••••••"));
assert_eq!(app.viewer().expect("viewer").scroll(), 0);
let payload = ironstorage::repository::SecretBytes::new(
b"otpauth://totp/test?secret=NEVER-RENDER".to_vec(),
);
let qr = ironstorage::presentation::QrMatrix::encode(&payload).expect("QR");
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpUriFinished {
entry: "otp/totp".to_owned(),
presentation: crate::app::OtpPresentationTarget::Qr,
payload,
qr: Some(qr),
}),
});
let resized = render(40, 8, &app);
assert!(resized.contains("too small for OTP QR"));
assert!(!resized.contains("NEVER-RENDER"));
assert!(matches!(
app.dispatch(crate::action::Action::Lock),
crate::app::AppEffect::ManualLock
));
let locked = render(120, 20, &app);
assert!(!locked.contains("123456"));
assert!(!locked.contains("666 555"));
assert!(!locked.contains("NEVER-RENDER"));
}
#[test]
fn selected_fields_status_and_results_keep_one_high_contrast_style() {
let mut app = App::new();
let document = fixture_document("otp/totp");
let otp_field = document
.fields()
.iter()
.find(|field| field.metadata().otp().is_some())
.expect("OTP field")
.id();
app.open_test_document("otp/totp", document);
while app
.viewer()
.and_then(EntryViewer::focused_field)
.map(|field| field.id())
!= Some(otp_field)
{
app.dispatch(crate::action::Action::FocusNext);
}
let token = app.begin_request();
app.apply_result(crate::app::AsyncResult {
token,
payload: Ok(crate::app::AsyncPayload::OtpCodeFinished {
entry: "otp/totp".to_owned(),
field: Some(otp_field),
code: ironstorage::repository::SecretBytes::new(b"123456".to_vec()),
validity: ironstorage::otp::OtpCodeValidity::Timed {
valid_until: 90,
period: 30,
},
observed_at: 60,
clipboard: false,
tree: None,
}),
});
let viewer = app.viewer().expect("viewer");
let lines = viewer_lines(viewer, app.otp_display());
let selected = &lines[viewer.focused_index().expect("focused field")];
assert!(
selected
.spans
.iter()
.any(|span| span.content.contains("code 123456"))
);
assert!(selected.spans.iter().all(|span| {
span.style.fg == Some(SELECTED_FOREGROUND)
&& span.style.bg == Some(SELECTED_BACKGROUND)
&& span.style.add_modifier.contains(Modifier::BOLD)
}));
let editor = EntryEditor::new(fixture_document("email/personal"));
let editor_selected = &editor_lines(&editor)[0];
assert!(editor_selected.spans.iter().all(|span| {
span.style.fg == Some(SELECTED_FOREGROUND)
&& span.style.bg == Some(SELECTED_BACKGROUND)
&& span.style.add_modifier.contains(Modifier::BOLD)
}));
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
path: "otp/totp".to_owned(),
name: "totp".to_owned(),
directory: false,
indicators: ironstorage::read::TreeNodeIndicators::default(),
children: vec![],
}]);
let sidebar_selected = &sidebar_lines(&app)[0];
assert!(sidebar_selected.spans.iter().all(|span| {
span.style.fg == Some(SELECTED_FOREGROUND)
&& span.style.bg == Some(SELECTED_BACKGROUND)
&& span.style.add_modifier.contains(Modifier::BOLD)
}));
let search = crate::search::GrepView::new(fixture_grep());
let search_lines = grep_lines(&search);
let selected_search_lines = 1 + search.entries()[0].lines().len();
assert!(
search_lines[..selected_search_lines]
.iter()
.flat_map(|line| &line.spans)
.all(|span| {
span.style.fg == Some(SELECTED_FOREGROUND)
&& span.style.bg == Some(SELECTED_BACKGROUND)
&& span.style.add_modifier.contains(Modifier::BOLD)
})
);
let status = status_content(&app);
assert_eq!(status.spans[0].style, selected_style());
let backend = TestBackend::new(60, 16);
let mut terminal = Terminal::new(backend).expect("test terminal");
terminal
.draw(|frame| {
draw_with_color_capability(frame, &app, ColorCapability::Monochrome);
})
.expect("monochrome viewer draw");
let buffer = terminal.backend().buffer();
assert!(
buffer
.content
.iter()
.any(|cell| cell.modifier.contains(Modifier::REVERSED))
);
assert!(
buffer
.content
.iter()
.all(|cell| cell.fg == Color::Reset && cell.bg == Color::Reset)
);
}
#[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());
}
#[test]
fn editor_shows_sensitive_input_while_the_entry_is_unlocked() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::BeginInput);
app.handle_editor_input(crossterm::event::KeyCode::Char('x'));
for width in [60, 100, 140] {
let visible = render(width, 20, &app);
assert!(visible.contains("correct horse fixturex"));
assert!(!visible.contains("••••••••"));
}
assert!(!format!("{app:?}").contains("correct horse fixturex"));
}
#[test]
fn dirty_editor_requires_explicit_discard_and_relock_discards_from_dialog() {
let mut app = App::new();
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::AddField);
app.dispatch(crate::action::Action::Cancel);
assert_eq!(app.mode(), Mode::Dialog);
assert!(app.discard_confirmation());
assert!(render(100, 20, &app).contains("Discard all unsaved edits"));
app.dispatch(crate::action::Action::KeepEditing);
assert_eq!(app.mode(), Mode::Editor);
app.dispatch(crate::action::Action::Cancel);
app.dispatch(crate::action::Action::ConfirmDiscard);
assert_eq!(app.mode(), Mode::Browser);
assert!(app.editor().is_none());
app.open_test_document("email/personal", fixture_document("email/personal"));
app.dispatch(crate::action::Action::EditEntry);
app.dispatch(crate::action::Action::AddField);
app.dispatch(crate::action::Action::Cancel);
app.forced_relock("test expiry");
assert_eq!(app.mode(), Mode::Locked);
assert!(app.editor().is_none());
assert!(app.status().contains("unsaved edits were discarded"));
}
}