use ratatui::{ buffer::Buffer, layout::{Alignment, Constraint, Direction, Layout, Rect}, style::{Color, Modifier, Style, Stylize}, text::{Line, Span, Text}, widgets::{Block, BorderType, Borders, List, Paragraph, StatefulWidget, Widget}, }; use crate::app::App; impl Widget for &mut App { /// Renders the user interface widgets. /// // This is where you add new widgets. // See the following resources: // - https://docs.rs/ratatui/latest/ratatui/widgets/index.html // - https://github.com/ratatui/ratatui/tree/master/examples fn render(self, area: Rect, buf: &mut Buffer) { let items = if let Some(manifest) = &self.manifest { manifest .secrets .keys() .map(|v| Text::from(truncate_with_ellipsis(v, 50))) .collect() } else { Vec::new() }; let layout = Layout::default() .direction(Direction::Horizontal) .constraints([Constraint::Ratio(1, 3), Constraint::Ratio(2, 3)]) .split(area); let left = layout[0]; let right = layout[1]; let left_block = Block::default().title_bottom("meow").borders(Borders::ALL); let list = List::default() .block(left_block) .highlight_style(Style::default().bg(Color::Blue).fg(Color::White)) .highlight_symbol("\u{25b6} ") .items(items); StatefulWidget::render(list, left, buf, &mut self.list_state); render_secret_detail(self, right, buf); } } fn render_secret_detail(state: &App, area: Rect, buf: &mut Buffer) { let manifest = match &state.manifest { Some(m) => m, None => { render_placeholder(area, buf, " Detail ", "No manifest loaded"); return; } }; let selected_name = match state.selected_secret_name() { Some(name) => name.to_string(), None => { render_placeholder(area, buf, " Detail ", "No secret selected"); return; } }; let secret = match manifest.secrets.get(&selected_name) { Some(s) => s, None => return, }; let title = format!(" {} ", selected_name); let block = Block::default().title(title).borders(Borders::ALL); // Build detail lines let mut lines: Vec = Vec::new(); // Separator lines.push(Line::from("")); // Format lines.push(Line::from(vec![ Span::styled(" Format: ", Style::default().fg(Color::DarkGray)), Span::styled(secret.format.to_string(), Style::default().fg(Color::White)), ])); // Scope let scope = if secret.global { "global".to_string() } else { "host-specific".to_string() }; lines.push(Line::from(vec![ Span::styled(" Scope: ", Style::default().fg(Color::DarkGray)), Span::styled(scope, Style::default().fg(Color::White)), ])); if !secret.global { // Hosts / identities. Home-manager identities are coloured distinctly // so it is obvious which targets are user (home) rather than system. let mut host_spans: Vec = vec![Span::styled( " Hosts: ", Style::default().fg(Color::DarkGray), )]; if secret.hosts.is_empty() { host_spans.push(Span::styled("none", Style::default().fg(Color::White))); } else { for (i, identity) in secret.hosts.iter().enumerate() { if i > 0 { host_spans.push(Span::styled(", ", Style::default().fg(Color::DarkGray))); } let is_home = manifest.home_identities.contains(identity); let style = if is_home { Style::default().fg(Color::Magenta) } else { Style::default().fg(Color::White) }; host_spans.push(Span::styled(identity.clone(), style)); } } lines.push(Line::from(host_spans)); } // Recipients lines.push(Line::from(vec![ Span::styled(" Recipients: ", Style::default().fg(Color::DarkGray)), Span::styled( format!("{} age keys", secret.keys.len()), Style::default().fg(Color::White), ), ])); // Needed for users if secret.needed_for_users { lines.push(Line::from(vec![ Span::styled(" Users: ", Style::default().fg(Color::DarkGray)), Span::styled("yes", Style::default().fg(Color::Yellow)), ])); } // Home-manager: shown when this secret is consumed by a home-manager user. if secret.home { lines.push(Line::from(vec![ Span::styled(" Home-mgr: ", Style::default().fg(Color::DarkGray)), Span::styled("yes", Style::default().fg(Color::Magenta)), ])); } while lines.len() < 6 { lines.push(Line::from("")); } // Key details section lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Keys:", Style::default() .fg(Color::DarkGray) .add_modifier(Modifier::BOLD), ))); for key in &secret.keys { let truncated = if key.len() > 20 { format!("{}...", &key[..20]) } else { key.clone() }; // Check if this is a master key let label = if manifest.master_keys.contains(key) { Span::styled(" (master)", Style::default().fg(Color::Magenta)) } else { // Try to find which identity owns this key. Home identities are // tagged and coloured distinctly from system identities. match manifest .hosts .iter() .find(|(_, info)| info.keys.contains(key)) { Some((name, _)) if manifest.home_identities.contains(name) => Span::styled( format!(" ({name}, home)"), Style::default().fg(Color::Magenta), ), Some((name, _)) => { Span::styled(format!(" ({name})"), Style::default().fg(Color::Cyan)) } None => Span::raw(""), } }; lines.push(Line::from(vec![ Span::raw(" "), Span::styled(truncated, Style::default().fg(Color::Green)), label, ])); } Paragraph::new(lines).block(block).render(area, buf); } fn render_placeholder(area: Rect, buf: &mut Buffer, title: &str, message: &str) { let block = Block::default() .title(title.to_string()) .borders(Borders::ALL); Paragraph::new(message.to_string()) .style(Style::default().fg(Color::DarkGray)) .block(block) .render(area, buf); } /// Truncate a name with middle ellipsis, preserving the file extension fn truncate_with_ellipsis(name: &str, max_width: usize) -> String { if name.len() <= max_width { return name.to_string(); } if max_width < 5 { return name[..max_width].to_string(); } // Try to preserve the extension if let Some(dot_pos) = name.rfind('.') { let ext = &name[dot_pos..]; if ext.len() < max_width - 3 { let prefix_len = max_width - 3 - ext.len(); return format!("{}...{}", &name[..prefix_len], ext); } } // Fallback: just truncate with ellipsis at end let prefix_len = max_width - 3; format!("{}...", &name[..prefix_len]) }