command_vault/ui/
app.rs

1use std::io::{self, Stdout};
2use anyhow::Result;
3use crossterm::{
4    event::{self, Event, KeyCode, KeyModifiers},
5    execute,
6    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
7};
8use ratatui::{
9    backend::CrosstermBackend,
10    Terminal,
11    layout::{Constraint, Direction, Layout, Rect},
12    style::{Color, Modifier, Style},
13    text::{Line, Span},
14    widgets::{Block, Borders, Clear, List, ListItem, Paragraph},
15};
16use crate::db::{Command, Database};
17use crate::utils::params::{substitute_parameters, parse_parameters};
18use crate::exec::{ExecutionContext, execute_shell_command};
19use crate::ui::AddCommandApp;
20
21pub struct App<'a> {
22    pub commands: Vec<Command>,
23    pub selected: Option<usize>,
24    pub show_help: bool,
25    pub message: Option<(String, Color)>,
26    pub filter_text: String,
27    pub filtered_commands: Vec<usize>,
28    pub db: &'a mut Database,
29    pub confirm_delete: Option<usize>, // Index of command pending deletion
30    pub debug_mode: bool,
31}
32
33impl<'a> App<'a> {
34    pub fn new(commands: Vec<Command>, db: &'a mut Database, debug_mode: bool) -> App<'a> {
35        let filtered_commands: Vec<usize> = (0..commands.len()).collect();
36        App {
37            commands,
38            selected: None,
39            show_help: false,
40            message: None,
41            filter_text: String::new(),
42            filtered_commands,
43            db,
44            confirm_delete: None,
45            debug_mode,
46        }
47    }
48
49    pub fn run(&mut self) -> Result<()> {
50        let mut terminal = setup_terminal()?;
51        let res = self.run_app(&mut terminal);
52        restore_terminal(&mut terminal)?;
53        res
54    }
55
56    fn run_app(&mut self, terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
57        loop {
58            terminal.draw(|f| self.ui(f))?;
59
60            if let Event::Key(key) = event::read()? {
61                match key.code {
62                    KeyCode::Char('q') => {
63                        if !self.filter_text.is_empty() {
64                            self.filter_text.clear();
65                            self.update_filtered_commands();
66                        } else if self.confirm_delete.is_some() {
67                            self.confirm_delete = None;
68                        } else if self.show_help {
69                            self.show_help = false;
70                        } else {
71                            return Ok(());
72                        }
73                    }
74                    KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
75                        return Ok(());
76                    }
77                    KeyCode::Char('?') => {
78                        self.show_help = !self.show_help;
79                        continue; // Skip further processing when toggling help
80                    }
81                    _ if self.show_help => {
82                        // If help is shown, ignore all other keys except those above
83                        continue;
84                    }
85                    KeyCode::Char('c') => {
86                        if let Some(selected) = self.selected {
87                            if let Some(&idx) = self.filtered_commands.get(selected) {
88                                if let Some(cmd) = self.commands.get(idx) {
89                                    copy_to_clipboard(&cmd.command)?;
90                                    self.message = Some(("Command copied to clipboard!".to_string(), Color::Green));
91                                }
92                            }
93                        }
94                    }
95                    KeyCode::Char('y') => {
96                        if let Some(selected) = self.selected {
97                            if let Some(&idx) = self.filtered_commands.get(selected) {
98                                if let Some(cmd) = self.commands.get(idx) {
99                                    copy_to_clipboard(&cmd.command)?;
100                                    self.message = Some(("Command copied to clipboard!".to_string(), Color::Green));
101                                }
102                            }
103                        }
104                    }
105                    KeyCode::Enter => {
106                        if let Some(selected) = self.selected {
107                            if let Some(confirm_idx) = self.confirm_delete {
108                                if confirm_idx == selected {
109                                    if let Some(&filtered_idx) = self.filtered_commands.get(selected) {
110                                        if let Some(command_id) = self.commands[filtered_idx].id {
111                                            match self.db.delete_command(command_id) {
112                                                Ok(_) => {
113                                                    self.commands.remove(filtered_idx);
114                                                    self.message = Some(("Command deleted successfully".to_string(), Color::Green));
115                                                    self.update_filtered_commands();
116                                                    // Update selection after deletion
117                                                    if self.filtered_commands.is_empty() {
118                                                        self.selected = None;
119                                                    } else {
120                                                        self.selected = Some(selected.min(self.filtered_commands.len() - 1));
121                                                    }
122                                                }
123                                                Err(e) => {
124                                                    self.message = Some((format!("Failed to delete command: {}", e), Color::Red));
125                                                }
126                                            }
127                                            self.confirm_delete = None;
128                                        }
129                                    }
130                                }
131                            } else if let Some(&filtered_idx) = self.filtered_commands.get(selected) {
132                                if let Some(cmd) = self.commands.get(filtered_idx) {
133                                    // Exit TUI temporarily
134                                    restore_terminal(terminal)?;
135                                    
136                                    // Re-enable colors after restoring terminal
137                                    colored::control::set_override(true);
138
139                                    // If command has parameters, substitute them with user input
140                                    let current_params = parse_parameters(&cmd.command);
141                                    let final_command = substitute_parameters(&cmd.command, &current_params, None)?;
142                                    let ctx = ExecutionContext {
143                                        command: final_command,
144                                        directory: cmd.directory.clone(),
145                                        test_mode: false,
146                                        debug_mode: self.debug_mode,
147                                    };
148                                    execute_shell_command(&ctx)?;
149                                    
150                                    return Ok(());
151                                }
152                            }
153                        }
154                    }
155                    KeyCode::Down | KeyCode::Char('j') => {
156                        if let Some(selected) = self.selected {
157                            if selected < self.filtered_commands.len() - 1 {
158                                self.selected = Some(selected + 1);
159                            }
160                        } else if !self.filtered_commands.is_empty() {
161                            self.selected = Some(0);
162                        }
163                    }
164                    KeyCode::Up | KeyCode::Char('k') => {
165                        if let Some(selected) = self.selected {
166                            if selected > 0 {
167                                self.selected = Some(selected - 1);
168                            }
169                        } else if !self.filtered_commands.is_empty() {
170                            self.selected = Some(self.filtered_commands.len() - 1);
171                        }
172                    }
173                    KeyCode::Char('/') => {
174                        self.filter_text.clear();
175                        self.message = Some(("Type to filter commands...".to_string(), Color::Blue));
176                    }
177                    KeyCode::Char('e') => {
178                        if let Some(selected) = self.selected {
179                            if let Some(&idx) = self.filtered_commands.get(selected) {
180                                if let Some(cmd) = self.commands.get(idx).cloned() {
181                                    // Exit TUI temporarily
182                                    restore_terminal(terminal)?;
183                                    
184                                    // Create AddCommandApp with existing command data
185                                    let mut add_app = AddCommandApp::new();
186                                    add_app.set_command(cmd.command.clone());
187                                    add_app.set_tags(cmd.tags.clone());
188                                    
189                                    let result = add_app.run();
190                                    
191                                    // Re-initialize terminal and force redraw
192                                    let mut new_terminal = setup_terminal()?;
193                                    new_terminal.clear()?;
194                                    *terminal = new_terminal;
195                                    terminal.draw(|f| self.ui(f))?;
196                                    
197                                    match result {
198                                        Ok(Some((new_command, new_tags, _))) => {
199                                            // Update command
200                                            let updated_cmd = Command {
201                                                id: cmd.id,
202                                                command: new_command.clone(),
203                                                timestamp: cmd.timestamp,
204                                                directory: cmd.directory.clone(),
205                                                tags: new_tags,
206                                                parameters: crate::utils::params::parse_parameters(&new_command),
207                                            };
208                                            
209                                            if let Err(e) = self.db.update_command(&updated_cmd) {
210                                                self.message = Some((format!("Failed to update command: {}", e), Color::Red));
211                                            } else {
212                                                // Update local command list
213                                                if let Some(cmd) = self.commands.get_mut(idx) {
214                                                    *cmd = updated_cmd;
215                                                }
216                                                self.message = Some(("Command updated successfully!".to_string(), Color::Green));
217                                            }
218                                        }
219                                        Ok(None) => {
220                                            self.message = Some(("Edit cancelled".to_string(), Color::Yellow));
221                                        }
222                                        Err(e) => {
223                                            self.message = Some((format!("Error during edit: {}", e), Color::Red));
224                                        }
225                                    }
226                                }
227                            }
228                        }
229                        continue;
230                    }
231                    KeyCode::Char('d') => {
232                        if let Some(selected) = self.selected {
233                            if let Some(&filtered_idx) = self.filtered_commands.get(selected) {
234                                if let Some(command_id) = self.commands[filtered_idx].id {
235                                    self.confirm_delete = Some(selected);
236                                }
237                            }
238                        }
239                    }
240                    KeyCode::Char(c) => {
241                        if c == '/' {  // Skip if it's the '/' character that started filter mode
242                            self.filter_text.clear();
243                            self.message = Some(("Type to filter commands...".to_string(), Color::Blue));
244                        } else if c != '/' {  // Skip if it's the '/' character that started filter mode
245                            self.filter_text.push(c);
246                            self.update_filtered_commands();
247                        }
248                    }
249                    KeyCode::Backspace if !self.filter_text.is_empty() => {
250                        self.filter_text.pop();
251                        self.update_filtered_commands();
252                    }
253                    KeyCode::Esc => {
254                        if !self.filter_text.is_empty() {
255                            self.filter_text.clear();
256                            self.update_filtered_commands();
257                        } else if self.confirm_delete.is_some() {
258                            self.confirm_delete = None;
259                            self.message = Some(("Delete operation cancelled".to_string(), Color::Yellow));
260                        }
261                    }
262                    _ => {}
263                }
264            }
265        }
266    }
267
268    /// Update the filtered commands list based on the current filter text
269    pub fn update_filtered_commands(&mut self) {
270        let search_term = self.filter_text.to_lowercase();
271        self.filtered_commands = (0..self.commands.len())
272            .filter(|&i| {
273                let cmd = &self.commands[i];
274                cmd.command.to_lowercase().contains(&search_term) ||
275                cmd.tags.iter().any(|tag| tag.to_lowercase().contains(&search_term)) ||
276                cmd.directory.to_lowercase().contains(&search_term)
277            })
278            .collect();
279        
280        // Update selection
281        if self.filtered_commands.is_empty() {
282            self.selected = None;
283        } else if let Some(selected) = self.selected {
284            if selected >= self.filtered_commands.len() {
285                self.selected = Some(self.filtered_commands.len() - 1);
286            }
287        }
288    }
289
290    fn ui(&mut self, f: &mut ratatui::Frame) {
291        if self.show_help {
292            let help_text = vec![
293                "Command Vault Help",
294                "",
295                "Navigation:",
296                "  ↑/k      - Move cursor up",
297                "  ↓/j      - Move cursor down",
298                "  q        - Quit (or clear filter/cancel delete/close help)",
299                "  Ctrl+c   - Force quit",
300                "",
301                "Command Actions:",
302                "  Enter    - Execute selected command",
303                "  c/y      - Copy command to clipboard",
304                "  e        - Edit selected command (text, tags, directory)",
305                "  d        - Delete selected command (requires confirmation)",
306                "",
307                "Search and Filter:",
308                "  /        - Start filtering commands",
309                "  [type]   - Filter by command text, tags, or directory",
310                "  Esc      - Clear filter or cancel current operation",
311                "  Backspace- Remove last character from filter",
312                "",
313                "Display:",
314                "  ?        - Toggle this help screen",
315                "",
316                "Command Format:",
317                "  - (@param) Parameters are shown with @ prefix",
318                "  - (#tag)  Tags are shown in green with # prefix",
319                "  - (dir)   Working directory is shown if set",
320                "  - (id)    Command IDs are shown in parentheses",
321                "",
322                "Tips:",
323                "  - Use descriptive tags to organize commands",
324                "  - Parameters (@param) allow dynamic input",
325                "  - Filter works on commands, tags, and directories",
326                "  - Working directory affects command execution",
327                "",
328                "Note:",
329                "  - Debug mode can be enabled for troubleshooting",
330                "  - All commands are executed in the current shell",
331                "  - Command history is preserved in the database"
332            ];
333
334            let help_paragraph = Paragraph::new(help_text.join("\n"))
335                .style(Style::default().fg(Color::White))
336                .block(Block::default().borders(Borders::ALL).title("Help (press ? to close)"));
337
338            // Center the help window
339            let area = centered_rect(80, 80, f.size());
340            f.render_widget(Clear, area); // Clear the background
341            f.render_widget(help_paragraph, area);
342            return;
343        }
344
345        let chunks = Layout::default()
346            .direction(Direction::Vertical)
347            .margin(1)
348            .constraints([
349                Constraint::Length(3),  // Title
350                Constraint::Min(0),     // Commands list
351                Constraint::Length(1),  // Filter
352                Constraint::Length(3),  // Status bar
353            ])
354            .split(f.size());
355
356        // Title
357        let title = Paragraph::new("Command Vault")
358            .style(Style::default().fg(Color::Cyan))
359            .block(Block::default().borders(Borders::ALL));
360        f.render_widget(title, chunks[0]);
361
362        // Commands list
363        let commands: Vec<ListItem> = self.filtered_commands.iter()
364            .map(|&i| {
365                let cmd = &self.commands[i];
366                let local_time = cmd.timestamp.with_timezone(&chrono::Local);
367                let time_str = local_time.format("%Y-%m-%d %H:%M:%S").to_string();
368                
369                let mut spans = vec![
370                    Span::styled(
371                        format!("({}) ", cmd.id.unwrap_or(0)),
372                        Style::default().fg(Color::DarkGray)
373                    ),
374                    Span::styled(
375                        format!("[{}] ", time_str),
376                        Style::default().fg(Color::Yellow)
377                    ),
378                    Span::raw(&cmd.command),
379                ];
380
381                if !cmd.tags.is_empty() {
382                    spans.push(Span::raw(" "));
383                    for tag in &cmd.tags {
384                        spans.push(Span::styled(
385                            format!("#{} ", tag),
386                            Style::default().fg(Color::Green)
387                        ));
388                    }
389                }
390
391                ListItem::new(Line::from(spans))
392            })
393            .collect();
394
395        let commands = List::new(commands)
396            .block(Block::default().borders(Borders::ALL).title("Commands"))
397            .highlight_style(Style::default().add_modifier(Modifier::REVERSED));
398        
399        let commands_state = self.selected.map(|i| {
400            let mut state = ratatui::widgets::ListState::default();
401            state.select(Some(i));
402            state
403        });
404
405        if let Some(state) = commands_state {
406            f.render_stateful_widget(commands, chunks[1], &mut state.clone());
407        } else {
408            f.render_widget(commands, chunks[1]);
409        }
410
411        // Filter
412        if !self.filter_text.is_empty() {
413            let filter = Paragraph::new(format!("Filter: {}", self.filter_text))
414                .style(Style::default().fg(Color::Yellow));
415            f.render_widget(filter, chunks[2]);
416        }
417
418        // Status bar with help text or message
419        let status = if let Some((msg, color)) = &self.message {
420            vec![Span::styled(msg, Style::default().fg(*color))]
421        } else if self.show_help {
422            vec![
423                Span::raw("Press "),
424                Span::styled("q", Style::default().fg(Color::Yellow)),
425                Span::raw(" to quit, "),
426                Span::styled("↑↓/jk", Style::default().fg(Color::Yellow)),
427                Span::raw(" to navigate, "),
428                Span::styled("c", Style::default().fg(Color::Yellow)),
429                Span::raw(" or "),
430                Span::styled("y", Style::default().fg(Color::Yellow)),
431                Span::raw(" to copy, "),
432                Span::styled("?", Style::default().fg(Color::Yellow)),
433                Span::raw(" for help"),
434            ]
435        } else {
436            vec![
437                Span::raw("Press "),
438                Span::styled("?", Style::default().fg(Color::Yellow)),
439                Span::raw(" for help"),
440            ]
441        };
442
443        let status = Paragraph::new(Line::from(status))
444            .block(Block::default().borders(Borders::ALL));
445        f.render_widget(status, chunks[3]);
446
447        // Render delete confirmation dialog if needed
448        if let Some(idx) = self.confirm_delete {
449            if let Some(&cmd_idx) = self.filtered_commands.get(idx) {
450                if let Some(cmd) = self.commands.get(cmd_idx) {
451                    let command_str = format!("Command: {}", cmd.command);
452                    let id_str = format!("ID: {}", cmd.id.unwrap_or(0));
453                    
454                    let dialog_text = vec![
455                        "Are you sure you want to delete this command?",
456                        "",
457                        &command_str,
458                        &id_str,
459                        "",
460                        "Press Enter to confirm or Esc to cancel",
461                    ];
462
463                    let dialog = Paragraph::new(dialog_text.join("\n"))
464                        .style(Style::default().fg(Color::White))
465                        .block(Block::default()
466                            .borders(Borders::ALL)
467                            .border_style(Style::default().fg(Color::Red))
468                            .title("Confirm Delete"));
469
470                    // Center the dialog
471                    let area = centered_rect(60, 40, f.size());
472                    f.render_widget(Clear, area);
473                    f.render_widget(dialog, area);
474                }
475            }
476        }
477    }
478}
479
480fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
481    // Calculate popup size based on percentage of screen size
482    let popup_width = (r.width as f32 * (percent_x as f32 / 100.0)) as u16;
483    let popup_height = (r.height as f32 * (percent_y as f32 / 100.0)) as u16;
484
485    // Calculate popup position to center it
486    let popup_x = ((r.width - popup_width) / 2) + r.x;
487    let popup_y = ((r.height - popup_height) / 2) + r.y;
488
489    Rect::new(popup_x, popup_y, popup_width, popup_height)
490}
491
492fn setup_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
493    enable_raw_mode()?;
494    let mut stdout = io::stdout();
495    execute!(stdout, EnterAlternateScreen)?;
496    let backend = CrosstermBackend::new(stdout);
497    let mut terminal = Terminal::new(backend)?;
498    terminal.hide_cursor()?;
499    Ok(terminal)
500}
501
502fn restore_terminal(terminal: &mut Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
503    terminal.show_cursor()?;
504    execute!(terminal.backend_mut(), LeaveAlternateScreen)?;
505    disable_raw_mode()?;
506    colored::control::set_override(true);
507    Ok(())
508}
509
510fn copy_to_clipboard(text: &str) -> Result<()> {
511    #[cfg(target_os = "macos")]
512    {
513        use std::process::Command;
514        let mut child = Command::new("pbcopy")
515            .stdin(std::process::Stdio::piped())
516            .spawn()?;
517        
518        if let Some(mut stdin) = child.stdin.take() {
519            use std::io::Write;
520            stdin.write_all(text.as_bytes())?;
521        }
522        
523        child.wait()?;
524    }
525    
526    #[cfg(target_os = "linux")]
527    {
528        use std::process::Command;
529        let mut child = Command::new("xclip")
530            .arg("-selection")
531            .arg("clipboard")
532            .stdin(std::process::Stdio::piped())
533            .spawn()?;
534        
535        if let Some(mut stdin) = child.stdin.take() {
536            use std::io::Write;
537            stdin.write_all(text.as_bytes())?;
538        }
539        
540        child.wait()?;
541    }
542    
543    Ok(())
544}