//! 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::>() .join(", "); format!( "{bindings:>10} {:<20} :{:<16} {}", spec.label, spec.command, spec.help() ) }) .collect::>(); let lines = if area.width >= 120 { entries .chunks(2) .map(|chunk| Line::raw(chunk.join(" "))) .collect::>() } else { entries.into_iter().map(Line::raw).collect::>() }; 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::(); 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::>(); 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::>(); 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::>(), ) } 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::>(); 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::>(); 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 { 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>, 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>) -> 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> { 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> { 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> { 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> { 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> { 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::>() .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::() }) .collect::>() .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 { 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::(); 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")); } }