sf_cli/
tui.rs

1//! Terminal User Interface for file encryption operations
2
3use crate::{
4    file_ops::FileOperator,
5    models::{OperationParams, OperationType, TargetType},
6};
7use crossterm::{
8    event::{self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode},
9    execute,
10    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
11};
12use ratatui::{
13    backend::{Backend, CrosstermBackend},
14    layout::{Constraint, Direction, Layout},
15    style::{Color, Modifier, Style},
16    text::{Line, Span},
17    widgets::{Block, Borders, List, ListItem, ListState, Paragraph},
18    Frame, Terminal,
19};
20use std::{
21    fs, io,
22    path::PathBuf,
23    collections::HashMap,
24};
25
26/// File entry for display
27#[derive(Debug, Clone)]
28pub struct FileEntry {
29    pub name: String,
30    pub path: PathBuf,
31    pub is_directory: bool,
32    pub is_encrypted: bool,
33    pub size: u64,
34}
35
36impl FileEntry {
37    pub fn new(path: PathBuf) -> io::Result<Self> {
38        let metadata = fs::metadata(&path)?;
39        let name = path.file_name()
40            .and_then(|n| n.to_str())
41            .unwrap_or("")
42            .to_string();
43        
44        let is_directory = metadata.is_dir();
45        let is_encrypted = if !is_directory {
46            name.ends_with(".sf") || name.ends_with(".sf.gz")
47        } else {
48            false
49        };
50
51        Ok(Self {
52            name,
53            path,
54            is_directory,
55            is_encrypted,
56            size: metadata.len(),
57        })
58    }
59}
60
61/// Application settings
62#[derive(Debug, Clone)]
63pub struct AppSettings {
64    pub global_password: Option<String>,
65    pub use_global_password: bool,
66    pub default_compression: bool,
67    pub delete_after_operation: bool,
68    pub verify_checksums: bool,
69}
70
71impl Default for AppSettings {
72    fn default() -> Self {
73        Self {
74            global_password: None,
75            use_global_password: false,
76            default_compression: false,
77            delete_after_operation: false,
78            verify_checksums: true,
79        }
80    }
81}
82
83/// TUI Application state
84pub struct App {
85    /// File operator
86    file_operator: FileOperator,
87    /// Current input text
88    input: String,
89    /// Current screen mode
90    mode: AppMode,
91    /// Status message
92    status: String,
93    /// Whether to quit the application
94    should_quit: bool,
95    /// Current directory
96    current_dir: PathBuf,
97    /// Files in current directory
98    files: Vec<FileEntry>,
99    /// File list state for navigation
100    file_list_state: ListState,
101    /// Search query
102    search_query: String,
103    /// Application settings
104    settings: AppSettings,
105    /// Selected file indices (for multi-select)
106    selected_files: HashMap<usize, bool>,
107    /// Current operation progress
108    operation_progress: Option<f64>,
109}
110
111/// Confirmation action type
112#[derive(Debug, Clone, PartialEq)]
113enum ConfirmAction {
114    DeleteFiles,
115    OverwriteFile,
116    ClearSettings,
117}
118
119/// Application modes
120#[derive(Debug, Clone, PartialEq)]
121enum AppMode {
122    /// File browser view
123    Browser,
124    /// Settings screen
125    Settings,
126    /// Search mode
127    Search,
128    /// Input password
129    InputPassword { 
130        operation: OperationType, 
131        files: Vec<PathBuf>, 
132        compress: bool 
133    },
134    /// Processing files
135    Processing {
136        operation: OperationType,
137        current_file: String,
138        progress: f64,
139    },
140    /// Help screen
141    Help,
142    /// Confirmation dialog
143    Confirm {
144        message: String,
145        action: ConfirmAction,
146    },
147}
148
149impl Default for App {
150    fn default() -> Self {
151        Self::new()
152    }
153}
154
155impl App {
156    /// Create a new TUI application
157    pub fn new() -> Self {
158        let current_dir = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
159        let mut app = Self {
160            file_operator: FileOperator::new(),
161            input: String::new(),
162            mode: AppMode::Browser,
163            status: "Ready - Navigate with ↑/↓, Enter to select, ? for help".to_string(),
164            should_quit: false,
165            current_dir: current_dir.clone(),
166            files: Vec::new(),
167            file_list_state: ListState::default(),
168            search_query: String::new(),
169            settings: AppSettings::default(),
170            selected_files: HashMap::new(),
171            operation_progress: None,
172        };
173        
174        app.refresh_file_list();
175        app
176    }
177
178    /// Refresh the file list for current directory
179    fn refresh_file_list(&mut self) {
180        self.files.clear();
181        self.selected_files.clear();
182        
183        // Add parent directory entry if not at root
184        if let Some(parent) = self.current_dir.parent() {
185            self.files.push(FileEntry {
186                name: "..".to_string(),
187                path: parent.to_path_buf(),
188                is_directory: true,
189                is_encrypted: false,
190                size: 0,
191            });
192        }
193
194        // Read directory contents
195        if let Ok(entries) = fs::read_dir(&self.current_dir) {
196            let mut file_entries: Vec<FileEntry> = entries
197                .filter_map(|entry| {
198                    let entry = entry.ok()?;
199                    FileEntry::new(entry.path()).ok()
200                })
201                .collect();
202
203            // Sort: directories first, then by name
204            file_entries.sort_by(|a, b| {
205                match (a.is_directory, b.is_directory) {
206                    (true, false) => std::cmp::Ordering::Less,
207                    (false, true) => std::cmp::Ordering::Greater,
208                    _ => a.name.cmp(&b.name),
209                }
210            });
211
212            self.files.extend(file_entries);
213        }
214
215        // Reset selection to first item
216        if !self.files.is_empty() {
217            self.file_list_state.select(Some(0));
218        }
219    }
220
221    /// Get selected file entries
222    fn get_selected_files(&self) -> Vec<&FileEntry> {
223        if self.selected_files.is_empty() {
224            // If no files are explicitly selected, use the currently highlighted file
225            if let Some(selected) = self.file_list_state.selected() {
226                if let Some(file) = self.files.get(selected) {
227                    return vec![file];
228                }
229            }
230            return vec![];
231        }
232
233        self.selected_files
234            .keys()
235            .filter_map(|&index| self.files.get(index))
236            .collect()
237    }
238
239    /// Toggle selection of current file
240    fn toggle_selection(&mut self) {
241        if let Some(selected) = self.file_list_state.selected() {
242            if let Some(file) = self.files.get(selected) {
243                if !file.is_directory || file.name != ".." {
244                    let is_selected = self.selected_files.get(&selected).unwrap_or(&false);
245                    if *is_selected {
246                        self.selected_files.remove(&selected);
247                    } else {
248                        self.selected_files.insert(selected, true);
249                    }
250                }
251            }
252        }
253    }
254
255    /// Navigate to directory
256    fn navigate_to(&mut self, path: PathBuf) {
257        if path.is_dir() {
258            self.current_dir = path;
259            self.refresh_file_list();
260        }
261    }
262
263    /// Apply search filter
264    fn filter_files(&self) -> Vec<(usize, &FileEntry)> {
265        if self.search_query.is_empty() {
266            return self.files.iter().enumerate().collect();
267        }
268
269        self.files
270            .iter()
271            .enumerate()
272            .filter(|(_, file)| {
273                file.name.to_lowercase().contains(&self.search_query.to_lowercase())
274            })
275            .collect()
276    }
277
278    /// Run the TUI application
279    pub async fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
280        // Setup terminal
281        enable_raw_mode()?;
282        let mut stdout = io::stdout();
283        execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
284        let backend = CrosstermBackend::new(stdout);
285        let mut terminal = Terminal::new(backend)?;
286
287        let result = self.run_app(&mut terminal).await;
288
289        // Restore terminal
290        disable_raw_mode()?;
291        execute!(
292            terminal.backend_mut(),
293            LeaveAlternateScreen,
294            DisableMouseCapture
295        )?;
296        terminal.show_cursor()?;
297
298        result
299    }
300
301    /// Main application loop
302    async fn run_app<B: Backend>(&mut self, terminal: &mut Terminal<B>) -> Result<(), Box<dyn std::error::Error>> {
303        loop {
304            terminal.draw(|f| self.ui(f))?;
305
306            if self.should_quit {
307                break;
308            }
309
310            if let Event::Key(key) = event::read()? {
311                self.handle_key_event(key.code).await;
312            }
313        }
314        Ok(())
315    }
316
317    /// Handle key events based on current mode
318    async fn handle_key_event(&mut self, key: KeyCode) {
319        match &self.mode.clone() {
320            AppMode::Browser => self.handle_browser_keys(key).await,
321            AppMode::Search => self.handle_search_keys(key),
322            AppMode::Settings => self.handle_settings_keys(key),
323            AppMode::InputPassword { .. } => self.handle_password_input_keys(key).await,
324            AppMode::Processing { .. } => {
325                // Processing mode - only allow quit
326                if let KeyCode::Esc = key {
327                    self.mode = AppMode::Browser;
328                }
329            }
330            AppMode::Help => self.handle_help_keys(key),
331            AppMode::Confirm { .. } => self.handle_confirm_keys(key),
332        }
333    }
334
335    /// Handle keys in browser mode
336    async fn handle_browser_keys(&mut self, key: KeyCode) {
337        match key {
338            KeyCode::Char('q') => {
339                self.should_quit = true;
340            }
341            KeyCode::Char('?') => {
342                self.mode = AppMode::Help;
343            }
344            KeyCode::Char('/') => {
345                self.mode = AppMode::Search;
346                self.search_query.clear();
347                self.input.clear();
348                self.status = "Search: Type to filter files, Esc to cancel".to_string();
349            }
350            KeyCode::Char('s') => {
351                self.mode = AppMode::Settings;
352                self.status = "Settings - Use ↑/↓ to navigate, Enter to toggle".to_string();
353            }
354            KeyCode::Up => {
355                let selected = self.file_list_state.selected().unwrap_or(0);
356                if selected > 0 {
357                    self.file_list_state.select(Some(selected - 1));
358                }
359            }
360            KeyCode::Down => {
361                let selected = self.file_list_state.selected().unwrap_or(0);
362                if selected < self.files.len().saturating_sub(1) {
363                    self.file_list_state.select(Some(selected + 1));
364                }
365            }
366            KeyCode::Enter => {
367                if let Some(selected) = self.file_list_state.selected() {
368                    if let Some(file) = self.files.get(selected) {
369                        if file.is_directory {
370                            self.navigate_to(file.path.clone());
371                        } else {
372                            // File selected - determine operation
373                            self.handle_file_selection().await;
374                        }
375                    }
376                }
377            }
378            KeyCode::Char(' ') => {
379                self.toggle_selection();
380            }
381            KeyCode::Char('a') => {
382                // Select all files (not directories)
383                self.selected_files.clear();
384                for (i, file) in self.files.iter().enumerate() {
385                    if !file.is_directory || file.name != ".." {
386                        self.selected_files.insert(i, true);
387                    }
388                }
389            }
390            KeyCode::Char('c') => {
391                // Clear selection
392                self.selected_files.clear();
393            }
394            _ => {}
395        }
396    }
397
398    /// Handle file selection
399    async fn handle_file_selection(&mut self) {
400        let selected_files = self.get_selected_files();
401        if selected_files.is_empty() {
402            return;
403        }
404
405        // Check if files are encrypted or not
406        let has_encrypted = selected_files.iter().any(|f| f.is_encrypted);
407        let has_unencrypted = selected_files.iter().any(|f| !f.is_encrypted);
408
409        if has_encrypted && has_unencrypted {
410            self.status = "Cannot mix encrypted and unencrypted files in one operation".to_string();
411            return;
412        }
413
414        let operation = if has_encrypted {
415            OperationType::Decrypt
416        } else {
417            OperationType::Encrypt
418        };
419
420        let file_paths: Vec<PathBuf> = selected_files.iter().map(|f| f.path.clone()).collect();
421        let compress = self.settings.default_compression && operation == OperationType::Encrypt;
422
423        if self.settings.use_global_password && self.settings.global_password.is_some() {
424            // Use global password
425            self.process_files(operation, file_paths, compress, 
426                              self.settings.global_password.as_ref().unwrap().clone()).await;
427        } else {
428            // Ask for password
429            self.mode = AppMode::InputPassword {
430                operation: operation.clone(),
431                files: file_paths,
432                compress,
433            };
434            self.input.clear();
435            self.status = format!("Enter password for {} operation:", 
436                                if operation == OperationType::Encrypt { "encryption" } else { "decryption" });
437        }
438    }
439
440    /// Handle keys in search mode
441    fn handle_search_keys(&mut self, key: KeyCode) {
442        match key {
443            KeyCode::Char(c) => {
444                self.input.push(c);
445                self.search_query = self.input.clone();
446            }
447            KeyCode::Backspace => {
448                self.input.pop();
449                self.search_query = self.input.clone();
450            }
451            KeyCode::Enter => {
452                self.mode = AppMode::Browser;
453                self.status = "Search applied".to_string();
454            }
455            KeyCode::Esc => {
456                self.mode = AppMode::Browser;
457                self.search_query.clear();
458                self.input.clear();
459                self.status = "Search cancelled".to_string();
460            }
461            _ => {}
462        }
463    }
464
465    /// Handle keys in settings mode
466    fn handle_settings_keys(&mut self, key: KeyCode) {
467        match key {
468            KeyCode::Esc => {
469                self.mode = AppMode::Browser;
470                self.status = "Settings saved".to_string();
471            }
472            KeyCode::Char('1') => {
473                self.settings.use_global_password = !self.settings.use_global_password;
474            }
475            KeyCode::Char('2') => {
476                self.settings.default_compression = !self.settings.default_compression;
477            }
478            KeyCode::Char('3') => {
479                self.settings.delete_after_operation = !self.settings.delete_after_operation;
480            }
481            KeyCode::Char('4') => {
482                self.settings.verify_checksums = !self.settings.verify_checksums;
483            }
484            _ => {}
485        }
486    }
487
488    /// Handle keys in password input mode
489    async fn handle_password_input_keys(&mut self, key: KeyCode) {
490        match key {
491            KeyCode::Char(c) => {
492                self.input.push(c);
493            }
494            KeyCode::Backspace => {
495                self.input.pop();
496            }
497            KeyCode::Enter => {
498                if !self.input.is_empty() {
499                    if let AppMode::InputPassword { operation, files, compress } = self.mode.clone() {
500                        let password = self.input.clone();
501                        self.process_files(operation, files, compress, password).await;
502                    }
503                }
504            }
505            KeyCode::Esc => {
506                self.mode = AppMode::Browser;
507                self.input.clear();
508                self.status = "Operation cancelled".to_string();
509            }
510            _ => {}
511        }
512    }
513
514    /// Handle keys in help mode
515    fn handle_help_keys(&mut self, key: KeyCode) {
516        match key {
517            KeyCode::Esc | KeyCode::Char('q') => {
518                self.mode = AppMode::Browser;
519                self.status = "Ready - Navigate with ↑/↓, Enter to select, ? for help".to_string();
520            }
521            _ => {}
522        }
523    }
524
525    /// Handle keys in confirm mode
526    fn handle_confirm_keys(&mut self, key: KeyCode) {
527        match key {
528            KeyCode::Char('y') | KeyCode::Enter => {
529                // Handle confirmation action
530                if let AppMode::Confirm { action, .. } = &self.mode {
531                    match action {
532                        ConfirmAction::DeleteFiles => {
533                            // TODO: Implement file deletion
534                        }
535                        ConfirmAction::OverwriteFile => {
536                            // TODO: Implement overwrite
537                        }
538                        ConfirmAction::ClearSettings => {
539                            self.settings = AppSettings::default();
540                        }
541                    }
542                }
543                self.mode = AppMode::Browser;
544                self.status = "Action completed".to_string();
545            }
546            KeyCode::Char('n') | KeyCode::Esc => {
547                self.mode = AppMode::Browser;
548                self.status = "Action cancelled".to_string();
549            }
550            _ => {}
551        }
552    }
553
554    /// Process selected files
555    async fn process_files(&mut self, operation: OperationType, files: Vec<PathBuf>, compress: bool, password: String) {
556        self.mode = AppMode::Processing {
557            operation: operation.clone(),
558            current_file: "Starting...".to_string(),
559            progress: 0.0,
560        };
561
562        for (i, file_path) in files.iter().enumerate() {
563            let progress = (i as f64) / (files.len() as f64) * 100.0;
564            let filename = file_path.file_name()
565                .and_then(|n| n.to_str())
566                .unwrap_or("unknown")
567                .to_string();
568
569            self.mode = AppMode::Processing {
570                operation: operation.clone(),
571                current_file: filename,
572                progress,
573            };
574
575            let target_type = if file_path.is_dir() {
576                TargetType::Directory
577            } else {
578                TargetType::File
579            };
580
581            let params = OperationParams::new(
582                operation.clone(),
583                target_type,
584                file_path.clone(),
585            ).with_compression(compress)
586             .with_delete_source(self.settings.delete_after_operation)
587             .with_verify_checksum(self.settings.verify_checksums);
588
589            let result = self.file_operator.process(&params, &password).await;
590            
591            if !result.success {
592                self.status = format!("Error: {}", result.error.unwrap_or_default());
593                self.mode = AppMode::Browser;
594                return;
595            }
596        }
597
598        self.status = format!("Successfully processed {} files", files.len());
599        self.mode = AppMode::Browser;
600        self.refresh_file_list();
601        self.selected_files.clear();
602    }
603
604    /// Draw the UI
605    fn ui(&self, f: &mut Frame) {
606        let chunks = Layout::default()
607            .direction(Direction::Vertical)
608            .margin(1)
609            .constraints([
610                Constraint::Length(1), // Title
611                Constraint::Min(1),    // Main content
612                Constraint::Length(3), // Status
613            ].as_ref())
614            .split(f.size());
615
616        // Title
617        let title = Paragraph::new("SF-CLI - Secure File Encryption")
618            .style(Style::default().fg(Color::Cyan).add_modifier(Modifier::BOLD));
619        f.render_widget(title, chunks[0]);
620
621        // Main content based on mode
622        match &self.mode {
623            AppMode::Browser => self.draw_browser(f, chunks[1]),
624            AppMode::Search => self.draw_search(f, chunks[1]),
625            AppMode::Settings => self.draw_settings(f, chunks[1]),
626            AppMode::InputPassword { operation, files, .. } => {
627                self.draw_password_input(f, chunks[1], operation, files.len())
628            }
629            AppMode::Processing { operation, current_file, progress } => {
630                self.draw_processing(f, chunks[1], operation, current_file, *progress)
631            }
632            AppMode::Help => self.draw_help(f, chunks[1]),
633            AppMode::Confirm { message, .. } => self.draw_confirm(f, chunks[1], message),
634        }
635
636        // Status bar
637        let status_text = if matches!(self.mode, AppMode::InputPassword { .. }) {
638            // Hide password input
639            format!("{} {}", self.status, "*".repeat(self.input.len()))
640        } else if matches!(self.mode, AppMode::Search) {
641            format!("{} {}", self.status, self.input)
642        } else {
643            self.status.clone()
644        };
645
646        let status_line = Line::from(vec![Span::styled(
647            &status_text,
648            Style::default().fg(Color::Green),
649        )]);
650        let status = Paragraph::new(status_line)
651            .block(Block::default().borders(Borders::ALL));
652        f.render_widget(status, chunks[2]);
653    }
654
655    /// Draw browser interface
656    fn draw_browser(&self, f: &mut Frame, area: ratatui::layout::Rect) {
657        let chunks = Layout::default()
658            .direction(Direction::Horizontal)
659            .constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref())
660            .split(area);
661
662        // File list
663        let filtered_files = self.filter_files();
664        let items: Vec<ListItem> = filtered_files
665            .iter()
666            .enumerate()
667            .map(|(_display_idx, (original_idx, file))| {
668                let prefix = if file.is_directory {
669                    if file.name == ".." {
670                        "📁 "
671                    } else {
672                        "📂 "
673                    }
674                } else if file.is_encrypted {
675                    "🔒 "
676                } else {
677                    "📄 "
678                };
679
680                let style = if self.selected_files.contains_key(original_idx) {
681                    Style::default().bg(Color::Blue).fg(Color::White)
682                } else if file.is_encrypted {
683                    Style::default().fg(Color::Yellow)
684                } else if file.is_directory {
685                    Style::default().fg(Color::Cyan)
686                } else {
687                    Style::default()
688                };
689
690                let size_str = if file.is_directory && file.name != ".." {
691                    "<DIR>".to_string()
692                } else if file.size < 1024 {
693                    format!("{} B", file.size)
694                } else if file.size < 1024 * 1024 {
695                    format!("{:.1} KB", file.size as f64 / 1024.0)
696                } else {
697                    format!("{:.1} MB", file.size as f64 / (1024.0 * 1024.0))
698                };
699
700                ListItem::new(format!("{}{:<30} {:>10}", prefix, file.name, size_str))
701                    .style(style)
702            })
703            .collect();
704
705        let current_dir_display = self.current_dir.to_string_lossy();
706        let file_list = List::new(items)
707            .block(Block::default()
708                .title(format!("Files: {} ({})", current_dir_display, filtered_files.len()))
709                .borders(Borders::ALL))
710            .highlight_style(Style::default().add_modifier(Modifier::REVERSED))
711            .highlight_symbol(">> ");
712
713        f.render_stateful_widget(file_list, chunks[0], &mut self.file_list_state.clone());
714
715        // Info panel
716        let info_text = vec![
717            Line::from("Controls:"),
718            Line::from(""),
719            Line::from("↑/↓     - Navigate"),
720            Line::from("Enter   - Select/Open"),
721            Line::from("Space   - Toggle selection"),
722            Line::from("a       - Select all"),
723            Line::from("c       - Clear selection"),
724            Line::from("/       - Search"),
725            Line::from("s       - Settings"),
726            Line::from("?       - Help"),
727            Line::from("q       - Quit"),
728            Line::from(""),
729            Line::from(Span::styled("Legend:", Style::default().add_modifier(Modifier::BOLD))),
730            Line::from("📂 Directory"),
731            Line::from("📄 File"),
732            Line::from(Span::styled("🔒 Encrypted", Style::default().fg(Color::Yellow))),
733        ];
734
735        let info_panel = Paragraph::new(info_text)
736            .block(Block::default().title("Info").borders(Borders::ALL))
737            .wrap(ratatui::widgets::Wrap { trim: true });
738        f.render_widget(info_panel, chunks[1]);
739    }
740
741    /// Draw search interface
742    fn draw_search(&self, f: &mut Frame, area: ratatui::layout::Rect) {
743        let search_text = format!("Search: {}", self.input);
744        let search_input = Paragraph::new(search_text)
745            .block(Block::default().title("Search Files").borders(Borders::ALL));
746        f.render_widget(search_input, area);
747    }
748
749    /// Draw settings interface
750    fn draw_settings(&self, f: &mut Frame, area: ratatui::layout::Rect) {
751        let settings_text = vec![
752            Line::from("Settings:"),
753            Line::from(""),
754            Line::from(format!("1. Global Password: {}", 
755                if self.settings.use_global_password { "Enabled" } else { "Disabled" })),
756            Line::from(format!("2. Default Compression: {}", 
757                if self.settings.default_compression { "Enabled" } else { "Disabled" })),
758            Line::from(format!("3. Delete After Operation: {}", 
759                if self.settings.delete_after_operation { "Enabled" } else { "Disabled" })),
760            Line::from(format!("4. Verify Checksums: {}", 
761                if self.settings.verify_checksums { "Enabled" } else { "Disabled" })),
762            Line::from(""),
763            Line::from("Press number to toggle, Esc to return"),
764        ];
765
766        let settings_panel = Paragraph::new(settings_text)
767            .block(Block::default().title("Settings").borders(Borders::ALL));
768        f.render_widget(settings_panel, area);
769    }
770
771    /// Draw password input interface
772    fn draw_password_input(&self, f: &mut Frame, area: ratatui::layout::Rect, operation: &OperationType, file_count: usize) {
773        let password_text = format!("Enter password for {} {} file(s):", 
774                                   operation, file_count);
775        let password_input = Paragraph::new(password_text)
776            .block(Block::default().title("Password Input").borders(Borders::ALL));
777        f.render_widget(password_input, area);
778    }
779
780    /// Draw processing interface
781    fn draw_processing(&self, f: &mut Frame, area: ratatui::layout::Rect, operation: &OperationType, current_file: &str, progress: f64) {
782        let processing_text = vec![
783            Line::from(format!("Operation: {}", operation)),
784            Line::from(format!("Current File: {}", current_file)),
785            Line::from(format!("Progress: {:.1}%", progress)),
786        ];
787
788        let processing_panel = Paragraph::new(processing_text)
789            .block(Block::default().title("Processing").borders(Borders::ALL));
790        f.render_widget(processing_panel, area);
791    }
792
793    /// Draw help interface
794    fn draw_help(&self, f: &mut Frame, area: ratatui::layout::Rect) {
795        let help_text = vec![
796            Line::from("SF-CLI Help"),
797            Line::from(""),
798            Line::from("File Operations:"),
799            Line::from("- Select files with Enter or Space"),
800            Line::from("- Encrypted files (🔒) can be decrypted"),
801            Line::from("- Regular files (📄) can be encrypted"),
802            Line::from("- Use 'a' to select all, 'c' to clear selection"),
803            Line::from(""),
804            Line::from("Navigation:"),
805            Line::from("- Use ↑/↓ to move between files"),
806            Line::from("- Enter on directory to navigate"),
807            Line::from("- .. goes to parent directory"),
808            Line::from(""),
809            Line::from("Features:"),
810            Line::from("- File extension preservation"),
811            Line::from("- SHA-256 checksum verification"),
812            Line::from("- Multi-file selection"),
813            Line::from("- Search with '/'"),
814            Line::from("- Settings with 's'"),
815            Line::from(""),
816            Line::from("Press Esc or q to return"),
817        ];
818
819        let help_panel = Paragraph::new(help_text)
820            .block(Block::default().title("Help").borders(Borders::ALL));
821        f.render_widget(help_panel, area);
822    }
823
824    /// Draw confirmation dialog
825    fn draw_confirm(&self, f: &mut Frame, area: ratatui::layout::Rect, message: &str) {
826        let confirm_text = vec![
827            Line::from(message),
828            Line::from(""),
829            Line::from("Press 'y' to confirm, 'n' or Esc to cancel"),
830        ];
831
832        let confirm_panel = Paragraph::new(confirm_text)
833            .block(Block::default().title("Confirm").borders(Borders::ALL));
834        f.render_widget(confirm_panel, area);
835    }
836}
837
838#[cfg(test)]
839mod tests {
840    use super::*;
841
842    #[test]
843    fn test_app_creation() {
844        let app = App::new();
845        assert_eq!(app.mode, AppMode::Browser);
846        assert!(app.input.is_empty());
847        assert!(!app.should_quit);
848        assert!(!app.files.is_empty()); // Should have at least current directory files
849    }
850}