use std::path::{Path, PathBuf};
use std::time::Duration;
use anyhow::{Context, Result};
use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
use ratatui::layout::{Alignment, Constraint, Layout, Rect};
use ratatui::prelude::Stylize;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, TableState};
use ratatui::{DefaultTerminal, Frame};
use secrecy::SecretString;
use tui_textarea::TextArea;
use crate::{crypto, store};
#[derive(Clone, Copy, PartialEq, Eq)]
enum Mode {
Browse,
Add,
Edit,
ConfirmDelete,
ConfirmQuit,
}
pub struct App {
sets: store::Sets,
passphrase: SecretString,
file: PathBuf,
selected: usize,
mode: Mode,
add_set: TextArea<'static>,
add_key: TextArea<'static>,
add_value: TextArea<'static>,
add_focus: usize,
edit_value: TextArea<'static>,
dirty: bool,
message: Option<String>,
}
impl App {
fn new(sets: store::Sets, passphrase: SecretString, file: &Path) -> Self {
Self {
sets,
passphrase,
file: file.to_path_buf(),
selected: 0,
mode: Mode::Browse,
add_set: TextArea::default(),
add_key: TextArea::default(),
add_value: TextArea::default(),
add_focus: 0,
edit_value: TextArea::default(),
dirty: false,
message: None,
}
}
fn rows(&self) -> Vec<(String, String)> {
let mut rows = Vec::new();
for (set, vars) in &self.sets {
for (key, value) in vars {
rows.push((format!("{set}.{key}"), value.clone()));
}
}
rows
}
fn nth(&self, index: usize) -> Option<(&str, &str)> {
let mut i = 0;
for (set, vars) in &self.sets {
for key in vars.keys() {
if i == index {
return Some((set, key));
}
i += 1;
}
}
None
}
fn remove_at(&mut self, index: usize) {
let removed = self
.nth(index)
.map(|(set, key)| (set.to_string(), key.to_string()));
if let Some((set, key)) = removed {
if let Some(vars) = self.sets.get_mut(&set) {
vars.remove(&key);
if vars.is_empty() {
self.sets.remove(&set);
}
}
self.dirty = true;
}
}
fn move_selection(&mut self, delta: isize) {
let len = self.rows().len();
if len == 0 {
return;
}
self.selected = (self.selected as isize + delta).clamp(0, len as isize - 1) as usize;
}
fn start_add(&mut self) {
self.add_set = TextArea::default();
self.add_key = TextArea::default();
self.add_value = TextArea::default();
self.add_set.set_placeholder_text("set name (e.g. db)");
self.add_key
.set_placeholder_text("variable name (e.g. HOST)");
self.add_value.set_placeholder_text("value");
self.add_focus = 0;
self.mode = Mode::Add;
self.message = None;
}
fn start_edit(&mut self) {
if let Some(value) = self.rows().get(self.selected).map(|(_, v)| v.clone()) {
self.edit_value = TextArea::from(vec![value]);
self.edit_value.set_placeholder_text("value");
self.mode = Mode::Edit;
self.message = None;
}
}
fn commit_add(&mut self) {
let set = self.add_set.lines().join("").trim().to_string();
let key = self.add_key.lines().join("").trim().to_string();
let value = self.add_value.lines().join("").to_string();
if set.is_empty() || key.is_empty() {
self.message = Some("set and key must not be empty".to_string());
return;
}
if !store::is_valid_env_name(&key) {
self.message = Some(format!("invalid variable name `{key}`"));
return;
}
self.sets.entry(set).or_default().insert(key, value);
self.dirty = true;
self.selected = self.rows().len().saturating_sub(1);
self.mode = Mode::Browse;
}
fn commit_edit(&mut self) {
let value = self.edit_value.lines().join("").to_string();
if let Some((set, key)) = self
.nth(self.selected)
.map(|(s, k)| (s.to_string(), k.to_string()))
&& let Some(vars) = self.sets.get_mut(&set)
{
vars.insert(key, value);
self.dirty = true;
}
self.mode = Mode::Browse;
}
fn save(&mut self) -> Result<()> {
let toml = store::to_toml(&self.sets);
let ciphertext = crypto::encrypt(toml.as_bytes(), self.passphrase.clone())?;
store::atomic_write(&self.file, &ciphertext)
.with_context(|| format!("failed to write {}", self.file.display()))?;
self.dirty = false;
Ok(())
}
fn try_save(&mut self) -> Result<bool> {
match self.save() {
Ok(()) => Ok(true),
Err(e) => {
self.message = Some(format!("save failed: {e:#}"));
Ok(false)
}
}
}
fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
if key.kind != KeyEventKind::Press {
return Ok(false);
}
match self.mode {
Mode::Browse => self.handle_browse_key(key),
Mode::Add => self.handle_add_key(key),
Mode::Edit => self.handle_edit_key(key),
Mode::ConfirmDelete | Mode::ConfirmQuit => self.handle_confirm_key(key),
}
}
fn handle_browse_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
KeyCode::Char('a') => self.start_add(),
KeyCode::Char('e') => self.start_edit(),
KeyCode::Char('d') => {
if !self.rows().is_empty() {
self.mode = Mode::ConfirmDelete;
}
}
KeyCode::Char('s') => return self.try_save(),
KeyCode::Char('q') => {
if self.dirty {
self.mode = Mode::ConfirmQuit;
} else {
return Ok(true);
}
}
_ => {}
}
Ok(false)
}
fn handle_add_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Esc => self.mode = Mode::Browse,
KeyCode::Tab => self.add_focus = (self.add_focus + 1) % 3,
KeyCode::BackTab => self.add_focus = (self.add_focus + 2) % 3,
KeyCode::Enter => self.commit_add(),
KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
return self.try_save();
}
_ => {
let focused = match self.add_focus {
0 => &mut self.add_set,
1 => &mut self.add_key,
_ => &mut self.add_value,
};
focused.input(key);
}
}
Ok(false)
}
fn handle_edit_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Esc => self.mode = Mode::Browse,
KeyCode::Enter => self.commit_edit(),
_ => {
self.edit_value.input(key);
}
}
Ok(false)
}
fn handle_confirm_key(&mut self, key: KeyEvent) -> Result<bool> {
match key.code {
KeyCode::Char('y') | KeyCode::Char('Y') => {
if self.mode == Mode::ConfirmDelete {
let index = self.selected;
self.remove_at(index);
self.selected = self.selected.min(self.rows().len().saturating_sub(1));
self.mode = Mode::Browse;
Ok(false)
} else {
Ok(true)
}
}
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter => {
self.mode = Mode::Browse;
Ok(false)
}
_ => Ok(false),
}
}
fn draw(&mut self, frame: &mut Frame) {
let [header, body, footer] = Layout::vertical([
Constraint::Length(1),
Constraint::Min(1),
Constraint::Length(1),
])
.areas(frame.area());
if let Some(message) = &self.message {
frame.render_widget(
Paragraph::new(message.as_str()).style(Style::new().fg(Color::Red)),
header,
);
} else {
frame.render_widget(
Paragraph::new(Line::from(vec![
Span::raw("secure-env").add_modifier(Modifier::BOLD),
Span::raw(format!(" {}", self.file.display())).fg(Color::DarkGray),
])),
header,
);
}
self.draw_table(frame, body);
self.draw_footer(frame, footer);
match self.mode {
Mode::Add => self.draw_add_popup(frame),
Mode::Edit => self.draw_edit_popup(frame),
Mode::ConfirmDelete | Mode::ConfirmQuit => self.draw_confirm_popup(frame),
Mode::Browse => {}
}
}
fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
let rows = self.rows();
if rows.is_empty() {
frame.render_widget(
Paragraph::new("No variables yet. Press `a` to add one.")
.alignment(Alignment::Center)
.style(Style::new().fg(Color::DarkGray))
.block(Block::bordered().border_type(BorderType::Rounded)),
area,
);
return;
}
let mut state = TableState::default();
state.select(Some(self.selected.min(rows.len() - 1)));
self.selected = state.selected().unwrap_or(0);
let header = Row::new(["env", "value"])
.style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
let body = rows
.into_iter()
.map(|(key, value)| Row::new([Cell::from(key), Cell::from(value)]));
let table = Table::new(
body,
[Constraint::Percentage(40), Constraint::Percentage(60)],
)
.header(header)
.block(Block::bordered().border_type(BorderType::Rounded))
.row_highlight_style(
Style::new()
.bg(Color::DarkGray)
.add_modifier(Modifier::BOLD),
)
.column_spacing(2);
frame.render_stateful_widget(table, area, &mut state);
}
fn draw_footer(&self, frame: &mut Frame, area: Rect) {
let help = match self.mode {
Mode::Browse => "↑↓ move a add e edit d delete s save & quit q quit",
Mode::Add => "tab switch field enter save esc cancel",
Mode::Edit => "enter save esc cancel",
Mode::ConfirmDelete => "y delete esc cancel",
Mode::ConfirmQuit => "y discard & quit esc back",
};
frame.render_widget(
Paragraph::new(help)
.alignment(Alignment::Center)
.style(Style::new().fg(Color::DarkGray)),
area,
);
}
fn centered_rect(&self, percent_x: u16, percent_y: u16, area: Rect) -> Rect {
let vertical = Layout::vertical([
Constraint::Percentage((100 - percent_y) / 2),
Constraint::Percentage(percent_y),
Constraint::Percentage((100 - percent_y) / 2),
])
.split(area);
Layout::horizontal([
Constraint::Percentage((100 - percent_x) / 2),
Constraint::Percentage(percent_x),
Constraint::Percentage((100 - percent_x) / 2),
])
.split(vertical[1])[1]
}
fn draw_add_popup(&mut self, frame: &mut Frame) {
let area = self.centered_rect(60, 40, frame.area());
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title("Add variable");
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
let chunks = Layout::vertical([
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
])
.spacing(1)
.split(inner);
let labels = ["set", "key", "value"];
let mut areas = Vec::new();
for chunk in chunks.iter() {
let line =
Layout::horizontal([Constraint::Length(8), Constraint::Min(1)]).split(*chunk);
areas.push((line[0], line[1]));
}
for (i, (label_area, field_area)) in areas.iter().enumerate() {
frame.render_widget(
Paragraph::new(format!("{}:", labels[i])).style(Style::new().fg(Color::Cyan)),
*label_area,
);
let style = if i == self.add_focus {
Style::new().fg(Color::White)
} else {
Style::new().fg(Color::DarkGray)
};
let field = match i {
0 => &mut self.add_set,
1 => &mut self.add_key,
_ => &mut self.add_value,
};
field.set_style(style);
frame.render_widget(&*field, *field_area);
}
}
fn draw_edit_popup(&mut self, frame: &mut Frame) {
let area = self.centered_rect(60, 25, frame.area());
let title = self
.rows()
.get(self.selected)
.map(|(key, _)| format!("Edit {key}"))
.unwrap_or_else(|| "Edit".to_string());
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(title);
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(&self.edit_value, inner);
}
fn draw_confirm_popup(&self, frame: &mut Frame) {
let area = self.centered_rect(50, 20, frame.area());
let (title, text) = match self.mode {
Mode::ConfirmDelete => (
"Delete",
self.rows()
.get(self.selected)
.map(|(key, _)| format!("Delete `{key}`? (y/N)"))
.unwrap_or_default(),
),
_ => (
"Quit",
"Discard unsaved changes and quit? (y/N)".to_string(),
),
};
let block = Block::bordered()
.border_type(BorderType::Rounded)
.title(title);
let inner = block.inner(area);
frame.render_widget(Clear, area);
frame.render_widget(block, area);
frame.render_widget(
Paragraph::new(text)
.alignment(Alignment::Center)
.style(Style::new().fg(Color::Yellow)),
inner,
);
}
}
pub fn run(sets: store::Sets, passphrase: SecretString, file: &Path) -> Result<()> {
let mut terminal = ratatui::try_init().context("failed to initialize terminal")?;
let mut app = App::new(sets, passphrase, file);
let result = event_loop(&mut terminal, &mut app);
ratatui::restore();
result
}
fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
loop {
terminal.draw(|frame| app.draw(frame))?;
if event::poll(Duration::from_millis(100))?
&& let Event::Key(key) = event::read()?
&& app.handle_key(key)?
{
return Ok(());
}
}
}