start work on tui app and rework secrets system
This commit is contained in:
128
src/app.rs
Normal file
128
src/app.rs
Normal file
@@ -0,0 +1,128 @@
|
||||
use color_eyre::Report;
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use ratatui::{prelude::CrosstermBackend, widgets::ListState, DefaultTerminal, Terminal};
|
||||
use ratatui_form::{Email, Form};
|
||||
use std::{io, path::Path};
|
||||
|
||||
use crate::{
|
||||
event::{AppEvent, Event, EventHandler},
|
||||
form::FormState,
|
||||
manifest::{load_manifest, Manifest},
|
||||
};
|
||||
|
||||
// #[derive(Debug)]
|
||||
pub struct App {
|
||||
pub running: bool,
|
||||
pub list_state: ListState,
|
||||
pub events: EventHandler,
|
||||
pub isloadingmanifest: bool,
|
||||
pub manifest: Option<Manifest>,
|
||||
}
|
||||
|
||||
impl App {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
running: true,
|
||||
list_state: ListState::default().with_selected(Some(0)),
|
||||
events: EventHandler::new(),
|
||||
isloadingmanifest: false,
|
||||
manifest: None,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(
|
||||
mut self,
|
||||
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
|
||||
) -> color_eyre::Result<()> {
|
||||
let project_root = Path::new("/home/nikkuss/dotfiles-new");
|
||||
self.events.send(AppEvent::LoadManifest);
|
||||
|
||||
while self.running {
|
||||
terminal.draw(|frame| frame.render_widget(&mut self, frame.area()))?;
|
||||
match self.events.next().await? {
|
||||
Event::Tick => self.tick(),
|
||||
Event::Crossterm(event) => match event {
|
||||
crossterm::event::Event::Key(key_event)
|
||||
if key_event.kind == crossterm::event::KeyEventKind::Press =>
|
||||
{
|
||||
self.handle_key_events(key_event)?
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
Event::App(app_event) => match app_event {
|
||||
AppEvent::LoadManifest => {
|
||||
if !self.isloadingmanifest {
|
||||
let tx = self.events.clone_sender();
|
||||
tokio::spawn(async move {
|
||||
let result = load_manifest(&project_root).await;
|
||||
let _ = tx.send(Event::App(AppEvent::ManifestLoaded(result)));
|
||||
});
|
||||
}
|
||||
}
|
||||
AppEvent::ManifestLoaded(manifest) => {
|
||||
self.isloadingmanifest = false;
|
||||
match manifest {
|
||||
Ok(m) => {
|
||||
self.manifest = Some(m);
|
||||
}
|
||||
Err(e) => {
|
||||
self.display_error(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
AppEvent::SelectionUp => self.selection_up(),
|
||||
AppEvent::SelectionDown => self.selection_down(),
|
||||
AppEvent::Quit => self.quit(),
|
||||
},
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Handles the key events and updates the state of [`App`].
|
||||
pub fn handle_key_events(&mut self, key_event: KeyEvent) -> color_eyre::Result<()> {
|
||||
match key_event.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => self.events.send(AppEvent::Quit),
|
||||
KeyCode::Char('c' | 'C') if key_event.modifiers == KeyModifiers::CONTROL => {
|
||||
self.events.send(AppEvent::Quit)
|
||||
}
|
||||
KeyCode::Up => self.events.send(AppEvent::SelectionUp),
|
||||
KeyCode::Down => self.events.send(AppEvent::SelectionDown),
|
||||
KeyCode::Char('a') => self.create_secret(),
|
||||
// Other handlers you could add here.
|
||||
_ => {}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn selected_secret_name(&self) -> Option<String> {
|
||||
let selected = self.list_state.selected()?;
|
||||
if let Some(manifest) = &self.manifest {
|
||||
return manifest.secrets.keys().nth(selected).cloned();
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Handles the tick event of the terminal.
|
||||
///
|
||||
/// The tick event is where you can update the state of your application with any logic that
|
||||
/// needs to be updated at a fixed frame rate. E.g. polling a server, updating an animation.
|
||||
pub fn tick(&self) {}
|
||||
|
||||
/// Set running to false to quit the application.
|
||||
pub fn quit(&mut self) {
|
||||
self.running = false;
|
||||
}
|
||||
|
||||
pub fn selection_down(&mut self) {
|
||||
self.list_state.select_next();
|
||||
}
|
||||
|
||||
pub fn selection_up(&mut self) {
|
||||
self.list_state.select_previous();
|
||||
}
|
||||
pub fn display_error(&mut self, display_error: Report) {
|
||||
// somehow display the error?
|
||||
}
|
||||
pub fn create_secret(&mut self) {}
|
||||
}
|
||||
125
src/event.rs
Normal file
125
src/event.rs
Normal file
@@ -0,0 +1,125 @@
|
||||
use color_eyre::eyre::OptionExt;
|
||||
use color_eyre::Result;
|
||||
use futures::{FutureExt, StreamExt};
|
||||
use ratatui::crossterm::event::Event as CrosstermEvent;
|
||||
use std::time::Duration;
|
||||
use tokio::sync::mpsc::{self, UnboundedSender};
|
||||
|
||||
use crate::manifest::Manifest;
|
||||
|
||||
/// The frequency at which tick events are emitted.
|
||||
const TICK_FPS: f64 = 30.0;
|
||||
|
||||
/// Representation of all possible events.
|
||||
#[derive(Debug)]
|
||||
pub enum Event {
|
||||
Tick,
|
||||
Crossterm(CrosstermEvent),
|
||||
App(AppEvent),
|
||||
}
|
||||
|
||||
/// Application events.
|
||||
///
|
||||
/// You can extend this enum with your own custom events.
|
||||
#[derive(Debug)]
|
||||
pub enum AppEvent {
|
||||
SelectionUp,
|
||||
SelectionDown,
|
||||
LoadManifest,
|
||||
ManifestLoaded(Result<Manifest>),
|
||||
/// Quit the application.
|
||||
Quit,
|
||||
}
|
||||
|
||||
/// Terminal event handler.
|
||||
#[derive(Debug)]
|
||||
pub struct EventHandler {
|
||||
/// Event sender channel.
|
||||
sender: mpsc::UnboundedSender<Event>,
|
||||
/// Event receiver channel.
|
||||
receiver: mpsc::UnboundedReceiver<Event>,
|
||||
}
|
||||
|
||||
impl EventHandler {
|
||||
/// Constructs a new instance of [`EventHandler`] and spawns a new thread to handle events.
|
||||
pub fn new() -> Self {
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let actor = EventTask::new(sender.clone());
|
||||
tokio::spawn(async { actor.run().await });
|
||||
Self { sender, receiver }
|
||||
}
|
||||
|
||||
/// Receives an event from the sender.
|
||||
///
|
||||
/// This function blocks until an event is received.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// This function returns an error if the sender channel is disconnected. This can happen if an
|
||||
/// error occurs in the event thread. In practice, this should not happen unless there is a
|
||||
/// problem with the underlying terminal.
|
||||
pub async fn next(&mut self) -> color_eyre::Result<Event> {
|
||||
self.receiver
|
||||
.recv()
|
||||
.await
|
||||
.ok_or_eyre("Failed to receive event")
|
||||
}
|
||||
|
||||
/// Queue an app event to be sent to the event receiver.
|
||||
///
|
||||
/// This is useful for sending events to the event handler which will be processed by the next
|
||||
/// iteration of the application's event loop.
|
||||
pub fn send(&mut self, app_event: AppEvent) {
|
||||
// Ignore the result as the reciever cannot be dropped while this struct still has a
|
||||
// reference to it
|
||||
let _ = self.sender.send(Event::App(app_event));
|
||||
}
|
||||
pub fn clone_sender(&self) -> UnboundedSender<Event> {
|
||||
self.sender.clone()
|
||||
}
|
||||
}
|
||||
|
||||
/// A thread that handles reading crossterm events and emitting tick events on a regular schedule.
|
||||
struct EventTask {
|
||||
/// Event sender channel.
|
||||
sender: mpsc::UnboundedSender<Event>,
|
||||
}
|
||||
|
||||
impl EventTask {
|
||||
/// Constructs a new instance of [`EventThread`].
|
||||
fn new(sender: mpsc::UnboundedSender<Event>) -> Self {
|
||||
Self { sender }
|
||||
}
|
||||
|
||||
/// Runs the event thread.
|
||||
///
|
||||
/// This function emits tick events at a fixed rate and polls for crossterm events in between.
|
||||
async fn run(self) -> color_eyre::Result<()> {
|
||||
let tick_rate = Duration::from_secs_f64(1.0 / TICK_FPS);
|
||||
let mut reader = crossterm::event::EventStream::new();
|
||||
let mut tick = tokio::time::interval(tick_rate);
|
||||
loop {
|
||||
let tick_delay = tick.tick();
|
||||
let crossterm_event = reader.next().fuse();
|
||||
tokio::select! {
|
||||
_ = self.sender.closed() => {
|
||||
break;
|
||||
}
|
||||
_ = tick_delay => {
|
||||
self.send(Event::Tick);
|
||||
}
|
||||
Some(Ok(evt)) = crossterm_event => {
|
||||
self.send(Event::Crossterm(evt));
|
||||
}
|
||||
};
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sends an event to the receiver.
|
||||
fn send(&self, event: Event) {
|
||||
// Ignores the result because shutting down the app drops the receiver, which causes the send
|
||||
// operation to fail. This is expected behavior and should not panic.
|
||||
let _ = self.sender.send(event);
|
||||
}
|
||||
}
|
||||
1
src/form.rs
Normal file
1
src/form.rs
Normal file
@@ -0,0 +1 @@
|
||||
pub struct FormState {}
|
||||
41
src/main.rs
Normal file
41
src/main.rs
Normal file
@@ -0,0 +1,41 @@
|
||||
use std::{io, path::Path};
|
||||
|
||||
use color_eyre::Result;
|
||||
use crossterm::{
|
||||
execute,
|
||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||
};
|
||||
use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||
|
||||
use crate::app::App;
|
||||
|
||||
mod app;
|
||||
mod event;
|
||||
mod form;
|
||||
mod manifest;
|
||||
mod ui;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
enable_raw_mode()?;
|
||||
let mut stdout = io::stdout();
|
||||
execute!(stdout, EnterAlternateScreen)?;
|
||||
let backend = CrosstermBackend::new(stdout);
|
||||
let mut terminal = Terminal::new(backend)?;
|
||||
|
||||
// Set up panic hook to restore terminal
|
||||
let original_hook = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |panic| {
|
||||
let _ = disable_raw_mode();
|
||||
let _ = execute!(io::stdout(), LeaveAlternateScreen);
|
||||
original_hook(panic);
|
||||
}));
|
||||
|
||||
// Run the app
|
||||
let result = App::new().run(&mut terminal).await;
|
||||
// Restore terminal
|
||||
disable_raw_mode()?;
|
||||
execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
|
||||
|
||||
result
|
||||
}
|
||||
79
src/manifest.rs
Normal file
79
src/manifest.rs
Normal file
@@ -0,0 +1,79 @@
|
||||
use color_eyre::{eyre::eyre, Result};
|
||||
use serde::Deserialize;
|
||||
use std::{collections::BTreeMap, fmt, path::Path};
|
||||
use tokio::process::Command;
|
||||
|
||||
/// Top-level manifest from `nix eval .#secretsManifest --json`
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct Manifest {
|
||||
#[serde(rename = "secretsDir")]
|
||||
pub secrets_dir: String,
|
||||
#[serde(rename = "masterKeys")]
|
||||
pub master_keys: Vec<String>,
|
||||
pub hosts: BTreeMap<String, HostInfo>,
|
||||
pub secrets: BTreeMap<String, SecretInfo>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct HostInfo {
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
/// Per-secret info, pre-computed by Nix
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SecretInfo {
|
||||
pub format: SecretFormat,
|
||||
pub global: bool,
|
||||
pub hosts: Vec<String>,
|
||||
#[serde(rename = "neededForUsers")]
|
||||
pub needed_for_users: bool,
|
||||
pub keys: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize, PartialEq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SecretFormat {
|
||||
Json,
|
||||
Yaml,
|
||||
Dotenv,
|
||||
Ini,
|
||||
Binary,
|
||||
}
|
||||
|
||||
impl fmt::Display for SecretFormat {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
SecretFormat::Json => write!(f, "json"),
|
||||
SecretFormat::Yaml => write!(f, "yaml"),
|
||||
SecretFormat::Dotenv => write!(f, "dotenv"),
|
||||
SecretFormat::Ini => write!(f, "ini"),
|
||||
SecretFormat::Binary => write!(f, "binary"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Load manifest by running `nix eval .#secretsManifest --json`
|
||||
pub async fn load_manifest(path: &Path) -> Result<Manifest> {
|
||||
let path = path.to_string_lossy();
|
||||
let timeout_duration = std::time::Duration::from_secs(30);
|
||||
let result = tokio::time::timeout(timeout_duration, async {
|
||||
Command::new("nix")
|
||||
.args(["eval", &format!("{path}#secretsManifest"), "--json"])
|
||||
.output()
|
||||
.await
|
||||
})
|
||||
.await;
|
||||
|
||||
match result {
|
||||
Ok(Ok(output)) => {
|
||||
if !output.status.success() {
|
||||
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||
Err(eyre!("nix eval failed:\n{}", stderr))
|
||||
} else {
|
||||
serde_json::from_slice(&output.stdout).map_err(Into::into)
|
||||
}
|
||||
}
|
||||
Ok(Err(e)) => Err(eyre!("Failed to run nix eval: {}", e)),
|
||||
Err(_) => Err(eyre!("nix eval timed out after 30s")),
|
||||
}
|
||||
}
|
||||
206
src/ui.rs
Normal file
206
src/ui.rs
Normal 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])
|
||||
}
|
||||
Reference in New Issue
Block a user