680 lines
24 KiB
Rust
680 lines
24 KiB
Rust
//! Responsive Ratatui rendering for the application shell.
|
||
|
||
use ratatui::{
|
||
Frame,
|
||
layout::{Constraint, Direction, Layout, Rect},
|
||
style::{Color, Modifier, Style},
|
||
text::{Line, Span},
|
||
widgets::{Block, Borders, Paragraph, Wrap},
|
||
};
|
||
|
||
use crate::{
|
||
action::available_actions,
|
||
app::{App, Mode, PaneFocus},
|
||
editor::EntryEditor,
|
||
viewer::EntryViewer,
|
||
};
|
||
|
||
const MINIMUM_WIDTH: u16 = 40;
|
||
const MINIMUM_HEIGHT: u16 = 8;
|
||
|
||
#[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) {
|
||
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]);
|
||
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) {
|
||
if app.mode() == Mode::Locked {
|
||
frame.render_widget(
|
||
Paragraph::new("The password store is locked. Authentication is required.")
|
||
.block(Block::bordered().title("Locked"))
|
||
.wrap(Wrap { trim: true }),
|
||
area,
|
||
);
|
||
return;
|
||
}
|
||
|
||
if app.mode() == Mode::Help {
|
||
let available_rows = usize::from(area.height.saturating_sub(2)).max(1);
|
||
let lines = (0..available_rows).map(|row| {
|
||
let mut spans = Vec::new();
|
||
for index in (row..crate::action::ACTIONS.len()).step_by(available_rows) {
|
||
let spec = &crate::action::ACTIONS[index];
|
||
spans.push(Span::styled(
|
||
format!("{:>6}", spec.bindings[0].display),
|
||
Style::default().fg(Color::Cyan),
|
||
));
|
||
spans.push(Span::raw(format!(
|
||
" {:<16} :{:<16}",
|
||
spec.label, spec.command
|
||
)));
|
||
}
|
||
Line::from(spans)
|
||
});
|
||
frame.render_widget(
|
||
Paragraph::new(lines.collect::<Vec<_>>())
|
||
.block(Block::bordered().title("Contextual help"))
|
||
.wrap(Wrap { trim: false }),
|
||
area,
|
||
);
|
||
return;
|
||
}
|
||
|
||
let class = layout_class(frame.area());
|
||
let (direction, constraints) = match class {
|
||
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))
|
||
.wrap(Wrap { trim: false }),
|
||
panes[0],
|
||
);
|
||
let main = match app.mode() {
|
||
Mode::Viewer => app.viewer().map_or_else(
|
||
|| Paragraph::new(main_text(app)),
|
||
|viewer| {
|
||
Paragraph::new(viewer_lines(viewer))
|
||
.scroll((u16::try_from(viewer.scroll()).unwrap_or(u16::MAX), 0))
|
||
},
|
||
),
|
||
Mode::Editor => app.editor().map_or_else(
|
||
|| Paragraph::new(main_text(app)),
|
||
|editor| {
|
||
Paragraph::new(editor_lines(editor)).scroll((
|
||
u16::try_from(editor.focused_index().unwrap_or_default()).unwrap_or(u16::MAX),
|
||
0,
|
||
))
|
||
},
|
||
),
|
||
_ => Paragraph::new(main_text(app)),
|
||
};
|
||
frame.render_widget(
|
||
main.block(pane_block(
|
||
mode_title(app.mode()),
|
||
app.focus() == PaneFocus::Main,
|
||
))
|
||
.wrap(Wrap { trim: false }),
|
||
panes[1],
|
||
);
|
||
}
|
||
|
||
fn pane_block(title: &'static str, focused: bool) -> Block<'static> {
|
||
let style = if focused {
|
||
Style::default()
|
||
.fg(Color::Cyan)
|
||
.add_modifier(Modifier::BOLD)
|
||
} else {
|
||
Style::default()
|
||
};
|
||
Block::default()
|
||
.borders(Borders::ALL)
|
||
.title(title)
|
||
.border_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 style = if selected == Some(&row.id) {
|
||
Style::default().bg(Color::Blue).fg(Color::White)
|
||
} else {
|
||
Style::default()
|
||
};
|
||
Line::styled(
|
||
format!(
|
||
"{}{} {}{}",
|
||
" ".repeat(row.depth),
|
||
marker,
|
||
row.name,
|
||
indicators
|
||
),
|
||
style,
|
||
)
|
||
})
|
||
.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 => "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(viewer: &EntryViewer) -> Vec<Line<'_>> {
|
||
let focused = viewer.focused_index();
|
||
if viewer.document().fields().is_empty() {
|
||
return vec![Line::from("This entry is empty.")];
|
||
}
|
||
|
||
viewer
|
||
.document()
|
||
.fields()
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, field)| {
|
||
let metadata = field.metadata();
|
||
let label = metadata.name().map_or_else(
|
||
|| match metadata.kind() {
|
||
ironstorage::document::EntryFieldKind::Note => format!("note {}", index + 1),
|
||
ironstorage::document::EntryFieldKind::Blank => format!("blank {}", index + 1),
|
||
kind => format!("{kind:?}").to_ascii_lowercase(),
|
||
},
|
||
str::to_owned,
|
||
);
|
||
let value = if metadata.sensitivity()
|
||
== ironstorage::document::EntrySensitivity::Sensitive
|
||
&& !viewer.is_revealed(field.id())
|
||
{
|
||
Span::styled("••••••••", Style::default().fg(Color::DarkGray))
|
||
} else if field.value().is_empty() {
|
||
Span::styled("(empty)", Style::default().fg(Color::DarkGray))
|
||
} else {
|
||
match std::str::from_utf8(field.value()) {
|
||
Ok(value) => Span::raw(value),
|
||
Err(_) => Span::styled("[non-UTF-8 value]", Style::default().fg(Color::Yellow)),
|
||
}
|
||
};
|
||
let mut spans = vec![
|
||
Span::styled(
|
||
format!("{label}: "),
|
||
Style::default()
|
||
.fg(Color::Cyan)
|
||
.add_modifier(Modifier::BOLD),
|
||
),
|
||
value,
|
||
];
|
||
if let Some(otp) = metadata.otp() {
|
||
let timing = otp.period().map_or_else(
|
||
|| format!("counter {}", otp.counter().unwrap_or_default()),
|
||
|period| format!("period {period}s"),
|
||
);
|
||
spans.push(Span::styled(
|
||
format!(
|
||
" [{:?}, {}, {:?}, {}, {} digits, {timing}]",
|
||
otp.kind(),
|
||
otp.issuer().unwrap_or("no issuer"),
|
||
otp.algorithm(),
|
||
otp.account(),
|
||
otp.digits(),
|
||
),
|
||
Style::default().fg(Color::DarkGray),
|
||
));
|
||
}
|
||
let line = Line::from(spans);
|
||
if focused == Some(index) {
|
||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||
} else {
|
||
line
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn 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.",
|
||
)];
|
||
}
|
||
|
||
editor
|
||
.document()
|
||
.fields()
|
||
.iter()
|
||
.enumerate()
|
||
.map(|(index, field)| {
|
||
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()),
|
||
);
|
||
let masked = field.metadata().sensitivity()
|
||
== ironstorage::document::EntrySensitivity::Sensitive
|
||
&& !editor.is_revealed(field.id());
|
||
let mut spans = vec![Span::styled(
|
||
format!("#{:02} {label}: ", index + 1),
|
||
Style::default()
|
||
.fg(Color::Cyan)
|
||
.add_modifier(Modifier::BOLD),
|
||
)];
|
||
if masked {
|
||
spans.push(Span::styled(
|
||
"••••••••",
|
||
Style::default().fg(Color::DarkGray),
|
||
));
|
||
if selected && editor.is_input_active() {
|
||
spans.push(Span::styled(
|
||
" [hidden input]",
|
||
Style::default().fg(Color::Yellow),
|
||
));
|
||
}
|
||
} else if let Ok(value) = std::str::from_utf8(contents) {
|
||
if selected && editor.is_input_active() && value.is_char_boundary(editor.cursor()) {
|
||
let (before, after) = value.split_at(editor.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));
|
||
}
|
||
} else {
|
||
spans.push(Span::styled(
|
||
"[non-UTF-8 field; editing will preserve bytes]",
|
||
Style::default().fg(Color::Yellow),
|
||
));
|
||
}
|
||
let line = Line::from(spans);
|
||
if selected {
|
||
line.style(Style::default().bg(Color::Blue).fg(Color::White))
|
||
} else {
|
||
line
|
||
}
|
||
})
|
||
.collect()
|
||
}
|
||
|
||
fn mode_title(mode: Mode) -> &'static str {
|
||
match mode {
|
||
Mode::Browser => "Browser",
|
||
Mode::Viewer => "Viewer",
|
||
Mode::Editor => "Editor",
|
||
Mode::Dialog => "Dialog",
|
||
Mode::Help => "Help",
|
||
Mode::Command => "Command",
|
||
Mode::Locked => "Locked",
|
||
}
|
||
}
|
||
|
||
fn status_line(app: &App) -> Paragraph<'_> {
|
||
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())
|
||
});
|
||
Paragraph::new(Line::from(vec![
|
||
Span::styled(
|
||
" status ",
|
||
Style::default().bg(Color::Blue).fg(Color::White),
|
||
),
|
||
Span::raw(format!(" {}{busy}{warning}", app.status())),
|
||
]))
|
||
}
|
||
|
||
fn context_line(app: &App) -> Paragraph<'static> {
|
||
let text = available_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(":").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 ratatui::{Terminal, backend::TestBackend};
|
||
|
||
use super::*;
|
||
use crate::app::Transition;
|
||
use crate::sidebar::TestTreeNode;
|
||
use crate::viewer::test_support::fixture_document;
|
||
|
||
fn render(width: u16, height: u16, app: &App) -> String {
|
||
let backend = TestBackend::new(width, height);
|
||
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")
|
||
}
|
||
|
||
#[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 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::ACTIONS {
|
||
assert!(output.contains(spec.label));
|
||
assert!(output.contains(spec.command));
|
||
}
|
||
}
|
||
|
||
#[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 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 viewer_masks_storage_sensitive_fields_and_renders_dynamic_unicode_fields() {
|
||
let mut app = App::new();
|
||
app.open_test_document("unicode/咖啡", fixture_document("unicode/咖啡"));
|
||
for width in [60, 100, 140] {
|
||
let output = render(width, 20, &app);
|
||
assert!(output.contains("password: ••••••••"));
|
||
assert!(output.contains("login:"));
|
||
assert!(output.contains('用'));
|
||
assert!(output.contains("@example.test"));
|
||
assert!(output.contains("notes: ••••••••"));
|
||
assert!(!output.contains("pässwörd-猫"));
|
||
}
|
||
assert!(!format!("{app:?}").contains("pässwörd-猫"));
|
||
}
|
||
|
||
#[test]
|
||
fn reveal_is_explicit_and_focus_change_or_lock_removes_secret_from_rendering() {
|
||
let mut app = App::new();
|
||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||
app.dispatch(crate::action::Action::Reveal);
|
||
let revealed = render(100, 20, &app);
|
||
assert!(revealed.contains("correct horse fixture"));
|
||
|
||
app.dispatch(crate::action::Action::FocusNext);
|
||
let hidden = render(100, 20, &app);
|
||
assert!(!hidden.contains("correct horse fixture"));
|
||
|
||
app.dispatch(crate::action::Action::FocusPrevious);
|
||
app.dispatch(crate::action::Action::Reveal);
|
||
app.dispatch(crate::action::Action::Help);
|
||
app.dispatch(crate::action::Action::Cancel);
|
||
let after_overlay = render(100, 20, &app);
|
||
assert!(!after_overlay.contains("correct horse fixture"));
|
||
|
||
app.dispatch(crate::action::Action::Reveal);
|
||
app.forced_relock("test lock");
|
||
let locked = render(100, 20, &app);
|
||
assert!(!locked.contains("correct horse fixture"));
|
||
assert!(locked.contains("locked"));
|
||
}
|
||
|
||
#[test]
|
||
fn otp_metadata_is_visible_while_the_secret_uri_stays_masked() {
|
||
let mut app = App::new();
|
||
app.open_test_document("otp/totp", fixture_document("otp/totp"));
|
||
app.dispatch(crate::action::Action::FocusNext);
|
||
let output = render(120, 20, &app);
|
||
assert!(output.contains("Totp"));
|
||
assert!(output.contains("IronStorage"));
|
||
assert!(output.contains("alice@example.test"));
|
||
assert!(output.contains("period 30s"));
|
||
assert!(!output.contains("JBSWY3DPEHPK3PXP"));
|
||
}
|
||
|
||
#[test]
|
||
fn closing_a_document_preserves_the_sidebar_selection() {
|
||
let mut app = App::new();
|
||
app.sidebar_mut().replace_test_tree(vec![TestTreeNode {
|
||
path: "email/personal".to_owned(),
|
||
name: "personal".to_owned(),
|
||
directory: false,
|
||
indicators: ironstorage::read::TreeNodeIndicators::default(),
|
||
children: vec![],
|
||
}]);
|
||
let selected = app.sidebar().selected().cloned();
|
||
app.open_test_document("email/personal", fixture_document("email/personal"));
|
||
app.dispatch(crate::action::Action::CloseEntry);
|
||
assert_eq!(app.mode(), Mode::Browser);
|
||
assert_eq!(app.sidebar().selected(), selected.as_ref());
|
||
assert!(app.viewer().is_none());
|
||
}
|
||
|
||
#[test]
|
||
fn editor_keeps_sensitive_input_masked_until_explicit_reveal() {
|
||
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 hidden = render(width, 20, &app);
|
||
assert!(hidden.contains("hidden input"));
|
||
assert!(!hidden.contains("correct horse fixturex"));
|
||
}
|
||
|
||
app.handle_editor_input(crossterm::event::KeyCode::Esc);
|
||
app.dispatch(crate::action::Action::Reveal);
|
||
let revealed = render(100, 20, &app);
|
||
assert!(revealed.contains("correct horse fixturex"));
|
||
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"));
|
||
}
|
||
}
|