start work on tui app and rework secrets system

This commit is contained in:
2026-02-15 01:12:38 +04:00
parent a89275f163
commit 2a8a30fc14
12 changed files with 2031 additions and 103 deletions
+206
View File
@@ -0,0 +1,206 @@
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<Line> = 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
let hosts_str = if secret.hosts.is_empty() {
if secret.global {
manifest
.hosts
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
} else {
"none".to_string()
}
} else {
secret.hosts.join(", ")
};
lines.push(Line::from(vec![
Span::styled(" Hosts: ", Style::default().fg(Color::DarkGray)),
Span::styled(hosts_str, Style::default().fg(Color::White)),
]));
}
// 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)),
]));
}
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 host owns this key
let host_label = manifest
.hosts
.iter()
.find(|(_, info)| info.keys.contains(key))
.map(|(name, _)| format!(" ({})", name))
.unwrap_or_default();
Span::styled(host_label, Style::default().fg(Color::Cyan))
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(truncated, Style::default().fg(Color::Green)),
label,
]));
}
let paragraph = 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])
}