1use std::path::{Path, PathBuf};
2use std::time::Duration;
3
4use anyhow::{Context, Result};
5use ratatui::crossterm::event::{self, Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
6use ratatui::layout::{Alignment, Constraint, Layout, Rect};
7use ratatui::prelude::Stylize;
8use ratatui::style::{Color, Modifier, Style};
9use ratatui::text::{Line, Span};
10use ratatui::widgets::{Block, BorderType, Cell, Clear, Paragraph, Row, Table, TableState};
11use ratatui::{DefaultTerminal, Frame};
12use secrecy::SecretString;
13use tui_textarea::TextArea;
14
15use crate::{crypto, store};
16
17#[derive(Clone, Copy, PartialEq, Eq)]
18enum Mode {
19 Browse,
20 Add,
21 Edit,
22 ConfirmDelete,
23 ConfirmQuit,
24}
25
26pub struct App {
27 sets: store::Sets,
28 passphrase: SecretString,
29 file: PathBuf,
30 selected: usize,
31 mode: Mode,
32 add_set: TextArea<'static>,
33 add_key: TextArea<'static>,
34 add_value: TextArea<'static>,
35 add_focus: usize,
36 edit_value: TextArea<'static>,
37 dirty: bool,
38 message: Option<String>,
39}
40
41impl App {
42 fn new(sets: store::Sets, passphrase: SecretString, file: &Path) -> Self {
43 Self {
44 sets,
45 passphrase,
46 file: file.to_path_buf(),
47 selected: 0,
48 mode: Mode::Browse,
49 add_set: TextArea::default(),
50 add_key: TextArea::default(),
51 add_value: TextArea::default(),
52 add_focus: 0,
53 edit_value: TextArea::default(),
54 dirty: false,
55 message: None,
56 }
57 }
58
59 fn rows(&self) -> Vec<(String, String)> {
60 let mut rows = Vec::new();
61 for (set, vars) in &self.sets {
62 for (key, value) in vars {
63 rows.push((format!("{set}.{key}"), value.clone()));
64 }
65 }
66 rows
67 }
68
69 fn nth(&self, index: usize) -> Option<(&str, &str)> {
70 let mut i = 0;
71 for (set, vars) in &self.sets {
72 for key in vars.keys() {
73 if i == index {
74 return Some((set, key));
75 }
76 i += 1;
77 }
78 }
79 None
80 }
81
82 fn remove_at(&mut self, index: usize) {
83 let removed = self
84 .nth(index)
85 .map(|(set, key)| (set.to_string(), key.to_string()));
86 if let Some((set, key)) = removed {
87 if let Some(vars) = self.sets.get_mut(&set) {
88 vars.remove(&key);
89 if vars.is_empty() {
90 self.sets.remove(&set);
91 }
92 }
93 self.dirty = true;
94 }
95 }
96
97 fn move_selection(&mut self, delta: isize) {
98 let len = self.rows().len();
99 if len == 0 {
100 return;
101 }
102 self.selected = (self.selected as isize + delta).clamp(0, len as isize - 1) as usize;
103 }
104
105 fn start_add(&mut self) {
106 self.add_set = TextArea::default();
107 self.add_key = TextArea::default();
108 self.add_value = TextArea::default();
109 self.add_set.set_placeholder_text("set name (e.g. db)");
110 self.add_key
111 .set_placeholder_text("variable name (e.g. HOST)");
112 self.add_value.set_placeholder_text("value");
113 self.add_focus = 0;
114 self.mode = Mode::Add;
115 self.message = None;
116 }
117
118 fn start_edit(&mut self) {
119 if let Some(value) = self.rows().get(self.selected).map(|(_, v)| v.clone()) {
120 self.edit_value = TextArea::from(vec![value]);
121 self.edit_value.set_placeholder_text("value");
122 self.mode = Mode::Edit;
123 self.message = None;
124 }
125 }
126
127 fn commit_add(&mut self) {
128 let set = self.add_set.lines().join("").trim().to_string();
129 let key = self.add_key.lines().join("").trim().to_string();
130 let value = self.add_value.lines().join("").to_string();
131 if set.is_empty() || key.is_empty() {
132 self.message = Some("set and key must not be empty".to_string());
133 return;
134 }
135 if !store::is_valid_env_name(&key) {
136 self.message = Some(format!("invalid variable name `{key}`"));
137 return;
138 }
139 self.sets.entry(set).or_default().insert(key, value);
140 self.dirty = true;
141 self.selected = self.rows().len().saturating_sub(1);
142 self.mode = Mode::Browse;
143 }
144
145 fn commit_edit(&mut self) {
146 let value = self.edit_value.lines().join("").to_string();
147 if let Some((set, key)) = self
148 .nth(self.selected)
149 .map(|(s, k)| (s.to_string(), k.to_string()))
150 && let Some(vars) = self.sets.get_mut(&set)
151 {
152 vars.insert(key, value);
153 self.dirty = true;
154 }
155 self.mode = Mode::Browse;
156 }
157
158 fn save(&mut self) -> Result<()> {
159 let toml = store::to_toml(&self.sets);
160 let ciphertext = crypto::encrypt(toml.as_bytes(), self.passphrase.clone())?;
161 store::atomic_write(&self.file, &ciphertext)
162 .with_context(|| format!("failed to write {}", self.file.display()))?;
163 self.dirty = false;
164 Ok(())
165 }
166
167 fn try_save(&mut self) -> Result<bool> {
168 match self.save() {
169 Ok(()) => Ok(true),
170 Err(e) => {
171 self.message = Some(format!("save failed: {e:#}"));
172 Ok(false)
173 }
174 }
175 }
176
177 fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
178 if key.kind != KeyEventKind::Press {
179 return Ok(false);
180 }
181 match self.mode {
182 Mode::Browse => self.handle_browse_key(key),
183 Mode::Add => self.handle_add_key(key),
184 Mode::Edit => self.handle_edit_key(key),
185 Mode::ConfirmDelete | Mode::ConfirmQuit => self.handle_confirm_key(key),
186 }
187 }
188
189 fn handle_browse_key(&mut self, key: KeyEvent) -> Result<bool> {
190 match key.code {
191 KeyCode::Up | KeyCode::Char('k') => self.move_selection(-1),
192 KeyCode::Down | KeyCode::Char('j') => self.move_selection(1),
193 KeyCode::Char('a') => self.start_add(),
194 KeyCode::Char('e') => self.start_edit(),
195 KeyCode::Char('d') => {
196 if !self.rows().is_empty() {
197 self.mode = Mode::ConfirmDelete;
198 }
199 }
200 KeyCode::Char('s') => return self.try_save(),
201 KeyCode::Char('q') => {
202 if self.dirty {
203 self.mode = Mode::ConfirmQuit;
204 } else {
205 return Ok(true);
206 }
207 }
208 _ => {}
209 }
210 Ok(false)
211 }
212
213 fn handle_add_key(&mut self, key: KeyEvent) -> Result<bool> {
214 match key.code {
215 KeyCode::Esc => self.mode = Mode::Browse,
216 KeyCode::Tab => self.add_focus = (self.add_focus + 1) % 3,
217 KeyCode::BackTab => self.add_focus = (self.add_focus + 2) % 3,
218 KeyCode::Enter => self.commit_add(),
219 KeyCode::Char('s') if key.modifiers.contains(KeyModifiers::CONTROL) => {
220 return self.try_save();
221 }
222 _ => {
223 let focused = match self.add_focus {
224 0 => &mut self.add_set,
225 1 => &mut self.add_key,
226 _ => &mut self.add_value,
227 };
228 focused.input(key);
229 }
230 }
231 Ok(false)
232 }
233
234 fn handle_edit_key(&mut self, key: KeyEvent) -> Result<bool> {
235 match key.code {
236 KeyCode::Esc => self.mode = Mode::Browse,
237 KeyCode::Enter => self.commit_edit(),
238 _ => {
239 self.edit_value.input(key);
240 }
241 }
242 Ok(false)
243 }
244
245 fn handle_confirm_key(&mut self, key: KeyEvent) -> Result<bool> {
246 match key.code {
247 KeyCode::Char('y') | KeyCode::Char('Y') => {
248 if self.mode == Mode::ConfirmDelete {
249 let index = self.selected;
250 self.remove_at(index);
251 self.selected = self.selected.min(self.rows().len().saturating_sub(1));
252 self.mode = Mode::Browse;
253 Ok(false)
254 } else {
255 Ok(true)
256 }
257 }
258 KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') | KeyCode::Enter => {
259 self.mode = Mode::Browse;
260 Ok(false)
261 }
262 _ => Ok(false),
263 }
264 }
265
266 fn draw(&mut self, frame: &mut Frame) {
267 let [header, body, footer] = Layout::vertical([
268 Constraint::Length(1),
269 Constraint::Min(1),
270 Constraint::Length(1),
271 ])
272 .areas(frame.area());
273
274 if let Some(message) = &self.message {
275 frame.render_widget(
276 Paragraph::new(message.as_str()).style(Style::new().fg(Color::Red)),
277 header,
278 );
279 } else {
280 frame.render_widget(
281 Paragraph::new(Line::from(vec![
282 Span::raw("secure-env").add_modifier(Modifier::BOLD),
283 Span::raw(format!(" {}", self.file.display())).fg(Color::DarkGray),
284 ])),
285 header,
286 );
287 }
288
289 self.draw_table(frame, body);
290 self.draw_footer(frame, footer);
291
292 match self.mode {
293 Mode::Add => self.draw_add_popup(frame),
294 Mode::Edit => self.draw_edit_popup(frame),
295 Mode::ConfirmDelete | Mode::ConfirmQuit => self.draw_confirm_popup(frame),
296 Mode::Browse => {}
297 }
298 }
299
300 fn draw_table(&mut self, frame: &mut Frame, area: Rect) {
301 let rows = self.rows();
302 if rows.is_empty() {
303 frame.render_widget(
304 Paragraph::new("No variables yet. Press `a` to add one.")
305 .alignment(Alignment::Center)
306 .style(Style::new().fg(Color::DarkGray))
307 .block(Block::bordered().border_type(BorderType::Rounded)),
308 area,
309 );
310 return;
311 }
312
313 let mut state = TableState::default();
314 state.select(Some(self.selected.min(rows.len() - 1)));
315 self.selected = state.selected().unwrap_or(0);
316
317 let header = Row::new(["env", "value"])
318 .style(Style::new().fg(Color::Yellow).add_modifier(Modifier::BOLD));
319 let body = rows
320 .into_iter()
321 .map(|(key, value)| Row::new([Cell::from(key), Cell::from(value)]));
322
323 let table = Table::new(
324 body,
325 [Constraint::Percentage(40), Constraint::Percentage(60)],
326 )
327 .header(header)
328 .block(Block::bordered().border_type(BorderType::Rounded))
329 .row_highlight_style(
330 Style::new()
331 .bg(Color::DarkGray)
332 .add_modifier(Modifier::BOLD),
333 )
334 .column_spacing(2);
335
336 frame.render_stateful_widget(table, area, &mut state);
337 }
338
339 fn draw_footer(&self, frame: &mut Frame, area: Rect) {
340 let help = match self.mode {
341 Mode::Browse => "↑↓ move a add e edit d delete s save & quit q quit",
342 Mode::Add => "tab switch field enter save esc cancel",
343 Mode::Edit => "enter save esc cancel",
344 Mode::ConfirmDelete => "y delete esc cancel",
345 Mode::ConfirmQuit => "y discard & quit esc back",
346 };
347 frame.render_widget(
348 Paragraph::new(help)
349 .alignment(Alignment::Center)
350 .style(Style::new().fg(Color::DarkGray)),
351 area,
352 );
353 }
354
355 fn centered_rect(&self, percent_x: u16, percent_y: u16, area: Rect) -> Rect {
356 let vertical = Layout::vertical([
357 Constraint::Percentage((100 - percent_y) / 2),
358 Constraint::Percentage(percent_y),
359 Constraint::Percentage((100 - percent_y) / 2),
360 ])
361 .split(area);
362 Layout::horizontal([
363 Constraint::Percentage((100 - percent_x) / 2),
364 Constraint::Percentage(percent_x),
365 Constraint::Percentage((100 - percent_x) / 2),
366 ])
367 .split(vertical[1])[1]
368 }
369
370 fn draw_add_popup(&mut self, frame: &mut Frame) {
371 let area = self.centered_rect(60, 40, frame.area());
372 let block = Block::bordered()
373 .border_type(BorderType::Rounded)
374 .title("Add variable");
375 let inner = block.inner(area);
376 frame.render_widget(Clear, area);
377 frame.render_widget(block, area);
378
379 let chunks = Layout::vertical([
380 Constraint::Length(3),
381 Constraint::Length(3),
382 Constraint::Length(3),
383 ])
384 .spacing(1)
385 .split(inner);
386
387 let labels = ["set", "key", "value"];
388 let mut areas = Vec::new();
389 for chunk in chunks.iter() {
390 let line =
391 Layout::horizontal([Constraint::Length(8), Constraint::Min(1)]).split(*chunk);
392 areas.push((line[0], line[1]));
393 }
394 for (i, (label_area, field_area)) in areas.iter().enumerate() {
395 frame.render_widget(
396 Paragraph::new(format!("{}:", labels[i])).style(Style::new().fg(Color::Cyan)),
397 *label_area,
398 );
399 let style = if i == self.add_focus {
400 Style::new().fg(Color::White)
401 } else {
402 Style::new().fg(Color::DarkGray)
403 };
404 let field = match i {
405 0 => &mut self.add_set,
406 1 => &mut self.add_key,
407 _ => &mut self.add_value,
408 };
409 field.set_style(style);
410 frame.render_widget(&*field, *field_area);
411 }
412 }
413
414 fn draw_edit_popup(&mut self, frame: &mut Frame) {
415 let area = self.centered_rect(60, 25, frame.area());
416 let title = self
417 .rows()
418 .get(self.selected)
419 .map(|(key, _)| format!("Edit {key}"))
420 .unwrap_or_else(|| "Edit".to_string());
421 let block = Block::bordered()
422 .border_type(BorderType::Rounded)
423 .title(title);
424 let inner = block.inner(area);
425 frame.render_widget(Clear, area);
426 frame.render_widget(block, area);
427 frame.render_widget(&self.edit_value, inner);
428 }
429
430 fn draw_confirm_popup(&self, frame: &mut Frame) {
431 let area = self.centered_rect(50, 20, frame.area());
432 let (title, text) = match self.mode {
433 Mode::ConfirmDelete => (
434 "Delete",
435 self.rows()
436 .get(self.selected)
437 .map(|(key, _)| format!("Delete `{key}`? (y/N)"))
438 .unwrap_or_default(),
439 ),
440 _ => (
441 "Quit",
442 "Discard unsaved changes and quit? (y/N)".to_string(),
443 ),
444 };
445 let block = Block::bordered()
446 .border_type(BorderType::Rounded)
447 .title(title);
448 let inner = block.inner(area);
449 frame.render_widget(Clear, area);
450 frame.render_widget(block, area);
451 frame.render_widget(
452 Paragraph::new(text)
453 .alignment(Alignment::Center)
454 .style(Style::new().fg(Color::Yellow)),
455 inner,
456 );
457 }
458}
459
460pub fn run(sets: store::Sets, passphrase: SecretString, file: &Path) -> Result<()> {
461 let mut terminal = ratatui::try_init().context("failed to initialize terminal")?;
462 let mut app = App::new(sets, passphrase, file);
463 let result = event_loop(&mut terminal, &mut app);
464 ratatui::restore();
465 result
466}
467
468fn event_loop(terminal: &mut DefaultTerminal, app: &mut App) -> Result<()> {
469 loop {
470 terminal.draw(|frame| app.draw(frame))?;
471 if event::poll(Duration::from_millis(100))?
472 && let Event::Key(key) = event::read()?
473 && app.handle_key(key)?
474 {
475 return Ok(());
476 }
477 }
478}