use ratatui::layout::Rect;
use ratatui::widgets::{Paragraph, Wrap};
use ratatui::Frame;
use super::theme;
use crate::app::{App, Cursor};
use crate::model::{template_display_name, FieldKind, Item};
pub fn render_detail(frame: &mut Frame, area: Rect, app: &App) {
if let Some(ed) = &app.editor {
render_editor(frame, area, &ed.draft, &ed.field);
return;
}
let title = app
.selected_item()
.map(|i| i.title.clone())
.unwrap_or_else(|| "Detail".into());
let inner = theme::panel_frame(frame, area, Some(&title));
let Some(item) = app.selected_item() else {
let p = Paragraph::new("(no item selected)\n\nselect with j/k or press n to create")
.style(theme::muted())
.wrap(Wrap { trim: true });
frame.render_widget(p, inner);
return;
};
let mut lines = view_lines(item);
let item_id = item.id;
if let Some(id) = item_id {
let mut att_lines = attachment_lines(app, id, matches!(app.mode, crate::app::Mode::Normal));
lines.append(&mut att_lines);
}
let p = Paragraph::new(lines).wrap(Wrap { trim: false });
frame.render_widget(p, inner);
}
fn attachment_lines(app: &App, item_id: i64, in_normal: bool) -> Vec<ratatui::text::Line<'static>> {
let mut lines = Vec::new();
lines.push(blank());
let metas = attachment_summary(app, item_id);
if metas.is_empty() {
lines.push(ratatui::text::Line::from(vec![
ratatui::text::Span::styled("📎 ", theme::accent2()),
ratatui::text::Span::styled("(none)", theme::muted()),
]));
} else {
for (name, size) in metas {
lines.push(ratatui::text::Line::from(vec![
ratatui::text::Span::styled("📎 ", theme::accent2()),
ratatui::text::Span::styled(name, theme::fg()),
ratatui::text::Span::styled(format!(" ({})", size), theme::muted()),
]));
}
}
if in_normal {
lines.push(
ratatui::text::Line::from("press a to manage").style(theme::muted()),
);
}
lines
}
fn attachment_summary(app: &App, item_id: i64) -> Vec<(String, i64)> {
let Some(db) = app.db.as_ref() else {
return Vec::new();
};
let conn = db.conn();
let mut stmt = match conn.prepare(
"SELECT filename, size FROM attachments WHERE item_id = ?1 ORDER BY id ASC",
) {
Ok(s) => s,
Err(_) => return Vec::new(),
};
let rows = stmt
.query_map([item_id], |r| {
Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?))
});
match rows {
Ok(it) => it.filter_map(|r| r.ok()).collect(),
Err(_) => Vec::new(),
}
}
fn view_lines(item: &Item) -> Vec<ratatui::text::Line<'static>> {
let mut lines: Vec<ratatui::text::Line<'static>> = Vec::new();
lines.push(field_view("Type", &template_display_name(&item.template_id)));
lines.push(divider_line());
for f in &item.fields {
match f.kind {
FieldKind::Totp => lines.push(totp_code_line_labeled(&f.name, &f.value)),
FieldKind::Secret => lines.push(secret_view(&f.name, &f.value)),
FieldKind::Multiline => {
lines.push(blank());
if f.value.is_empty() {
lines.push(ratatui::text::Line::from("(empty)").style(theme::muted()));
} else {
for ln in f.value.lines() {
lines.push(ratatui::text::Line::from(ln.to_string()).style(theme::fg()));
}
}
}
FieldKind::Text => lines.push(field_view(&f.name, &f.value)),
}
}
lines
}
fn render_editor(frame: &mut Frame, area: Rect, draft: &Item, field: &Cursor) {
let inner = theme::panel_frame(frame, area, Some("Editor · Tab/^T totp/Enter/Esc"));
let rows = editor_rows(draft, field);
let p = Paragraph::new(rows).wrap(Wrap { trim: false });
frame.render_widget(p, inner);
}
fn editor_rows(draft: &Item, field: &Cursor) -> Vec<ratatui::text::Line<'static>> {
let mut rows = Vec::new();
rows.push(field_line("Title", &draft.title, field, &Cursor::Title));
for (i, f) in draft.fields.iter().enumerate() {
let cur = Cursor::Field(i);
match f.kind {
FieldKind::Secret | FieldKind::Totp => {
rows.push(field_line_mask(&f.name, &f.value, field, &cur));
}
_ => rows.push(field_line(&f.name, &f.value, field, &cur)),
}
}
rows
}
fn field_line(label: &str, value: &str, cur: &Cursor, this: &Cursor) -> ratatui::text::Line<'static> {
let active = cur == this;
let body = if active {
format!("▍{value}")
} else if value.is_empty() {
"—".into()
} else {
value.to_string()
};
let vstyle = if active {
theme::selected()
} else if value.is_empty() {
theme::muted()
} else {
theme::fg()
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(format!("{label:<10}"), theme::accent2()),
ratatui::text::Span::raw(": "),
ratatui::text::Span::styled(body, vstyle),
])
}
fn field_line_mask(label: &str, value: &str, cur: &Cursor, this: &Cursor) -> ratatui::text::Line<'static> {
let masked = mask(value);
field_line(label, &masked, cur, this)
}
fn blank() -> ratatui::text::Line<'static> {
ratatui::text::Line::from("")
}
fn divider_line() -> ratatui::text::Line<'static> {
ratatui::text::Line::from(" ╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌╌")
.style(theme::muted())
}
fn field_view(label: &str, value: &str) -> ratatui::text::Line<'static> {
let (text, style) = if value.is_empty() {
("—".to_string(), theme::muted())
} else {
(value.to_string(), theme::fg())
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(format!("{label:<10}"), theme::accent2()),
ratatui::text::Span::raw(": "),
ratatui::text::Span::styled(text, style),
])
}
fn secret_view(label: &str, value: &str) -> ratatui::text::Line<'static> {
let (text, style) = if value.is_empty() {
("—".to_string(), theme::muted())
} else {
(mask(value), theme::accent())
};
ratatui::text::Line::from(vec![
ratatui::text::Span::styled(format!("{label:<10}"), theme::accent2()),
ratatui::text::Span::raw(": "),
ratatui::text::Span::styled(text, style),
])
}
#[cfg(test)]
fn totp_code_line(secret: &str) -> ratatui::text::Line<'static> {
totp_code_line_labeled("TOTP", secret)
}
fn totp_code_line_labeled(label: &str, secret: &str) -> ratatui::text::Line<'static> {
use std::time::{SystemTime, UNIX_EPOCH};
let label = ratatui::text::Span::styled(format!("{:<10}", label), theme::accent2());
let sep = ratatui::text::Span::raw(": ");
if secret.is_empty() {
return ratatui::text::Line::from(vec![
label,
sep,
ratatui::text::Span::styled("—", theme::muted()),
]);
}
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
match crate::totp::totp_at(secret, now) {
Ok(code) => {
let remaining = 30 - (now % 30);
let mut spans = vec![
label,
sep,
ratatui::text::Span::styled(code, theme::accent()),
ratatui::text::Span::raw(" "),
];
spans.extend(totp_gauge_spans(remaining));
spans.push(ratatui::text::Span::styled(
format!(" (~{remaining}s)"),
theme::muted(),
));
ratatui::text::Line::from(spans)
}
Err(_) => ratatui::text::Line::from(vec![
label,
sep,
ratatui::text::Span::styled("(invalid)", theme::error()),
]),
}
}
fn totp_gauge_spans(remaining: u64) -> Vec<ratatui::text::Span<'static>> {
const SEG: usize = 20;
let filled = ((remaining as usize) * SEG / 30).min(SEG);
let bar_color = if remaining >= 18 {
theme::colors::accent()
} else if remaining >= 9 {
theme::colors::accent2()
} else {
theme::colors::alert()
};
vec![
ratatui::text::Span::styled(
"▰".repeat(filled),
ratatui::style::Style::default().fg(bar_color),
),
ratatui::text::Span::styled("▱".repeat(SEG - filled), theme::muted()),
]
}
fn mask(s: &str) -> String {
if s.is_empty() {
String::new()
} else {
"•".repeat(s.chars().count())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn line_text(line: &ratatui::text::Line<'_>) -> String {
line.spans
.iter()
.map(|s| s.content.as_ref())
.collect::<Vec<_>>()
.join("")
}
#[test]
fn totp_code_line_valid_secret_has_six_digits() {
let line = totp_code_line("JBSWY3DPEHPK3PXP");
let text = line_text(&line);
assert!(text.contains("TOTP"), "line should contain TOTP label: {text}");
let code_part = text.split(": ").nth(1).unwrap_or("");
let code = code_part.split_whitespace().next().unwrap_or("");
assert_eq!(code.len(), 6, "code should be 6 digits: {text}");
assert!(code.chars().all(|c| c.is_ascii_digit()));
assert!(text.contains("(~") && text.contains("s)"), "missing countdown: {text}");
}
#[test]
fn totp_code_line_empty_secret_shows_dash() {
let line = totp_code_line("");
let text = line_text(&line);
assert!(text.contains("—"), "empty secret should show dash: {text}");
assert!(!text.contains("invalid"));
}
#[test]
fn totp_code_line_invalid_secret_shows_invalid() {
let line = totp_code_line("!!!");
let text = line_text(&line);
assert!(text.contains("invalid"), "illegal secret should show invalid: {text}");
}
}