Skip to main content

secure_env/
tui.rs

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}