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>, 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; }
81 _ if self.show_help => {
82 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 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 restore_terminal(terminal)?;
135
136 colored::control::set_override(true);
138
139 let current_params = parse_parameters(&cmd.command);
141 let final_command = substitute_parameters(&cmd.command, ¤t_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 restore_terminal(terminal)?;
183
184 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 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 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 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 == '/' { self.filter_text.clear();
243 self.message = Some(("Type to filter commands...".to_string(), Color::Blue));
244 } else if c != '/' { 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 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 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 let area = centered_rect(80, 80, f.size());
340 f.render_widget(Clear, area); 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), Constraint::Min(0), Constraint::Length(1), Constraint::Length(3), ])
354 .split(f.size());
355
356 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 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 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 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 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 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 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 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}