1use 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#[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#[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
83pub struct App {
85 file_operator: FileOperator,
87 input: String,
89 mode: AppMode,
91 status: String,
93 should_quit: bool,
95 current_dir: PathBuf,
97 files: Vec<FileEntry>,
99 file_list_state: ListState,
101 search_query: String,
103 settings: AppSettings,
105 selected_files: HashMap<usize, bool>,
107 operation_progress: Option<f64>,
109}
110
111#[derive(Debug, Clone, PartialEq)]
113enum ConfirmAction {
114 DeleteFiles,
115 OverwriteFile,
116 ClearSettings,
117}
118
119#[derive(Debug, Clone, PartialEq)]
121enum AppMode {
122 Browser,
124 Settings,
126 Search,
128 InputPassword {
130 operation: OperationType,
131 files: Vec<PathBuf>,
132 compress: bool
133 },
134 Processing {
136 operation: OperationType,
137 current_file: String,
138 progress: f64,
139 },
140 Help,
142 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 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 fn refresh_file_list(&mut self) {
180 self.files.clear();
181 self.selected_files.clear();
182
183 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 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 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 if !self.files.is_empty() {
217 self.file_list_state.select(Some(0));
218 }
219 }
220
221 fn get_selected_files(&self) -> Vec<&FileEntry> {
223 if self.selected_files.is_empty() {
224 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 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 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 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 pub async fn run(&mut self) -> Result<(), Box<dyn std::error::Error>> {
280 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 disable_raw_mode()?;
291 execute!(
292 terminal.backend_mut(),
293 LeaveAlternateScreen,
294 DisableMouseCapture
295 )?;
296 terminal.show_cursor()?;
297
298 result
299 }
300
301 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 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 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 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 self.handle_file_selection().await;
374 }
375 }
376 }
377 }
378 KeyCode::Char(' ') => {
379 self.toggle_selection();
380 }
381 KeyCode::Char('a') => {
382 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 self.selected_files.clear();
393 }
394 _ => {}
395 }
396 }
397
398 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 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 self.process_files(operation, file_paths, compress,
426 self.settings.global_password.as_ref().unwrap().clone()).await;
427 } else {
428 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 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 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 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 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 fn handle_confirm_keys(&mut self, key: KeyCode) {
527 match key {
528 KeyCode::Char('y') | KeyCode::Enter => {
529 if let AppMode::Confirm { action, .. } = &self.mode {
531 match action {
532 ConfirmAction::DeleteFiles => {
533 }
535 ConfirmAction::OverwriteFile => {
536 }
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 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(¶ms, &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 fn ui(&self, f: &mut Frame) {
606 let chunks = Layout::default()
607 .direction(Direction::Vertical)
608 .margin(1)
609 .constraints([
610 Constraint::Length(1), Constraint::Min(1), Constraint::Length(3), ].as_ref())
614 .split(f.size());
615
616 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 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 let status_text = if matches!(self.mode, AppMode::InputPassword { .. }) {
638 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 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 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 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 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 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 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 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 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 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()); }
850}