envx_tui/
app.rs

1use color_eyre::Result;
2use envx_core::{EnvVar, EnvVarManager};
3use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
4use tui_input::Input;
5use tui_input::backend::crossterm::EventHandler;
6use tui_textarea::{CursorMove, TextArea};
7
8#[derive(Debug, Clone, PartialEq)]
9pub enum Mode {
10    Normal,
11    Search,
12    Edit,
13    Add,
14    Confirm(ConfirmAction),
15    View(String), // View mode for viewing full variable value
16}
17
18#[derive(Debug, Clone, PartialEq)]
19pub enum ConfirmAction {
20    Delete(String),
21    Save(String, String),
22}
23
24#[derive(Debug, Clone, Copy, PartialEq)]
25pub enum EditField {
26    Name,
27    Value,
28}
29
30pub struct App {
31    pub manager: EnvVarManager,
32    pub mode: Mode,
33    pub selected_index: usize,
34    pub filtered_vars: Vec<EnvVar>,
35    pub search_input: Input,
36    pub edit_name_input: Input,
37    pub edit_value_textarea: TextArea<'static>,
38    pub active_edit_field: EditField,
39    pub status_message: Option<(String, std::time::Instant)>,
40    pub should_quit: bool,
41    pub scroll_offset: usize,
42}
43
44impl App {
45    /// Creates a new App instance.
46    ///
47    /// # Errors
48    ///
49    /// Returns an error if the environment variable manager fails to load variables.
50    pub fn new() -> Result<Self> {
51        let mut manager = EnvVarManager::new();
52        manager.load_all()?;
53        let vars = manager.list().into_iter().cloned().collect();
54
55        Ok(Self {
56            manager,
57            mode: Mode::Normal,
58            selected_index: 0,
59            filtered_vars: vars,
60            search_input: Input::default(),
61            edit_name_input: Input::default(),
62            edit_value_textarea: TextArea::default(),
63            active_edit_field: EditField::Name,
64            status_message: None,
65            should_quit: false,
66            scroll_offset: 0,
67        })
68    }
69
70    /// Handles a key event based on the current mode.
71    ///
72    /// Returns `true` if the event was handled and requires a re-render.
73    ///
74    /// # Errors
75    ///
76    /// Returns an error if there's a failure in environment variable operations,
77    /// such as loading, saving, or deleting variables.
78    pub fn handle_key_event(&mut self, key: KeyEvent) -> Result<bool> {
79        match self.mode {
80            Mode::Normal => self.handle_normal_mode(key),
81            Mode::Search => Ok(self.handle_search_mode(key)),
82            Mode::Edit | Mode::Add => Ok(self.handle_edit_mode(key)),
83            Mode::Confirm(ref action) => self.handle_confirm_mode(key, action.clone()),
84            Mode::View(_) => Ok(self.handle_view_mode(key)),
85        }
86    }
87
88    fn handle_normal_mode(&mut self, key: KeyEvent) -> Result<bool> {
89        match key.code {
90            KeyCode::Char('q' | 'Q') => {
91                self.should_quit = true;
92                return Ok(true);
93            }
94            KeyCode::Char('/') => {
95                self.mode = Mode::Search;
96                self.search_input.reset();
97            }
98            KeyCode::Char('a' | 'A') => {
99                self.mode = Mode::Add;
100                self.edit_name_input.reset();
101                self.edit_value_textarea = TextArea::default();
102                self.active_edit_field = EditField::Name;
103            }
104            KeyCode::Char('e' | 'E') => {
105                if !self.filtered_vars.is_empty() {
106                    let var = &self.filtered_vars[self.selected_index];
107                    self.edit_name_input = Input::default().with_value(var.name.clone());
108
109                    // Initialize textarea with the current value
110                    let mut textarea = TextArea::from(var.value.lines().collect::<Vec<&str>>());
111                    textarea.move_cursor(CursorMove::End);
112                    self.edit_value_textarea = textarea;
113
114                    self.active_edit_field = EditField::Name;
115                    self.mode = Mode::Edit;
116                }
117            }
118            KeyCode::Char('v' | 'V') | KeyCode::Enter => {
119                if !self.filtered_vars.is_empty() {
120                    let var = &self.filtered_vars[self.selected_index];
121                    self.mode = Mode::View(var.name.clone());
122                }
123            }
124            KeyCode::Char('d' | 'D') => {
125                if !self.filtered_vars.is_empty() {
126                    let var_name = self.filtered_vars[self.selected_index].name.clone();
127                    self.mode = Mode::Confirm(ConfirmAction::Delete(var_name));
128                }
129            }
130            KeyCode::Char('r' | 'R') => {
131                self.refresh_vars()?;
132                self.set_status("Refreshed environment variables");
133            }
134            KeyCode::Up | KeyCode::Char('k') => {
135                self.move_selection_up();
136            }
137            KeyCode::Down | KeyCode::Char('j') => {
138                self.move_selection_down();
139            }
140            KeyCode::PageUp => {
141                self.page_up();
142            }
143            KeyCode::PageDown => {
144                self.page_down();
145            }
146            KeyCode::Home => {
147                self.selected_index = 0;
148                self.scroll_offset = 0;
149            }
150            KeyCode::End => {
151                if !self.filtered_vars.is_empty() {
152                    self.selected_index = self.filtered_vars.len() - 1;
153                }
154            }
155            _ => {}
156        }
157        Ok(false)
158    }
159
160    fn handle_view_mode(&mut self, key: KeyEvent) -> bool {
161        match key.code {
162            KeyCode::Esc | KeyCode::Char('q') | KeyCode::Enter => {
163                self.mode = Mode::Normal;
164            }
165            _ => {}
166        }
167        false
168    }
169
170    const fn move_selection_up(&mut self) {
171        if self.selected_index > 0 {
172            self.selected_index -= 1;
173        }
174    }
175
176    fn move_selection_down(&mut self) {
177        if !self.filtered_vars.is_empty() && self.selected_index < self.filtered_vars.len() - 1 {
178            self.selected_index += 1;
179        }
180    }
181
182    const fn page_up(&mut self) {
183        if self.selected_index >= 10 {
184            self.selected_index -= 10;
185        } else {
186            self.selected_index = 0;
187        }
188    }
189
190    fn page_down(&mut self) {
191        let max_index = if self.filtered_vars.is_empty() {
192            0
193        } else {
194            self.filtered_vars.len() - 1
195        };
196        if self.selected_index + 10 <= max_index {
197            self.selected_index += 10;
198        } else {
199            self.selected_index = max_index;
200        }
201    }
202
203    pub const fn calculate_scroll(&mut self, visible_height: usize) {
204        // Ensure selected item is visible
205        if self.selected_index < self.scroll_offset {
206            self.scroll_offset = self.selected_index;
207        } else if self.selected_index >= self.scroll_offset + visible_height {
208            self.scroll_offset = self.selected_index.saturating_sub(visible_height - 1);
209        }
210    }
211
212    fn handle_search_mode(&mut self, key: KeyEvent) -> bool {
213        match key.code {
214            KeyCode::Esc | KeyCode::Enter => {
215                self.mode = Mode::Normal;
216                self.apply_search();
217            }
218            _ => {
219                self.search_input.handle_event(&Event::Key(key));
220                self.apply_search();
221            }
222        }
223        false
224    }
225
226    fn handle_edit_mode(&mut self, key: KeyEvent) -> bool {
227        match key.code {
228            KeyCode::Esc => {
229                self.mode = Mode::Normal;
230            }
231            KeyCode::Tab => {
232                // Toggle between name and value fields
233                self.active_edit_field = match self.active_edit_field {
234                    EditField::Name => EditField::Value,
235                    EditField::Value => EditField::Name,
236                };
237            }
238            KeyCode::Enter if key.modifiers.contains(KeyModifiers::CONTROL) => {
239                // Ctrl+Enter to save
240                let name = self.edit_name_input.value().to_string();
241                let value = self.edit_value_textarea.lines().join("\n");
242                if !name.is_empty() {
243                    self.mode = Mode::Confirm(ConfirmAction::Save(name, value));
244                }
245            }
246            _ => {
247                // Handle input based on active field
248                match self.active_edit_field {
249                    EditField::Name => {
250                        self.edit_name_input.handle_event(&Event::Key(key));
251                    }
252                    EditField::Value => {
253                        self.edit_value_textarea.input(key);
254                    }
255                }
256            }
257        }
258        false
259    }
260
261    fn handle_confirm_mode(&mut self, key: KeyEvent, action: ConfirmAction) -> Result<bool> {
262        match key.code {
263            KeyCode::Char('y' | 'Y') => {
264                match action {
265                    ConfirmAction::Delete(name) => match self.manager.delete(&name) {
266                        Ok(()) => {
267                            self.refresh_vars()?;
268                            self.set_status(&format!("Deleted variable: {name}"));
269                        }
270                        Err(e) => {
271                            self.set_status(&format!("Error deleting variable: {e}"));
272                        }
273                    },
274                    ConfirmAction::Save(name, value) => match self.manager.set(&name, &value, true) {
275                        Ok(()) => {
276                            self.refresh_vars()?;
277                            self.set_status(&format!("Saved variable: {name}"));
278                        }
279                        Err(e) => {
280                            self.set_status(&format!("Error saving variable: {e}"));
281                        }
282                    },
283                }
284                self.mode = Mode::Normal;
285            }
286            KeyCode::Char('n' | 'N') | KeyCode::Esc => {
287                self.mode = Mode::Normal;
288            }
289            _ => {}
290        }
291        Ok(false)
292    }
293
294    pub fn tick(&mut self) {
295        // Remove status message after timeout
296        if let Some((_, timestamp)) = &self.status_message {
297            if timestamp.elapsed().as_secs() > 3 {
298                self.status_message = None;
299            }
300        }
301    }
302
303    fn apply_search(&mut self) {
304        let search_term = self.search_input.value();
305        if search_term.is_empty() {
306            self.filtered_vars = self.manager.list().into_iter().cloned().collect();
307        } else {
308            self.filtered_vars = self.manager.search(search_term).into_iter().cloned().collect();
309        }
310
311        // Reset selection and scroll if it's out of bounds
312        if self.selected_index >= self.filtered_vars.len() && !self.filtered_vars.is_empty() {
313            self.selected_index = self.filtered_vars.len() - 1;
314        }
315        self.scroll_offset = 0; // Reset scroll when search changes
316    }
317
318    fn refresh_vars(&mut self) -> Result<()> {
319        self.manager.load_all()?;
320        self.apply_search();
321        Ok(())
322    }
323
324    fn set_status(&mut self, message: &str) {
325        self.status_message = Some((message.to_string(), std::time::Instant::now()));
326    }
327}