2062 lines
73 KiB
Rust
2062 lines
73 KiB
Rust
//! 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"));
|
||
}
|
||
}
|