Skip to main content

dedups/tui_app/
mod.rs

1use anyhow::Result;
2use crossterm::{
3    event::{
4        self, DisableMouseCapture, EnableMouseCapture, Event as CEvent, KeyCode, KeyEvent,
5        KeyEventKind, KeyModifiers,
6    },
7    execute,
8    terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
9};
10use humansize::{format_size, DECIMAL};
11use num_cpus; // For displaying actual core count in auto mode
12use ratatui::prelude::*;
13use ratatui::widgets::*;
14use std::collections::HashMap; // For grouping
15use std::io::{stdout, Stdout};
16use std::path::{Path, PathBuf}; // Ensure Path is imported here
17use std::str::FromStr;
18use std::sync::mpsc as std_mpsc; // Alias to avoid conflict if crate::mpsc is used elsewhere
19use std::thread as std_thread; // Alias for clarity
20use std::time::{Duration, Instant};
21use tui_input::backend::crossterm::EventHandler; // For tui-input
22use tui_input::Input;
23
24use crate::file_utils::{
25    self, delete_files, move_files, DuplicateSet, FileInfo, SelectionStrategy, SortCriterion,
26    SortOrder,
27};
28use crate::Cli; // Added SortCriterion, SortOrder
29
30// Application state
31#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] // Added PartialEq, Eq
32pub enum ActionType {
33    Keep, // Implicit action for the one file not chosen for delete/move
34    Delete,
35    Move(PathBuf), // Target directory for move
36    Copy(PathBuf), // Target directory for copy
37    Ignore,        // New action type
38}
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct Job {
42    pub action: ActionType,
43    pub file_info: FileInfo,
44    // No explicit destination here, it's part of ActionType::Move/Copy
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum ActivePanel {
49    Sets,
50    Files,
51    Jobs,
52}
53
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum InputMode {
56    Normal,
57    CopyDestination,
58    Settings, // New mode for settings
59    Help,     // New mode for help screen
60}
61
62// ---- New structs for parent folder grouping ----
63#[derive(Debug, Clone)]
64pub struct ParentFolderGroup {
65    pub path: PathBuf,
66    pub sets: Vec<DuplicateSet>,
67    pub is_expanded: bool,
68}
69
70#[derive(Debug, Clone)]
71pub enum DisplayListItem {
72    Folder {
73        path: PathBuf,
74        is_expanded: bool,
75        set_count: usize,
76    },
77    SetEntry {
78        set_hash_preview: String,
79        set_total_size: u64,
80        file_count_in_set: usize,
81        original_group_index: usize,
82        original_set_index_in_group: usize,
83        indent: bool,
84    },
85}
86// ---- End new structs ----
87
88#[derive(Debug)]
89pub struct AppState {
90    pub grouped_data: Vec<ParentFolderGroup>,
91    pub display_list: Vec<DisplayListItem>,
92    pub selected_display_list_index: usize,
93    pub selected_file_index_in_set: usize,
94    pub selected_job_index: usize,
95    pub jobs: Vec<Job>,
96    pub active_panel: ActivePanel,
97    pub default_selection_strategy: SelectionStrategy, // Store parsed strategy
98    pub status_message: Option<String>,                // For feedback
99    pub input_mode: InputMode,
100    pub current_input: Input,                 // Using tui-input crate
101    pub file_for_copy_move: Option<FileInfo>, // Store file when prompting for dest
102
103    // Fields for TUI loading progress
104    pub is_loading: bool,
105    pub loading_message: String,
106    // pub loading_progress_percent: Option<f32>, // For a gauge, if we can get good percentages
107
108    // Modifiable scan settings
109    pub current_algorithm: String,
110    pub current_parallel: Option<usize>,
111    pub rescan_needed: bool, // Flag to indicate if settings changed and rescan is advised
112
113    // Settings Menu State
114    pub selected_setting_category_index: usize, // 0: Strategy, 1: Algorithm, 2: Parallelism, 3: Sort Criterion, 4: Sort Order
115    pub current_sort_criterion: SortCriterion,  // New for sorting
116    pub current_sort_order: SortOrder,          // New for sorting
117    pub sort_settings_changed: bool,            // Flag if sorting needs re-application
118
119    // Media deduplication options
120    pub media_mode: bool,
121    pub media_resolution: String,
122    pub media_formats: Vec<String>,
123    pub media_similarity: u32,
124
125    pub log_messages: Vec<String>,  // For operation output
126    pub log_scroll: usize,          // For scrolling the log
127    pub log_focus: bool,            // Whether log area is focused
128    pub log_filter: Option<String>, // For filtering (stub for now)
129
130    pub is_processing_jobs: bool,
131    pub job_processing_message: String,
132    pub job_progress: (usize, usize), // (done, total)
133
134    pub dry_run: bool, // Indicates if actions should be performed in dry run mode
135}
136
137// Channel for messages from scan thread to TUI thread
138#[derive(Debug)]
139pub enum ScanMessage {
140    StatusUpdate(u8, String), // Stage number (1-3) + message
141    // ProgressUpdate(f32), // If we have percentage
142    Completed(Result<Vec<DuplicateSet>>),
143    Error(String),
144}
145
146pub struct App {
147    pub state: AppState,
148    pub should_quit: bool,
149    scan_thread_join_handle: Option<std_thread::JoinHandle<()>>,
150    scan_rx: Option<std_mpsc::Receiver<ScanMessage>>,
151    scan_tx: Option<std_mpsc::Sender<ScanMessage>>, // Added sender to be stored for rescans
152    cli_config: Cli,                                // Store the initial CLI config
153}
154
155impl App {
156    pub fn new(cli_args: &Cli) -> Self {
157        let strategy = SelectionStrategy::from_str(&cli_args.mode)
158            .unwrap_or(SelectionStrategy::NewestModified);
159        let initial_status = "Preparing to scan for duplicates...";
160
161        let app_state = AppState {
162            grouped_data: Vec::new(),
163            display_list: Vec::new(),
164            selected_display_list_index: 0,
165            selected_file_index_in_set: 0,
166            selected_job_index: 0,
167            jobs: Vec::new(),
168            active_panel: ActivePanel::Sets,
169            default_selection_strategy: strategy,
170            status_message: None,
171            input_mode: InputMode::Normal,
172            current_input: Input::default(),
173            file_for_copy_move: None,
174            is_loading: true, // Always start in loading state, scan will update
175            loading_message: initial_status.to_string(),
176            current_algorithm: cli_args.algorithm.clone(),
177            current_parallel: cli_args.parallel,
178            rescan_needed: false,
179            selected_setting_category_index: 0,
180            current_sort_criterion: cli_args.sort_by, // Initialize from Cli
181            current_sort_order: cli_args.sort_order,  // Initialize from Cli
182            sort_settings_changed: false,
183            media_mode: cli_args.media_mode,
184            media_resolution: cli_args.media_resolution.clone(),
185            media_formats: cli_args.media_formats.clone(),
186            media_similarity: cli_args.media_similarity,
187            log_messages: Vec::new(),
188            log_scroll: 0,
189            log_focus: false,
190            log_filter: None,
191            is_processing_jobs: false,
192            job_processing_message: String::new(),
193            job_progress: (0, 0),
194            dry_run: cli_args.dry_run, // Initialize from CLI args
195        };
196
197        // Always perform async scan for TUI
198        log::info!(
199            "Initializing TUI with directory: {:?}",
200            cli_args.directories[0]
201        );
202        let (tx, rx) = std_mpsc::channel::<ScanMessage>();
203
204        // Send an immediate status update to show we're properly initialized
205        tx.send(ScanMessage::StatusUpdate(
206            1,
207            format!("Starting scan of {}...", cli_args.directories[0].display()),
208        ))
209        .unwrap_or_else(|e| log::error!("Failed to send initial status update: {}", e));
210
211        let mut current_cli_for_scan = cli_args.clone();
212        current_cli_for_scan.algorithm = app_state.current_algorithm.clone();
213        current_cli_for_scan.parallel = app_state.current_parallel;
214        current_cli_for_scan.sort_by = app_state.current_sort_criterion;
215        current_cli_for_scan.sort_order = app_state.current_sort_order;
216
217        log::info!(
218            "Starting scan thread with algorithm={}, parallel={:?}",
219            current_cli_for_scan.algorithm,
220            current_cli_for_scan.parallel
221        );
222
223        let thread_tx = tx.clone();
224        let scan_thread = std_thread::spawn(move || {
225            log::info!("[ScanThread] Starting initial duplicate scan...");
226            thread_tx
227                .send(ScanMessage::StatusUpdate(
228                    1,
229                    "Scan thread initialized, starting file scan...".to_string(),
230                ))
231                .unwrap_or_else(|e| {
232                    log::error!("[ScanThread] Failed to send initialization message: {}", e)
233                });
234
235            match file_utils::find_duplicate_files_with_progress(
236                &current_cli_for_scan,
237                thread_tx.clone(),
238            ) {
239                Ok(raw_sets) => {
240                    log::info!(
241                        "[ScanThread] Scan completed successfully with {} sets",
242                        raw_sets.len()
243                    );
244                    if thread_tx
245                        .send(ScanMessage::Completed(Ok(raw_sets)))
246                        .is_err()
247                    {
248                        log::error!("[ScanThread] Failed to send completion message to TUI.");
249                    }
250                }
251                Err(e) => {
252                    log::error!("[ScanThread] Scan failed with error: {}", e);
253                    if thread_tx.send(ScanMessage::Error(e.to_string())).is_err() {
254                        log::error!("[ScanThread] Failed to send error message to TUI.");
255                    }
256                }
257            }
258            log::info!("[ScanThread] Initial scan finished.");
259        });
260
261        // Wrap the thread in Some() with error handling
262        let id = scan_thread.thread().id();
263        let scan_join_handle = {
264            log::info!("Scan thread started with ID: {:?}", id);
265            Some(scan_thread)
266        };
267
268        Self {
269            state: app_state,
270            should_quit: false,
271            scan_thread_join_handle: scan_join_handle,
272            scan_rx: Some(rx),
273            scan_tx: Some(tx),
274            cli_config: cli_args.clone(),
275        }
276    }
277
278    fn process_raw_sets_into_grouped_view(
279        sets: Vec<DuplicateSet>,
280        default_expanded: bool,
281    ) -> (Vec<ParentFolderGroup>, Vec<DisplayListItem>) {
282        let mut parent_map: HashMap<PathBuf, Vec<DuplicateSet>> = HashMap::new();
283        for set in sets {
284            if let Some(first_file) = set.files.first() {
285                let parent = first_file
286                    .path
287                    .parent()
288                    .unwrap_or_else(|| Path::new("/"))
289                    .to_path_buf();
290                parent_map.entry(parent).or_default().push(set);
291            }
292        }
293
294        let mut grouped_data: Vec<ParentFolderGroup> = parent_map
295            .into_iter()
296            .map(|(path, sets_in_group)| ParentFolderGroup {
297                path,
298                sets: sets_in_group,
299                is_expanded: default_expanded,
300            })
301            .collect();
302
303        grouped_data.sort_by(|a, b| a.path.cmp(&b.path));
304        for group in &mut grouped_data {
305            group.sets.sort_by(|a, b| a.hash.cmp(&b.hash)); // Ensure consistent order of sets within a folder
306        }
307
308        let display_list = App::build_display_list_from_grouped_data(&grouped_data);
309        (grouped_data, display_list)
310    }
311
312    fn build_display_list_from_grouped_data(
313        grouped_data: &[ParentFolderGroup],
314    ) -> Vec<DisplayListItem> {
315        let mut display_list = Vec::new();
316        for (group_idx, group) in grouped_data.iter().enumerate() {
317            display_list.push(DisplayListItem::Folder {
318                path: group.path.clone(),
319                is_expanded: group.is_expanded,
320                set_count: group.sets.len(),
321            });
322            if group.is_expanded {
323                for (set_idx, set_item) in group.sets.iter().enumerate() {
324                    display_list.push(DisplayListItem::SetEntry {
325                        set_hash_preview: set_item.hash.chars().take(8).collect(),
326                        set_total_size: set_item.size,
327                        file_count_in_set: set_item.files.len(),
328                        original_group_index: group_idx,
329                        original_set_index_in_group: set_idx,
330                        indent: true,
331                    });
332                }
333            }
334        }
335        display_list
336    }
337
338    // Method to trigger a rescan
339    fn trigger_rescan(&mut self) {
340        if self.state.is_loading && self.scan_thread_join_handle.is_some() {
341            self.state.status_message = Some("Scan already in progress.".to_string());
342            return;
343        }
344
345        // Attempt to join the previous scan thread if it exists
346        if let Some(handle) = self.scan_thread_join_handle.take() {
347            log::debug!("Attempting to join previous scan thread before rescan...");
348            if let Err(e) = handle.join() {
349                log::error!("Failed to join previous scan thread: {:?}", e);
350                // Decide if we should proceed or not, for now, we proceed cautiously
351            }
352        }
353
354        self.state.grouped_data.clear();
355        self.state.display_list.clear();
356        self.state.jobs.clear();
357        self.state.selected_display_list_index = 0;
358        self.state.selected_file_index_in_set = 0;
359        self.state.selected_job_index = 0;
360        self.state.is_loading = true;
361        self.state.loading_message = "⏳ [0/3] Preparing for rescan...".to_string();
362        self.state.status_message = Some("Starting rescan...".to_string());
363        self.state.rescan_needed = false; // Reset flag as we are acting on it
364
365        let mut current_cli_for_scan = self.cli_config.clone(); // Use stored cli_config
366        current_cli_for_scan.algorithm = self.state.current_algorithm.clone();
367        current_cli_for_scan.parallel = self.state.current_parallel;
368        current_cli_for_scan.sort_by = self.state.current_sort_criterion;
369        current_cli_for_scan.sort_order = self.state.current_sort_order;
370        // Always enable progress for TUI mode
371        current_cli_for_scan.progress = true;
372        current_cli_for_scan.progress_tui = true;
373
374        // Apply media deduplication options
375        current_cli_for_scan.media_mode = self.state.media_mode;
376        current_cli_for_scan.media_resolution = self.state.media_resolution.clone();
377        current_cli_for_scan.media_formats = self.state.media_formats.clone();
378        current_cli_for_scan.media_similarity = self.state.media_similarity;
379
380        // If media mode is enabled, set up the media_dedup_options
381        if current_cli_for_scan.media_mode {
382            // Clear any existing options first
383            current_cli_for_scan.media_dedup_options =
384                crate::media_dedup::MediaDedupOptions::default();
385
386            // Apply settings to media_dedup_options
387            crate::media_dedup::add_media_options_to_cli(
388                &mut current_cli_for_scan.media_dedup_options,
389                self.state.media_mode,
390                &self.state.media_resolution,
391                &self.state.media_formats,
392                self.state.media_similarity,
393            );
394        }
395
396        // Note: We always use progress for TUI internal scans regardless of initial cli.progress
397        // find_duplicate_files_with_progress requires a tx channel.
398        // Ensure scan_tx is Some.
399
400        // Create a fresh channel, assign the receiver to our app's scan_rx
401        let (tx, rx) = std_mpsc::channel::<ScanMessage>();
402        self.scan_rx = Some(rx);
403
404        // Send an initial status to note the rescan
405        tx.send(ScanMessage::StatusUpdate(
406            1,
407            "Starting new scan...".to_string(),
408        ))
409        .unwrap_or_else(|e| log::error!("Failed to send initial rescan status: {}", e));
410
411        // Create the scan thread
412        let thread_tx = tx.clone();
413        let scan_thread = std_thread::spawn(move || {
414            log::info!("[ScanThread] Starting rescan...");
415            match file_utils::find_duplicate_files_with_progress(
416                &current_cli_for_scan,
417                thread_tx.clone(),
418            ) {
419                Ok(raw_sets) => {
420                    log::info!(
421                        "[ScanThread] Rescan completed successfully with {} sets",
422                        raw_sets.len()
423                    );
424                    if thread_tx
425                        .send(ScanMessage::Completed(Ok(raw_sets)))
426                        .is_err()
427                    {
428                        log::error!("[ScanThread] Failed to send rescan completion to TUI.");
429                    }
430                }
431                Err(e) => {
432                    log::error!("[ScanThread] Rescan failed with error: {}", e);
433                    if thread_tx.send(ScanMessage::Error(e.to_string())).is_err() {
434                        log::error!("[ScanThread] Failed to send rescan error to TUI.");
435                    }
436                }
437            }
438            log::info!("[ScanThread] Rescan finished.");
439        });
440
441        let id = scan_thread.thread().id();
442        let scan_join_handle = {
443            log::info!("Scan thread started with ID: {:?}", id);
444            Some(scan_thread)
445        };
446
447        self.scan_thread_join_handle = scan_join_handle;
448        self.scan_tx = Some(tx);
449    }
450
451    // Method to handle messages from the scan thread
452    pub fn handle_scan_messages(&mut self) {
453        if let Some(ref rx) = self.scan_rx {
454            match rx.try_recv() {
455                Ok(message) => {
456                    match message {
457                        ScanMessage::StatusUpdate(stage, msg) => {
458                            // Format the stage indicator for display
459                            let stage_prefix = match stage {
460                                0 => "⏳ [0/3] ", // Pre-scan stage
461                                1 => "📁 [1/3] ",
462                                2 => "🔍 [2/3] ",
463                                3 => "🔄 [3/3] ",
464                                _ => "",
465                            };
466
467                            self.state.loading_message = format!("{}{}", stage_prefix, msg);
468                            log::debug!("Updated loading message: {}", self.state.loading_message);
469                        }
470                        ScanMessage::Completed(result) => {
471                            match result {
472                                Ok(sets) => {
473                                    log::info!("Scan completed with {} sets", sets.len());
474                                    self.state.is_loading = false;
475
476                                    // Process the raw sets into our grouped view
477                                    let (grouped_data, display_list) =
478                                        App::process_raw_sets_into_grouped_view(sets, true);
479                                    self.state.grouped_data = grouped_data;
480                                    self.state.display_list = display_list;
481
482                                    // Apply current sort settings to the loaded data
483                                    self.apply_sort_settings();
484
485                                    self.state.status_message = Some(format!(
486                                        "Scan complete! Found {} duplicate sets.",
487                                        self.state
488                                            .grouped_data
489                                            .iter()
490                                            .map(|g| g.sets.len())
491                                            .sum::<usize>()
492                                    ));
493                                }
494                                Err(e) => {
495                                    log::error!("Scan completed with error: {}", e);
496                                    self.state.is_loading = false;
497                                    self.state.status_message = Some(format!("Scan failed: {}", e));
498                                }
499                            }
500                        }
501                        ScanMessage::Error(err) => {
502                            log::error!("Scan error: {}", err);
503                            self.state.is_loading = false;
504                            self.state.status_message = Some(format!("Scan error: {}", err));
505                        }
506                    }
507                }
508                Err(std_mpsc::TryRecvError::Empty) => {
509                    // No messages available, perfectly normal.
510                }
511                Err(std_mpsc::TryRecvError::Disconnected) => {
512                    // Channel disconnected. This could happen if the scan thread finishes.
513                    log::warn!("Scan thread channel disconnected.");
514                    if self.state.is_loading {
515                        // If still in loading state, this is an error.
516                        self.state.is_loading = false;
517                        self.state.status_message =
518                            Some("Scan thread disconnected unexpectedly.".to_string());
519                    }
520                }
521            }
522        }
523    }
524
525    pub fn on_key(&mut self, key_event: KeyEvent) {
526        self.state.status_message = None; // Clear old status on new key press
527
528        match self.state.input_mode {
529            InputMode::Normal => self.handle_normal_mode_key(key_event),
530            InputMode::CopyDestination => self.handle_copy_dest_input_key(key_event),
531            InputMode::Settings => self.handle_settings_mode_key(key_event),
532            InputMode::Help => self.handle_help_mode_key(key_event),
533        }
534        self.validate_selection_indices(); // Ensure selections are valid after any action
535    }
536
537    fn handle_normal_mode_key(&mut self, key_event: KeyEvent) {
538        match key_event.code {
539            KeyCode::Char('q') | KeyCode::Char('c')
540                if key_event.modifiers.contains(KeyModifiers::CONTROL) =>
541            {
542                self.should_quit = true;
543            }
544            KeyCode::Char('h') => {
545                self.state.input_mode = InputMode::Help;
546                self.state.status_message = Some("Displaying Help. Esc to exit.".to_string());
547            }
548            KeyCode::Char('d') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
549                // Toggle dry run mode
550                self.state.dry_run = !self.state.dry_run;
551                let status = if self.state.dry_run {
552                    "Dry run mode ENABLED - No actual changes will be made"
553                } else {
554                    "Dry run mode DISABLED - Actions will perform actual changes"
555                };
556                self.state.status_message = Some(status.to_string());
557                self.state.log_messages.push(status.to_string());
558
559                // Add more information about dry run mode when enabled
560                if self.state.dry_run {
561                    self.state.log_messages.push(
562                        "In dry run mode, all operations are simulated and logged but no actual changes are made to files.".to_string()
563                    );
564                    if !self.state.jobs.is_empty() {
565                        self.state.log_messages.push(
566                            format!("Current job queue contains {} operations that will be simulated when executed.",
567                                   self.state.jobs.len())
568                        );
569                    }
570                }
571
572                log::info!("{}", status);
573            }
574            KeyCode::Char('a') => {
575                // Global toggle between deleting ALL files or KEEPING all (no explicit jobs).
576                // To avoid huge memory spikes we only create Delete jobs when needed and
577                // never generate explicit Keep jobs (no job = Keep).
578
579                // 1. Count total files across all duplicate sets.
580                let total_files: usize = self
581                    .state
582                    .grouped_data
583                    .iter()
584                    .map(|g| g.sets.iter().map(|s| s.files.len()).sum::<usize>())
585                    .sum();
586
587                // 2. Count current Delete jobs.
588                let current_delete_jobs = self
589                    .state
590                    .jobs
591                    .iter()
592                    .filter(|j| matches!(j.action, ActionType::Delete))
593                    .count();
594
595                let currently_all_deleted = current_delete_jobs == total_files && total_files > 0;
596
597                if currently_all_deleted {
598                    // Toggle to KEEP all: simply clear the job list.
599                    self.state.jobs.clear();
600                    self.state.status_message =
601                        Some("All delete jobs cleared. All files kept.".to_string());
602                    self.state
603                        .log_messages
604                        .push("Toggled: KEEP all files (cleared delete jobs)".to_string());
605                } else {
606                    // Toggle to DELETE all: rebuild jobs list with Delete actions for every file.
607                    self.state.jobs.clear();
608
609                    // Iterate over grouped_data without cloning large intermediate Vec.
610                    for group in &self.state.grouped_data {
611                        for set in &group.sets {
612                            for file in &set.files {
613                                self.state.jobs.push(Job {
614                                    action: ActionType::Delete,
615                                    file_info: file.clone(),
616                                });
617                            }
618                        }
619                    }
620
621                    self.state.status_message =
622                        Some(format!("All {} files marked for delete", total_files));
623                    self.state
624                        .log_messages
625                        .push(format!("Toggled: DELETE all {} files", total_files));
626                }
627            }
628            KeyCode::Char('d') => {
629                // Mark all files in the selected set or folder for delete
630                if let Some(selected) = self
631                    .state
632                    .display_list
633                    .get(self.state.selected_display_list_index)
634                {
635                    match selected {
636                        DisplayListItem::SetEntry {
637                            original_group_index,
638                            original_set_index_in_group,
639                            ..
640                        } => {
641                            let files_to_process = if let Some(group) =
642                                self.state.grouped_data.get(*original_group_index)
643                            {
644                                group.sets[*original_set_index_in_group].files.clone()
645                            } else {
646                                Vec::new()
647                            };
648                            let paths: Vec<_> =
649                                files_to_process.iter().map(|f| f.path.clone()).collect();
650                            self.state
651                                .jobs
652                                .retain(|job| !paths.contains(&job.file_info.path));
653                            for file in files_to_process {
654                                self.state.jobs.push(Job {
655                                    action: ActionType::Delete,
656                                    file_info: file,
657                                });
658                            }
659                            self.state.status_message =
660                                Some("All files in set marked for delete".to_string());
661                        }
662                        DisplayListItem::Folder { .. } => {
663                            // Find the group for this folder
664                            let group_index = self.state.display_list
665                                [..=self.state.selected_display_list_index]
666                                .iter()
667                                .filter(|item| matches!(item, DisplayListItem::Folder { .. }))
668                                .count()
669                                - 1;
670                            let files_to_process =
671                                if let Some(group) = self.state.grouped_data.get(group_index) {
672                                    group
673                                        .sets
674                                        .iter()
675                                        .flat_map(|set| set.files.clone())
676                                        .collect::<Vec<_>>()
677                                } else {
678                                    Vec::new()
679                                };
680                            let paths: Vec<_> =
681                                files_to_process.iter().map(|f| f.path.clone()).collect();
682                            self.state
683                                .jobs
684                                .retain(|job| !paths.contains(&job.file_info.path));
685                            for file in files_to_process {
686                                self.state.jobs.push(Job {
687                                    action: ActionType::Delete,
688                                    file_info: file,
689                                });
690                            }
691                            self.state.status_message =
692                                Some("All files in folder marked for delete".to_string());
693                        }
694                    }
695                }
696            }
697            KeyCode::Char('k') => {
698                // Mark all files in the selected set or folder for keep
699                if let Some(selected) = self
700                    .state
701                    .display_list
702                    .get(self.state.selected_display_list_index)
703                {
704                    match selected {
705                        DisplayListItem::SetEntry {
706                            original_group_index,
707                            original_set_index_in_group,
708                            ..
709                        } => {
710                            let files_to_process = if let Some(group) =
711                                self.state.grouped_data.get(*original_group_index)
712                            {
713                                group.sets[*original_set_index_in_group].files.clone()
714                            } else {
715                                Vec::new()
716                            };
717                            let paths: Vec<_> =
718                                files_to_process.iter().map(|f| f.path.clone()).collect();
719                            self.state
720                                .jobs
721                                .retain(|job| !paths.contains(&job.file_info.path));
722                            for file in files_to_process {
723                                self.state.jobs.push(Job {
724                                    action: ActionType::Keep,
725                                    file_info: file,
726                                });
727                            }
728                            self.state.status_message =
729                                Some("All files in set marked to keep".to_string());
730                        }
731                        DisplayListItem::Folder { .. } => {
732                            // Find the group for this folder
733                            let group_index = self.state.display_list
734                                [..=self.state.selected_display_list_index]
735                                .iter()
736                                .filter(|item| matches!(item, DisplayListItem::Folder { .. }))
737                                .count()
738                                - 1;
739                            let files_to_process =
740                                if let Some(group) = self.state.grouped_data.get(group_index) {
741                                    group
742                                        .sets
743                                        .iter()
744                                        .flat_map(|set| set.files.clone())
745                                        .collect::<Vec<_>>()
746                                } else {
747                                    Vec::new()
748                                };
749                            let paths: Vec<_> =
750                                files_to_process.iter().map(|f| f.path.clone()).collect();
751                            self.state
752                                .jobs
753                                .retain(|job| !paths.contains(&job.file_info.path));
754                            for file in files_to_process {
755                                self.state.jobs.push(Job {
756                                    action: ActionType::Keep,
757                                    file_info: file,
758                                });
759                            }
760                            self.state.status_message =
761                                Some("All files in folder marked to keep".to_string());
762                        }
763                    }
764                }
765            }
766            KeyCode::Tab => {
767                self.cycle_active_panel();
768            }
769            KeyCode::Char('e') => {
770                let result = self.process_pending_jobs();
771                match result {
772                    Ok(_) => {
773                        self.state
774                            .log_messages
775                            .push("Executed all pending jobs.".to_string());
776                    }
777                    Err(e) => {
778                        self.state
779                            .log_messages
780                            .push(format!("Error processing jobs: {}", e));
781                    }
782                }
783            }
784            KeyCode::Char('r') => {
785                self.trigger_rescan();
786            }
787            KeyCode::Char('s') => {
788                self.state.input_mode = InputMode::Settings;
789                self.state.status_message = Some("Entered settings mode. Esc to exit.".to_string());
790            }
791            KeyCode::Char('i') => {
792                self.set_action_for_selected_file(ActionType::Ignore);
793            }
794            KeyCode::Char('c') => {
795                self.initiate_copy_action();
796            }
797            KeyCode::Up => match self.state.active_panel {
798                ActivePanel::Sets => self.select_previous_set(),
799                ActivePanel::Files => self.select_previous_file_in_set(),
800                ActivePanel::Jobs => self.select_previous_job(),
801            },
802            KeyCode::Down => match self.state.active_panel {
803                ActivePanel::Sets => self.select_next_set(),
804                ActivePanel::Files => self.select_next_file_in_set(),
805                ActivePanel::Jobs => self.select_next_job(),
806            },
807            KeyCode::Left => {
808                self.state.active_panel = ActivePanel::Sets;
809            }
810            KeyCode::Right => {
811                self.focus_files_panel();
812            }
813            KeyCode::Char('x') | KeyCode::Delete | KeyCode::Backspace => {
814                // Remove selected job from any panel
815                let before = self.state.jobs.len();
816                self.remove_selected_job();
817                let after = self.state.jobs.len();
818                if after < before {
819                    self.state.log_messages.push("Job removed.".to_string());
820                } else {
821                    self.state
822                        .log_messages
823                        .push("No job selected to remove or jobs list empty.".to_string());
824                }
825            }
826            KeyCode::Char('g') if key_event.modifiers.contains(KeyModifiers::CONTROL) => {
827                self.state.log_focus = !self.state.log_focus;
828                self.state.status_message = Some(if self.state.log_focus {
829                    "Log focus ON (Up/Down/PgUp/PgDn, Ctrl+L: clear, /: filter, Esc: exit log)"
830                        .to_string()
831                } else {
832                    "Log focus OFF".to_string()
833                });
834            }
835            KeyCode::Char('l')
836                if key_event.modifiers.contains(KeyModifiers::CONTROL) && self.state.log_focus =>
837            {
838                self.state.log_messages.clear();
839                self.state.log_scroll = 0;
840                self.state.status_message = Some("Log cleared.".to_string());
841            }
842            KeyCode::PageUp if self.state.log_focus => {
843                let log_height = 5;
844                if self.state.log_scroll >= log_height {
845                    self.state.log_scroll -= log_height;
846                } else {
847                    self.state.log_scroll = 0;
848                }
849            }
850            KeyCode::PageDown if self.state.log_focus => {
851                let log_height = 5;
852                let max_scroll = self.state.log_messages.len().saturating_sub(log_height);
853                if self.state.log_scroll + log_height < max_scroll {
854                    self.state.log_scroll += log_height;
855                } else {
856                    self.state.log_scroll = max_scroll;
857                }
858            }
859            KeyCode::Esc if self.state.log_focus => {
860                self.state.log_focus = false;
861                self.state.status_message = Some("Exited log focus.".to_string());
862            }
863            KeyCode::Char('/') if self.state.log_focus => {
864                self.state.log_filter = Some(String::new());
865                self.state.status_message =
866                    Some("Log filter: (type to filter, Esc to clear)".to_string());
867            }
868            _ => {}
869        }
870    }
871
872    fn handle_settings_mode_key(&mut self, key_event: KeyEvent) {
873        match key_event.code {
874            KeyCode::Esc => {
875                self.state.input_mode = InputMode::Normal;
876                if self.state.rescan_needed {
877                    self.state.status_message =
878                        Some("Exited settings. Ctrl+R to apply algo/parallel changes.".to_string());
879                }
880                if self.state.sort_settings_changed {
881                    self.apply_sort_settings(); // Apply sort changes immediately on exiting settings
882                    self.state.status_message = Some(
883                        self.state
884                            .status_message
885                            .clone()
886                            .map_or("".to_string(), |s| s + " ")
887                            + "Sort settings applied.",
888                    );
889                }
890                if !self.state.rescan_needed && !self.state.sort_settings_changed {
891                    // access sort_settings_changed *after* it might have been reset by apply_sort_settings
892                    self.state.status_message = Some("Exited settings mode.".to_string());
893                }
894                self.state.sort_settings_changed = false; // Reset flag after processing
895            }
896            KeyCode::Up => {
897                self.state.selected_setting_category_index =
898                    self.state.selected_setting_category_index.saturating_sub(1);
899            }
900            KeyCode::Down => {
901                self.state.selected_setting_category_index =
902                    (self.state.selected_setting_category_index + 1).min(8); // Max index is 8 now including media options
903            }
904            // Strategy selection keys (n, o, s, l)
905            KeyCode::Char('n') if self.state.selected_setting_category_index == 0 => {
906                self.state.default_selection_strategy = SelectionStrategy::NewestModified;
907                self.state.status_message = Some("Strategy: Newest Modified".to_string());
908            }
909            KeyCode::Char('o') if self.state.selected_setting_category_index == 0 => {
910                self.state.default_selection_strategy = SelectionStrategy::OldestModified;
911                self.state.status_message = Some("Strategy: Oldest Modified".to_string());
912            }
913            KeyCode::Char('s') if self.state.selected_setting_category_index == 0 => {
914                self.state.default_selection_strategy = SelectionStrategy::ShortestPath;
915                self.state.status_message = Some("Strategy: Shortest Path".to_string());
916            }
917            KeyCode::Char('l') if self.state.selected_setting_category_index == 0 => {
918                self.state.default_selection_strategy = SelectionStrategy::LongestPath;
919                self.state.status_message = Some("Strategy: Longest Path".to_string());
920            }
921            // Algorithm selection keys (m, a, b, x, g, f, c)
922            KeyCode::Char('m') if self.state.selected_setting_category_index == 1 => {
923                self.state.current_algorithm = "md5".to_string();
924                self.state.rescan_needed = true;
925                self.state.status_message = Some("Algorithm: md5 (Rescan needed)".to_string());
926            }
927            KeyCode::Char('a') if self.state.selected_setting_category_index == 1 => {
928                self.state.current_algorithm = "sha256".to_string();
929                self.state.rescan_needed = true;
930                self.state.status_message = Some("Algorithm: sha256 (Rescan needed)".to_string());
931            }
932            KeyCode::Char('b') if self.state.selected_setting_category_index == 1 => {
933                self.state.current_algorithm = "blake3".to_string();
934                self.state.rescan_needed = true;
935                self.state.status_message = Some("Algorithm: blake3 (Rescan needed)".to_string());
936            }
937            KeyCode::Char('x') if self.state.selected_setting_category_index == 1 => {
938                self.state.current_algorithm = "xxhash".to_string();
939                self.state.rescan_needed = true;
940                self.state.status_message = Some("Algorithm: xxhash (Rescan needed)".to_string());
941            }
942            KeyCode::Char('g') if self.state.selected_setting_category_index == 1 => {
943                self.state.current_algorithm = "gxhash".to_string();
944                self.state.rescan_needed = true;
945                self.state.status_message = Some("Algorithm: gxhash (Rescan needed)".to_string());
946            }
947            KeyCode::Char('f') if self.state.selected_setting_category_index == 1 => {
948                self.state.current_algorithm = "fnv1a".to_string();
949                self.state.rescan_needed = true;
950                self.state.status_message = Some("Algorithm: fnv1a (Rescan needed)".to_string());
951            }
952            KeyCode::Char('c') if self.state.selected_setting_category_index == 1 => {
953                self.state.current_algorithm = "crc32".to_string();
954                self.state.rescan_needed = true;
955                self.state.status_message = Some("Algorithm: crc32 (Rescan needed)".to_string());
956            }
957            // Parallelism adjustment keys (+, -, 0-9)
958            KeyCode::Char('0') if self.state.selected_setting_category_index == 2 => {
959                self.state.current_parallel = None; // None signifies auto
960                self.state.rescan_needed = true;
961                self.state.status_message =
962                    Some("Parallel Cores: Auto (Rescan needed)".to_string());
963            }
964            KeyCode::Char(c @ '1'..='9') if self.state.selected_setting_category_index == 2 => {
965                // Simple single digit for now. Could extend to multi-digit input.
966                let cores = c.to_digit(10).map(|d| d as usize);
967                if self.state.current_parallel != cores {
968                    self.state.current_parallel = cores;
969                    self.state.rescan_needed = true;
970                    self.state.status_message =
971                        Some(format!("Parallel Cores: {} (Rescan needed)", c));
972                }
973            }
974            KeyCode::Char('+') if self.state.selected_setting_category_index == 2 => {
975                let current_val = self.state.current_parallel.unwrap_or(0);
976                // Cap at num_cpus or a reasonable max like 16 if num_cpus is too high/unavailable?
977                // For simplicity, let's just increment, max 16 for now.
978                let new_val = (current_val + 1).min(16);
979                if self.state.current_parallel != Some(new_val) {
980                    self.state.current_parallel = Some(new_val);
981                    self.state.rescan_needed = true;
982                    self.state.status_message =
983                        Some(format!("Parallel Cores: {} (Rescan needed)", new_val));
984                }
985            }
986            KeyCode::Char('-') if self.state.selected_setting_category_index == 2 => {
987                let current_val = self.state.current_parallel.unwrap_or(1); // If auto (None), treat as 1 for decrement start
988                if current_val > 1 {
989                    // Minimum 1 core
990                    let new_val = current_val - 1;
991                    if self.state.current_parallel != Some(new_val) {
992                        self.state.current_parallel = Some(new_val);
993                        self.state.rescan_needed = true;
994                        self.state.status_message =
995                            Some(format!("Parallel Cores: {} (Rescan needed)", new_val));
996                    }
997                } else if current_val == 1 && self.state.current_parallel.is_some() {
998                    // Allow going from 1 to Auto (None)
999                    self.state.current_parallel = None;
1000                    self.state.rescan_needed = true;
1001                    self.state.status_message =
1002                        Some("Parallel Cores: Auto (Rescan needed)".to_string());
1003                }
1004            }
1005            // Sort Criterion Keys (f, z, c, m, p) - for FileName, FileSize, CreatedAt, ModifiedAt, PathLength
1006            KeyCode::Char('f') if self.state.selected_setting_category_index == 3 => {
1007                self.state.current_sort_criterion = SortCriterion::FileName;
1008                self.state.sort_settings_changed = true;
1009                self.state.status_message = Some("Sort By: File Name (apply on exit)".to_string());
1010            }
1011            KeyCode::Char('z') if self.state.selected_setting_category_index == 3 => {
1012                // z for siZe
1013                self.state.current_sort_criterion = SortCriterion::FileSize;
1014                self.state.sort_settings_changed = true;
1015                self.state.status_message = Some("Sort By: File Size (apply on exit)".to_string());
1016            }
1017            KeyCode::Char('c') if self.state.selected_setting_category_index == 3 => {
1018                self.state.current_sort_criterion = SortCriterion::CreatedAt;
1019                self.state.sort_settings_changed = true;
1020                self.state.status_message =
1021                    Some("Sort By: Created Date (apply on exit)".to_string());
1022            }
1023            KeyCode::Char('m') if self.state.selected_setting_category_index == 3 => {
1024                // m for modified
1025                self.state.current_sort_criterion = SortCriterion::ModifiedAt;
1026                self.state.sort_settings_changed = true;
1027                self.state.status_message =
1028                    Some("Sort By: Modified Date (apply on exit)".to_string());
1029            }
1030            KeyCode::Char('p') if self.state.selected_setting_category_index == 3 => {
1031                self.state.current_sort_criterion = SortCriterion::PathLength;
1032                self.state.sort_settings_changed = true;
1033                self.state.status_message =
1034                    Some("Sort By: Path Length (apply on exit)".to_string());
1035            }
1036            // Sort Order Keys (a, d) - for Ascending, Descending
1037            KeyCode::Char('a') if self.state.selected_setting_category_index == 4 => {
1038                self.state.current_sort_order = SortOrder::Ascending;
1039                self.state.sort_settings_changed = true;
1040                self.state.status_message =
1041                    Some("Sort Order: Ascending (apply on exit)".to_string());
1042            }
1043            KeyCode::Char('d') if self.state.selected_setting_category_index == 4 => {
1044                self.state.current_sort_order = SortOrder::Descending;
1045                self.state.sort_settings_changed = true;
1046                self.state.status_message =
1047                    Some("Sort Order: Descending (apply on exit)".to_string());
1048            }
1049            // Media Deduplication Toggle
1050            KeyCode::Char('e') if self.state.selected_setting_category_index == 5 => {
1051                self.state.media_mode = !self.state.media_mode;
1052                self.state.rescan_needed = true;
1053                if self.state.media_mode {
1054                    // Check if ffmpeg is available
1055                    if crate::media_dedup::is_ffmpeg_available() {
1056                        self.state.status_message =
1057                            Some("Media Mode: Enabled (Rescan needed)".to_string());
1058                    } else {
1059                        self.state.status_message = Some("Media Mode: Enabled - ffmpeg not found, video processing may be limited (Rescan needed)".to_string());
1060                        self.state.log_messages.push(
1061                            "Warning: ffmpeg not found. Video deduplication will be limited."
1062                                .to_string(),
1063                        );
1064                    }
1065                } else {
1066                    self.state.status_message =
1067                        Some("Media Mode: Disabled (Rescan needed)".to_string());
1068                }
1069            }
1070            // Resolution Preference
1071            KeyCode::Char('h') if self.state.selected_setting_category_index == 6 => {
1072                self.state.media_resolution = "highest".to_string();
1073                self.state.rescan_needed = true;
1074                self.state.status_message =
1075                    Some("Media Resolution Preference: Highest (Rescan needed)".to_string());
1076            }
1077            KeyCode::Char('l') if self.state.selected_setting_category_index == 6 => {
1078                self.state.media_resolution = "lowest".to_string();
1079                self.state.rescan_needed = true;
1080                self.state.status_message =
1081                    Some("Media Resolution Preference: Lowest (Rescan needed)".to_string());
1082            }
1083            KeyCode::Char('c') if self.state.selected_setting_category_index == 6 => {
1084                self.state.media_resolution = "1280x720".to_string(); // Default to 720p
1085                self.state.rescan_needed = true;
1086                self.state.status_message = Some(
1087                    "Media Resolution Preference: Custom (1280x720) (Rescan needed)".to_string(),
1088                );
1089            }
1090            // Format Preference
1091            KeyCode::Char('r') if self.state.selected_setting_category_index == 7 => {
1092                self.state.media_formats = vec![
1093                    "raw".to_string(),
1094                    "png".to_string(),
1095                    "jpg".to_string(),
1096                    "mp4".to_string(),
1097                    "wav".to_string(),
1098                ];
1099                self.state.rescan_needed = true;
1100                self.state.status_message =
1101                    Some("Media Format Preference: RAW > PNG > JPG (Rescan needed)".to_string());
1102            }
1103            KeyCode::Char('p') if self.state.selected_setting_category_index == 7 => {
1104                self.state.media_formats = vec![
1105                    "png".to_string(),
1106                    "jpg".to_string(),
1107                    "raw".to_string(),
1108                    "mp4".to_string(),
1109                    "wav".to_string(),
1110                ];
1111                self.state.rescan_needed = true;
1112                self.state.status_message =
1113                    Some("Media Format Preference: PNG > JPG > RAW (Rescan needed)".to_string());
1114            }
1115            KeyCode::Char('j') if self.state.selected_setting_category_index == 7 => {
1116                self.state.media_formats = vec![
1117                    "jpg".to_string(),
1118                    "raw".to_string(),
1119                    "png".to_string(),
1120                    "mp4".to_string(),
1121                    "wav".to_string(),
1122                ];
1123                self.state.rescan_needed = true;
1124                self.state.status_message =
1125                    Some("Media Format Preference: JPG > RAW > PNG (Rescan needed)".to_string());
1126            }
1127            // Similarity Threshold
1128            KeyCode::Char('1') if self.state.selected_setting_category_index == 8 => {
1129                self.state.media_similarity = 95;
1130                self.state.rescan_needed = true;
1131                self.state.status_message = Some(
1132                    "Media Similarity Threshold: 95% (Very strict) (Rescan needed)".to_string(),
1133                );
1134            }
1135            KeyCode::Char('2') if self.state.selected_setting_category_index == 8 => {
1136                self.state.media_similarity = 90;
1137                self.state.rescan_needed = true;
1138                self.state.status_message =
1139                    Some("Media Similarity Threshold: 90% (Default) (Rescan needed)".to_string());
1140            }
1141            KeyCode::Char('3') if self.state.selected_setting_category_index == 8 => {
1142                self.state.media_similarity = 85;
1143                self.state.rescan_needed = true;
1144                self.state.status_message =
1145                    Some("Media Similarity Threshold: 85% (Relaxed) (Rescan needed)".to_string());
1146            }
1147            KeyCode::Char('4') if self.state.selected_setting_category_index == 8 => {
1148                self.state.media_similarity = 75;
1149                self.state.rescan_needed = true;
1150                self.state.status_message = Some(
1151                    "Media Similarity Threshold: 75% (Very relaxed) (Rescan needed)".to_string(),
1152                );
1153            }
1154            _ => {}
1155        }
1156    }
1157
1158    fn handle_copy_dest_input_key(&mut self, key_event: KeyEvent) {
1159        match key_event.code {
1160            KeyCode::Enter => {
1161                let dest_path_str = self.state.current_input.value().to_string();
1162                self.state.current_input.reset();
1163                self.state.input_mode = InputMode::Normal;
1164                if let Some(file_to_copy) = self.state.file_for_copy_move.take() {
1165                    if !dest_path_str.trim().is_empty() {
1166                        let dest_path = PathBuf::from(dest_path_str.trim());
1167                        self.set_action_for_selected_file(ActionType::Copy(dest_path.clone()));
1168                        self.state.status_message = Some(format!(
1169                            "Marked {} for copy to {}",
1170                            file_to_copy.path.display(),
1171                            dest_path.display()
1172                        ));
1173                    } else {
1174                        self.state.status_message =
1175                            Some("Copy cancelled: empty destination path.".to_string());
1176                    }
1177                } else {
1178                    self.state.status_message =
1179                        Some("Copy cancelled: no file selected.".to_string());
1180                }
1181            }
1182            KeyCode::Esc => {
1183                self.state.current_input.reset();
1184                self.state.input_mode = InputMode::Normal;
1185                self.state.file_for_copy_move = None;
1186                self.state.status_message = Some("Copy action cancelled.".to_string());
1187            }
1188            _ => {
1189                // Pass the event to tui-input handler
1190                self.state
1191                    .current_input
1192                    .handle_event(&CEvent::Key(key_event));
1193            }
1194        }
1195    }
1196
1197    fn initiate_copy_action(&mut self) {
1198        if let Some(selected_file) = self.current_selected_file().cloned() {
1199            self.state.file_for_copy_move = Some(selected_file);
1200            self.state.input_mode = InputMode::CopyDestination;
1201            self.state.current_input.reset(); // Clear previous input
1202            self.state.status_message = Some(
1203                "Enter destination path for copy (Enter to confirm, Esc to cancel):".to_string(),
1204            );
1205        } else {
1206            self.state.status_message = Some("No file selected to copy.".to_string());
1207        }
1208    }
1209
1210    fn cycle_active_panel(&mut self) {
1211        self.state.active_panel = match self.state.active_panel {
1212            ActivePanel::Sets => ActivePanel::Files,
1213            ActivePanel::Files => ActivePanel::Jobs,
1214            ActivePanel::Jobs => ActivePanel::Sets,
1215        };
1216        log::debug!("Active panel changed to: {:?}", self.state.active_panel);
1217    }
1218
1219    fn focus_files_panel(&mut self) {
1220        if !self.state.display_list.is_empty() {
1221            self.state.active_panel = ActivePanel::Files;
1222        }
1223    }
1224
1225    fn select_next_set(&mut self) {
1226        if !self.state.display_list.is_empty() {
1227            self.state.selected_display_list_index =
1228                (self.state.selected_display_list_index + 1) % self.state.display_list.len();
1229            self.state.selected_file_index_in_set = 0;
1230        }
1231    }
1232
1233    fn select_previous_set(&mut self) {
1234        if !self.state.display_list.is_empty() {
1235            if self.state.selected_display_list_index > 0 {
1236                self.state.selected_display_list_index -= 1;
1237            } else {
1238                self.state.selected_display_list_index = self.state.display_list.len() - 1;
1239            }
1240            self.state.selected_file_index_in_set = 0;
1241        }
1242    }
1243
1244    fn select_next_file_in_set(&mut self) {
1245        if let Some(set) = self.current_selected_set_from_display_list() {
1246            if !set.files.is_empty() {
1247                self.state.selected_file_index_in_set =
1248                    (self.state.selected_file_index_in_set + 1) % set.files.len();
1249            }
1250        }
1251    }
1252
1253    fn select_previous_file_in_set(&mut self) {
1254        if let Some(set) = self.current_selected_set_from_display_list() {
1255            if !set.files.is_empty() {
1256                if self.state.selected_file_index_in_set > 0 {
1257                    self.state.selected_file_index_in_set -= 1;
1258                } else {
1259                    self.state.selected_file_index_in_set = set.files.len() - 1;
1260                }
1261            }
1262        }
1263    }
1264
1265    fn set_action_for_selected_file(&mut self, action_type: ActionType) {
1266        if let Some(selected_file_info) = self.current_selected_file().cloned() {
1267            // Remove any existing job for this file first
1268            self.state
1269                .jobs
1270                .retain(|job| job.file_info.path != selected_file_info.path);
1271
1272            // Add the new job
1273            log::info!(
1274                "Setting action {:?} for file {:?}",
1275                action_type,
1276                selected_file_info.path
1277            );
1278            self.state.jobs.push(Job {
1279                action: action_type.clone(),
1280                file_info: selected_file_info.clone(),
1281            });
1282            self.state.status_message = Some(format!(
1283                "Marked {} for {:?}.",
1284                selected_file_info
1285                    .path
1286                    .file_name()
1287                    .unwrap_or_default()
1288                    .to_string_lossy(),
1289                action_type
1290            ));
1291        } else {
1292            self.state.status_message = Some("No file selected to set action.".to_string());
1293        }
1294    }
1295
1296    #[allow(dead_code)]
1297    fn set_selected_file_as_kept(&mut self) {
1298        let file_index_in_set = self.state.selected_file_index_in_set;
1299        let mut _status_update: Option<String> = None;
1300        let mut jobs_to_add: Vec<Job> = Vec::new();
1301        let mut paths_in_set_to_update_jobs_for: Vec<PathBuf> = Vec::new();
1302        let mut file_to_keep_path_option: Option<PathBuf> = None;
1303
1304        if let Some(current_duplicate_set_ref) = self.current_selected_set_from_display_list() {
1305            if let Some(file_to_keep_cloned) = current_duplicate_set_ref
1306                .files
1307                .get(file_index_in_set)
1308                .cloned()
1309            {
1310                log::info!(
1311                    "User designated {:?} as to be KEPT.",
1312                    file_to_keep_cloned.path
1313                );
1314                _status_update = Some(format!(
1315                    "Marked {} to be KEPT.",
1316                    file_to_keep_cloned
1317                        .path
1318                        .file_name()
1319                        .unwrap_or_default()
1320                        .to_string_lossy()
1321                ));
1322
1323                file_to_keep_path_option = Some(file_to_keep_cloned.path.clone());
1324                jobs_to_add.push(Job {
1325                    action: ActionType::Keep,
1326                    file_info: file_to_keep_cloned.clone(),
1327                });
1328
1329                paths_in_set_to_update_jobs_for = current_duplicate_set_ref
1330                    .files
1331                    .iter()
1332                    .map(|f| f.path.clone())
1333                    .collect();
1334
1335                for file_in_set in &current_duplicate_set_ref.files {
1336                    if file_in_set.path != file_to_keep_cloned.path {
1337                        // Check if already ignored before deciding to mark for delete
1338                        let is_ignored = self.state.jobs.iter().any(|job| {
1339                            job.file_info.path == file_in_set.path
1340                                && job.action == ActionType::Ignore
1341                        });
1342                        if !is_ignored {
1343                            jobs_to_add.push(Job {
1344                                action: ActionType::Delete,
1345                                file_info: file_in_set.clone(),
1346                            });
1347                            log::debug!(
1348                                "Auto-marking {:?} for DELETE as another file in set is kept.",
1349                                file_in_set.path
1350                            );
1351                        }
1352                    }
1353                }
1354            } else {
1355                _status_update = Some("No file selected in set, or set is empty.".to_string());
1356            }
1357        } else {
1358            _status_update =
1359                Some("No duplicate set selected (or a folder is selected).".to_string());
1360        }
1361
1362        // Now, perform mutations to self.state *after* borrows from current_selected_set_from_display_list are dropped
1363        if let Some(msg) = _status_update {
1364            self.state.status_message = Some(msg);
1365        }
1366
1367        if let Some(_kept_path) = file_to_keep_path_option.take() {
1368            // Remove all existing jobs for any file in this specific set first
1369            // This is important to handle re-marking a different file as kept, or changing mind.
1370            if !paths_in_set_to_update_jobs_for.is_empty() {
1371                self.state
1372                    .jobs
1373                    .retain(|job| !paths_in_set_to_update_jobs_for.contains(&job.file_info.path));
1374            }
1375            // Then add the new jobs decided above
1376            self.state.jobs.extend(jobs_to_add);
1377        } else if !jobs_to_add.is_empty() {
1378            // This case might happen if only a delete was added without a keep (e.g. if logic changes)
1379            // For now, if no file_to_keep was identified, we only update status.
1380            // If jobs_to_add contains items but file_to_keep_path_option is None, it implies an issue or an edge case not fully handled.
1381            // However, the current logic ensures jobs_to_add is only populated if file_to_keep is found.
1382        }
1383    }
1384
1385    #[allow(dead_code)]
1386    fn mark_set_for_deletion(&mut self) {
1387        if let Some(selected_set_to_action) = self.current_selected_set_from_display_list().cloned()
1388        {
1389            // Use the renamed method
1390            if selected_set_to_action.files.len() < 2 {
1391                self.state.status_message =
1392                    Some("Set has less than 2 files, no action taken.".to_string());
1393                return;
1394            }
1395
1396            match file_utils::determine_action_targets(
1397                &selected_set_to_action,
1398                self.state.default_selection_strategy,
1399            ) {
1400                Ok((kept_file, files_to_delete)) => {
1401                    let kept_file_path = kept_file.path.clone();
1402                    let mut files_marked_for_delete = 0;
1403
1404                    // First, remove any existing jobs for files in this set
1405                    self.state.jobs.retain(|job| {
1406                        !selected_set_to_action
1407                            .files
1408                            .iter()
1409                            .any(|f_in_set| f_in_set.path == job.file_info.path)
1410                    });
1411
1412                    // Add Keep job for the determined file
1413                    self.state.jobs.push(Job {
1414                        action: ActionType::Keep,
1415                        file_info: kept_file.clone(),
1416                    });
1417                    log::info!(
1418                        "Auto-marking {:?} to KEEP based on strategy {:?}.",
1419                        kept_file.path,
1420                        self.state.default_selection_strategy
1421                    );
1422
1423                    // Add Delete jobs for all other files
1424                    for file_to_delete in files_to_delete {
1425                        // Double check it's not the one we decided to keep (should be handled by determine_action_targets)
1426                        if file_to_delete.path != kept_file_path {
1427                            self.state.jobs.push(Job {
1428                                action: ActionType::Delete,
1429                                file_info: file_to_delete.clone(),
1430                            });
1431                            files_marked_for_delete += 1;
1432                            log::info!("Auto-marking {:?} for DELETE in set.", file_to_delete.path);
1433                        }
1434                    }
1435                    self.state.status_message = Some(format!(
1436                        "Marked {} files for DELETE, 1 to KEEP in current set.",
1437                        files_marked_for_delete
1438                    ));
1439                }
1440                Err(e) => {
1441                    self.state.status_message =
1442                        Some(format!("Error determining actions for set: {}", e));
1443                    log::error!("Could not determine action targets for set deletion: {}", e);
1444                }
1445            }
1446        } else {
1447            self.state.status_message = Some("No set selected.".to_string());
1448        }
1449    }
1450
1451    fn validate_selection_indices(&mut self) {
1452        if self.state.display_list.is_empty() {
1453            self.state.selected_display_list_index = 0;
1454            self.state.selected_file_index_in_set = 0;
1455            return;
1456        }
1457        if self.state.selected_display_list_index >= self.state.display_list.len() {
1458            self.state.selected_display_list_index =
1459                self.state.display_list.len().saturating_sub(1);
1460        }
1461
1462        if let Some(selected_item) = self
1463            .state
1464            .display_list
1465            .get(self.state.selected_display_list_index)
1466        {
1467            match selected_item {
1468                DisplayListItem::SetEntry {
1469                    original_group_index,
1470                    original_set_index_in_group,
1471                    ..
1472                } => {
1473                    if let Some(current_set) = self
1474                        .state
1475                        .grouped_data
1476                        .get(*original_group_index)
1477                        .and_then(|group| group.sets.get(*original_set_index_in_group))
1478                    {
1479                        if current_set.files.is_empty() {
1480                            self.state.selected_file_index_in_set = 0;
1481                        } else if self.state.selected_file_index_in_set >= current_set.files.len() {
1482                            self.state.selected_file_index_in_set =
1483                                current_set.files.len().saturating_sub(1);
1484                        }
1485                    } else {
1486                        self.state.selected_file_index_in_set = 0; // Should not happen if display_list is sync with grouped_data
1487                    }
1488                }
1489                DisplayListItem::Folder { .. } => {
1490                    self.state.selected_file_index_in_set = 0; // No files to select when a folder is selected
1491                }
1492            }
1493        } else {
1494            // display_list is empty or index out of bounds (should be caught by earlier check)
1495            self.state.selected_file_index_in_set = 0;
1496        }
1497
1498        if self.state.jobs.is_empty() {
1499            self.state.selected_job_index = 0;
1500        } else if self.state.selected_job_index >= self.state.jobs.len() {
1501            self.state.selected_job_index = self.state.jobs.len().saturating_sub(1);
1502        }
1503    }
1504
1505    // Gets the actual DuplicateSet if a SetEntry is selected in the display list
1506    pub fn current_selected_set_from_display_list(&self) -> Option<&DuplicateSet> {
1507        if let Some(selected_item) = self
1508            .state
1509            .display_list
1510            .get(self.state.selected_display_list_index)
1511        {
1512            match selected_item {
1513                DisplayListItem::SetEntry {
1514                    original_group_index,
1515                    original_set_index_in_group,
1516                    ..
1517                } => self
1518                    .state
1519                    .grouped_data
1520                    .get(*original_group_index)
1521                    .and_then(|group| group.sets.get(*original_set_index_in_group)),
1522                DisplayListItem::Folder { .. } => None, // No specific set if a folder is selected
1523            }
1524        } else {
1525            None
1526        }
1527    }
1528
1529    // Current selected file in the middle panel, uses current_selected_set_from_display_list
1530    pub fn current_selected_file(&self) -> Option<&FileInfo> {
1531        self.current_selected_set_from_display_list()
1532            .and_then(|set| set.files.get(self.state.selected_file_index_in_set))
1533    }
1534
1535    fn process_pending_jobs(&mut self) -> Result<()> {
1536        if self.state.jobs.is_empty() {
1537            self.state.status_message = Some("No jobs to process.".to_string());
1538            self.state
1539                .log_messages
1540                .push("No jobs to process.".to_string());
1541            return Ok(());
1542        }
1543
1544        // Set the dry_run flag based on app state
1545        let dry_run_mode = self.state.dry_run;
1546        if dry_run_mode {
1547            self.state
1548                .log_messages
1549                .push("DRY RUN MODE: Simulating actions without making changes".to_string());
1550        }
1551
1552        self.state.is_processing_jobs = true;
1553        self.state.job_processing_message = if dry_run_mode {
1554            "Simulating jobs (DRY RUN)..."
1555        } else {
1556            "Processing jobs..."
1557        }
1558        .to_string();
1559
1560        let total_jobs = self.state.jobs.len();
1561        self.state.job_progress = (0, total_jobs);
1562        let mut success_count = 0;
1563        let mut fail_count = 0;
1564        let jobs_to_process = self.state.jobs.drain(..).collect::<Vec<_>>(); // Take ownership
1565        for (idx, job) in jobs_to_process.into_iter().enumerate() {
1566            self.state.job_progress = (idx + 1, total_jobs);
1567            let result: Result<(), anyhow::Error> = match job.action {
1568                ActionType::Delete => {
1569                    match delete_files(&[job.file_info.clone()], dry_run_mode) {
1570                        Ok((1, logs)) => {
1571                            // Add logs from delete_files to our log messages
1572                            for log in logs {
1573                                self.state.log_messages.push(log);
1574                            }
1575                            Ok(())
1576                        }
1577                        Ok((count, logs)) => {
1578                            // Add logs anyway even when count is unexpected
1579                            for log in logs {
1580                                self.state.log_messages.push(log);
1581                            }
1582                            Err(anyhow::anyhow!(
1583                                "Delete action affected {} files, expected 1.",
1584                                count
1585                            ))
1586                        }
1587                        Err(e) => Err(e),
1588                    }
1589                }
1590                ActionType::Move(ref target_dir) => {
1591                    match move_files(&[job.file_info.clone()], target_dir, dry_run_mode) {
1592                        Ok((1, logs)) => {
1593                            // Add logs from move_files to our log messages
1594                            for log in logs {
1595                                self.state.log_messages.push(log);
1596                            }
1597                            Ok(())
1598                        }
1599                        Ok((count, logs)) => {
1600                            // Add logs anyway even when count is unexpected
1601                            for log in logs {
1602                                self.state.log_messages.push(log);
1603                            }
1604                            Err(anyhow::anyhow!(
1605                                "Move action affected {} files, expected 1.",
1606                                count
1607                            ))
1608                        }
1609                        Err(e) => Err(e),
1610                    }
1611                }
1612                ActionType::Copy(ref target_dir) => {
1613                    log::debug!(
1614                        "Attempting to copy {:?} to {:?}",
1615                        job.file_info.path,
1616                        target_dir
1617                    );
1618
1619                    if dry_run_mode {
1620                        self.state.log_messages.push(format!(
1621                            "[DRY RUN] Would copy {} to {}",
1622                            job.file_info.path.display(),
1623                            target_dir.display()
1624                        ));
1625
1626                        // Add more detailed logs similar to delete_files and move_files
1627                        if !target_dir.exists() {
1628                            self.state.log_messages.push(format!(
1629                                "[DRY RUN] Would create target directory: {}",
1630                                target_dir.display()
1631                            ));
1632                        }
1633
1634                        // Check for potential destination conflicts (even in dry run mode)
1635                        let file_name = job.file_info.path.file_name().unwrap_or_default();
1636                        let dest_path = target_dir.join(file_name);
1637                        if dest_path.exists() {
1638                            self.state.log_messages.push(format!(
1639                                "[DRY RUN] Note: Destination {} exists. Would be renamed with _copy suffix",
1640                                dest_path.display()));
1641                        }
1642
1643                        self.state
1644                            .log_messages
1645                            .push(format!("[DRY RUN] File size: {} bytes", job.file_info.size));
1646
1647                        Ok(())
1648                    } else {
1649                        if !target_dir.exists() {
1650                            if let Err(e) = std::fs::create_dir_all(target_dir) {
1651                                let error_msg = format!(
1652                                    "Failed to create target directory {}: {}",
1653                                    target_dir.display(),
1654                                    e
1655                                );
1656                                self.state.log_messages.push(error_msg);
1657                                log::error!(
1658                                    "Failed to create target directory {:?} for copy: {}",
1659                                    target_dir,
1660                                    e
1661                                );
1662                                return Err(e.into());
1663                            }
1664                            self.state
1665                                .log_messages
1666                                .push(format!("Created directory: {}", target_dir.display()));
1667                        }
1668                        let file_name = job.file_info.path.file_name().unwrap_or_default();
1669                        let mut dest_path = target_dir.join(file_name);
1670                        let mut counter = 1;
1671                        while dest_path.exists() {
1672                            let stem = dest_path.file_stem().unwrap_or_default().to_string_lossy();
1673                            let ext = dest_path.extension().unwrap_or_default().to_string_lossy();
1674                            let new_name = format!(
1675                                "{}_copy({}){}{}",
1676                                stem.trim_end_matches(&format!("_copy({})", counter - 1))
1677                                    .trim_end_matches("_copy"),
1678                                counter,
1679                                if ext.is_empty() { "" } else { "." },
1680                                ext
1681                            );
1682                            dest_path = target_dir.join(new_name);
1683                            counter += 1;
1684                        }
1685                        std::fs::copy(&job.file_info.path, &dest_path)
1686                            .map(|size| {
1687                                self.state.log_messages.push(format!(
1688                                    "Copied: {} -> {} ({} bytes)",
1689                                    job.file_info.path.display(),
1690                                    dest_path.display(),
1691                                    size
1692                                ));
1693                            })
1694                            .map_err(|e| {
1695                                let error_msg = format!(
1696                                    "Failed to copy {}: {}",
1697                                    job.file_info.path.display(),
1698                                    e
1699                                );
1700                                self.state.log_messages.push(error_msg);
1701                                log::error!(
1702                                    "Failed to copy {:?} to {:?}: {}",
1703                                    job.file_info.path,
1704                                    dest_path,
1705                                    e
1706                                );
1707                                anyhow::Error::from(e)
1708                            })
1709                    }
1710                }
1711                ActionType::Keep | ActionType::Ignore => Ok(()),
1712            };
1713            if result.is_ok() {
1714                success_count += 1;
1715                if dry_run_mode {
1716                    self.state.log_messages.push(format!(
1717                        "[DRY RUN] Success: Would perform {:?} for {}",
1718                        job.action,
1719                        job.file_info.path.display()
1720                    ));
1721                } else {
1722                    self.state.log_messages.push(format!(
1723                        "Success: {:?} for {}",
1724                        job.action,
1725                        job.file_info.path.display()
1726                    ));
1727                }
1728            } else {
1729                fail_count += 1;
1730                self.state.log_messages.push(format!(
1731                    "Failed: {:?} for {}: {}",
1732                    job.action,
1733                    job.file_info.path.display(),
1734                    result.err().unwrap()
1735                ));
1736            }
1737        }
1738        self.state.is_processing_jobs = false;
1739
1740        if dry_run_mode {
1741            self.state.job_processing_message = format!(
1742                "[DRY RUN] Simulated jobs. Success: {}, Fail: {}",
1743                success_count, fail_count
1744            );
1745        } else {
1746            self.state.job_processing_message = format!(
1747                "Jobs processed. Success: {}, Fail: {}",
1748                success_count, fail_count
1749            );
1750        }
1751
1752        self.state.status_message = Some(self.state.job_processing_message.clone());
1753        self.state.job_progress = (0, 0);
1754        self.state.selected_job_index = 0;
1755        Ok(())
1756    }
1757
1758    fn select_next_job(&mut self) {
1759        if !self.state.jobs.is_empty() {
1760            self.state.selected_job_index =
1761                (self.state.selected_job_index + 1) % self.state.jobs.len();
1762        }
1763    }
1764
1765    fn select_previous_job(&mut self) {
1766        if !self.state.jobs.is_empty() {
1767            if self.state.selected_job_index > 0 {
1768                self.state.selected_job_index -= 1;
1769            } else {
1770                self.state.selected_job_index = self.state.jobs.len() - 1;
1771            }
1772        }
1773    }
1774
1775    fn remove_selected_job(&mut self) {
1776        if !self.state.jobs.is_empty() && self.state.selected_job_index < self.state.jobs.len() {
1777            let removed_job = self.state.jobs.remove(self.state.selected_job_index);
1778            log::info!(
1779                "Removed job: {:?} for file {:?}",
1780                removed_job.action,
1781                removed_job.file_info.path
1782            );
1783            if self.state.selected_job_index >= self.state.jobs.len() && !self.state.jobs.is_empty()
1784            {
1785                self.state.selected_job_index = self.state.jobs.len() - 1;
1786            }
1787            if self.state.jobs.is_empty() {
1788                self.state.selected_job_index = 0;
1789            }
1790            self.state.status_message = Some("Job removed.".to_string());
1791        } else {
1792            self.state.status_message =
1793                Some("No job selected to remove or jobs list empty.".to_string());
1794        }
1795    }
1796
1797    fn handle_help_mode_key(&mut self, key_event: KeyEvent) {
1798        if key_event.code == KeyCode::Esc {
1799            self.state.input_mode = InputMode::Normal;
1800            self.state.status_message = Some("Exited help screen.".to_string());
1801        }
1802        // Other keys do nothing in help mode
1803    }
1804
1805    fn rebuild_display_list(&mut self) {
1806        self.state.display_list =
1807            App::build_display_list_from_grouped_data(&self.state.grouped_data);
1808        self.validate_selection_indices(); // Ensure selection is still valid
1809    }
1810
1811    fn apply_sort_settings(&mut self) {
1812        log::info!(
1813            "Applying sort settings: {:?} {:?}",
1814            self.state.current_sort_criterion,
1815            self.state.current_sort_order
1816        );
1817        for group in &mut self.state.grouped_data {
1818            for set in &mut group.sets {
1819                // Use the utility from file_utils, assuming it's public or in the same module
1820                // If not, we might need to replicate or expose it.
1821                // For now, assuming file_utils::sort_file_infos is accessible.
1822                // It needs to be `pub(crate)` or public in `file_utils`.
1823                file_utils::sort_file_infos(
1824                    &mut set.files,
1825                    self.state.current_sort_criterion,
1826                    self.state.current_sort_order,
1827                );
1828            }
1829        }
1830        self.rebuild_display_list(); // This will also validate selections
1831        self.state.sort_settings_changed = false; // Reset flag
1832        self.state.status_message = Some("Sort settings applied to current view.".to_string());
1833    }
1834}
1835
1836type TerminalBackend = CrosstermBackend<Stdout>;
1837
1838pub fn run_tui_app(cli: &Cli) -> Result<()> {
1839    enable_raw_mode()?;
1840    let mut stdout = stdout();
1841    execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?;
1842    let backend = CrosstermBackend::new(stdout);
1843    let mut terminal = Terminal::new(backend)?;
1844
1845    // Create a modified cli config with progress always enabled for TUI mode
1846    let mut tui_cli = cli.clone();
1847    tui_cli.progress = true;
1848    tui_cli.progress_tui = true;
1849
1850    let mut app = App::new(&tui_cli);
1851    app.validate_selection_indices(); // Initial validation for sync loaded data if any
1852
1853    // Always enable progress for TUI mode regardless of cli.progress setting
1854    let res = run_main_loop(&mut terminal, &mut app, true);
1855
1856    // Restore terminal
1857    disable_raw_mode()?;
1858    execute!(
1859        terminal.backend_mut(),
1860        LeaveAlternateScreen,
1861        DisableMouseCapture
1862    )?;
1863    terminal.show_cursor()?;
1864
1865    // Join scan thread if it exists (especially on quit)
1866    if let Some(handle) = app.scan_thread_join_handle.take() {
1867        log::debug!("Attempting to join scan thread...");
1868        if let Err(e) = handle.join() {
1869            log::error!("Failed to join scan thread: {:?}", e);
1870        } else {
1871            log::debug!("Scan thread joined successfully.");
1872        }
1873    }
1874
1875    if let Err(err) = res {
1876        log::error!("TUI Error: {}", err);
1877        if log::log_enabled!(log::Level::Debug) {
1878            let mut backtrace_output = "(backtrace not available or disabled)".to_string();
1879
1880            // Explicitly wrap the backtrace reference in Some() because err.backtrace() returns &Backtrace
1881            let an_option_of_backtrace: Option<&std::backtrace::Backtrace> = Some(err.backtrace());
1882
1883            // Now match on this explicitly typed Option
1884            if let Some(bt_ref) = an_option_of_backtrace {
1885                // bt_ref should be &std::backtrace::Backtrace
1886                backtrace_output = format!("Stack backtrace:\n{}", bt_ref); // &std::backtrace::Backtrace implements Display
1887            }
1888            println!("Error in TUI: {}\n{}", err, backtrace_output);
1889        } else {
1890            println!("Error in TUI: {}. Run with -vv for more details.", err);
1891        }
1892    }
1893
1894    Ok(())
1895}
1896
1897fn run_main_loop(
1898    terminal: &mut Terminal<TerminalBackend>,
1899    app: &mut App,
1900    show_tui_progress: bool,
1901) -> Result<()> {
1902    let tick_rate = Duration::from_millis(100); // Faster tick rate for responsiveness with async msgs
1903    let mut last_tick = Instant::now();
1904
1905    // Handle messages from scan thread immediately for the first frame
1906    if show_tui_progress {
1907        app.handle_scan_messages();
1908    }
1909
1910    loop {
1911        // Handle messages from scan thread first
1912        if show_tui_progress {
1913            // Only check messages if async scan was started
1914            app.handle_scan_messages();
1915        }
1916
1917        terminal.draw(|f| ui(f, app))?;
1918
1919        let timeout = tick_rate
1920            .checked_sub(last_tick.elapsed())
1921            .unwrap_or_else(|| Duration::from_secs(0));
1922
1923        if crossterm::event::poll(timeout)? {
1924            if let CEvent::Key(key) = event::read()? {
1925                if key.kind == KeyEventKind::Press {
1926                    app.on_key(key);
1927                }
1928            }
1929        }
1930
1931        if last_tick.elapsed() >= tick_rate {
1932            last_tick = Instant::now();
1933        }
1934
1935        if app.should_quit {
1936            return Ok(());
1937        }
1938    }
1939}
1940
1941// Helper function to parse progress information from loading messages
1942fn parse_progress_from_message(message: &str) -> (String, String, Option<f64>) {
1943    // Extract stage from messages like "📁 [1/3] File Discovery: Found 196200 files..."
1944    let stage = if message.contains("[0/3]") {
1945        "0/3 Pre-scan".to_string()
1946    } else if message.contains("[1/3]") {
1947        "1/3 Discovery".to_string()
1948    } else if message.contains("[2/3]") {
1949        "2/3 Size Analysis".to_string()
1950    } else if message.contains("[3/3]") {
1951        "3/3 Hashing".to_string()
1952    } else {
1953        "Loading".to_string()
1954    };
1955
1956    // Extract file counts and percentages
1957    let mut progress_text = message.to_string();
1958
1959    // Try to extract file counts for a better display format
1960    if let Some(count_start) = message.find("Found ") {
1961        if let Some(count_end) = message[count_start..].find(" files") {
1962            let file_count_str = &message[count_start + 6..count_start + count_end];
1963            progress_text = format!("Found {} files", file_count_str);
1964        }
1965    }
1966
1967    // Extract scanning path for better display
1968    if message.contains("Scanning:") {
1969        progress_text = message.to_string();
1970    }
1971
1972    // Try to extract percentage values from messages containing them
1973    let percentage = if let Some(pct_start) = message.find("(") {
1974        if let Some(pct_end) = message[pct_start..].find("%)") {
1975            let pct_str = &message[pct_start + 1..pct_start + pct_end];
1976            pct_str.parse::<f64>().ok()
1977        } else {
1978            None
1979        }
1980    } else {
1981        None
1982    };
1983
1984    (stage, progress_text, percentage)
1985}
1986
1987fn format_file_size(size: u64, raw_sizes: bool) -> String {
1988    if raw_sizes {
1989        format!("{} bytes", size)
1990    } else {
1991        format_size(size, DECIMAL)
1992    }
1993}
1994
1995fn ui(frame: &mut Frame, app: &mut App) {
1996    let chunks = Layout::default()
1997        .direction(Direction::Vertical)
1998        .constraints([
1999            Constraint::Length(3), // Title
2000            Constraint::Length(3), // Status
2001            Constraint::Min(0),    // Main content
2002            Constraint::Length(5), // Log area (fixed height for now)
2003            Constraint::Length(1), // Progress bar (if any)
2004            Constraint::Length(1), // Help bar (always visible)
2005        ])
2006        .split(frame.size());
2007
2008    if app.state.is_loading && app.scan_rx.is_some() {
2009        // Show loading screen with two progress bars - one for total progress, one for stage progress
2010        let chunks = Layout::default()
2011            .direction(Direction::Vertical)
2012            .constraints([
2013                Constraint::Percentage(20), // Upper space
2014                Constraint::Length(3),      // Title and global progress
2015                Constraint::Length(1),      // Spacing
2016                Constraint::Length(3),      // Stage-specific progress
2017                Constraint::Percentage(20), // Lower space
2018            ])
2019            .split(frame.size());
2020
2021        // Extract progress information from loading message
2022        let (stage_str, progress_text, percentage) =
2023            parse_progress_from_message(&app.state.loading_message);
2024
2025        // Calculate the total progress based on the stage
2026        let (current_stage, total_stages) = parse_stage_numbers(&stage_str);
2027        let total_progress = if let (Some(current), Some(total), Some(pct)) =
2028            (current_stage, total_stages, percentage)
2029        {
2030            // Overall progress = (completed stages + current stage progress)
2031            ((current - 1) as f64 / total as f64) + (pct / 100.0 / total as f64)
2032        } else {
2033            // Indeterminate if we can't extract actual values
2034            let now = std::time::Instant::now();
2035            let secs = now.elapsed().as_secs_f64();
2036            (secs % 2.0) / 2.0 // Pulse every 2 seconds
2037        };
2038
2039        // Top bar: Total progress
2040        let total_progress_text =
2041            if let (Some(current), Some(total)) = (current_stage, total_stages) {
2042                format!(
2043                    "Total Progress: Stage {} of {} - {:.1}% Complete",
2044                    current,
2045                    total,
2046                    total_progress * 100.0
2047                )
2048            } else {
2049                "Processing...".to_string()
2050            };
2051
2052        let total_progress_gauge = Gauge::default()
2053            .block(
2054                Block::default()
2055                    .borders(Borders::ALL)
2056                    .title("Overall Progress"),
2057            )
2058            .gauge_style(Style::default().fg(Color::Cyan).bg(Color::Black))
2059            .label(total_progress_text)
2060            .ratio(total_progress);
2061
2062        frame.render_widget(total_progress_gauge, chunks[1]);
2063
2064        // Stage-specific progress (bottom bar)
2065        // Use extracted percentage if available, otherwise animate
2066        let stage_progress_value = if let Some(pct) = percentage {
2067            pct / 100.0
2068        } else {
2069            // Animate when no percentage available
2070            let now = std::time::Instant::now();
2071            let secs = now.elapsed().as_secs_f64();
2072            (secs % 3.0) / 3.0 // Cycles every 3 seconds (0.0 to 1.0)
2073        };
2074
2075        let stage_gauge = Gauge::default()
2076            .block(Block::default().borders(Borders::ALL).title(stage_str))
2077            .gauge_style(Style::default().fg(Color::White).bg(Color::Black))
2078            .label(progress_text)
2079            .ratio(stage_progress_value);
2080
2081        frame.render_widget(stage_gauge, chunks[3]);
2082    } else if app.state.input_mode == InputMode::Settings {
2083        // Basic placeholder for settings UI
2084        let chunks = Layout::default()
2085            .direction(Direction::Vertical)
2086            .margin(2)
2087            .constraints([
2088                Constraint::Length(3), // Title
2089                Constraint::Min(10),   // Settings options
2090                Constraint::Length(1), // Hint
2091            ])
2092            .split(frame.size());
2093
2094        let title = Paragraph::new("--- Settings Menu ---")
2095            .alignment(Alignment::Center)
2096            .block(
2097                Block::default()
2098                    .borders(Borders::ALL)
2099                    .title("Settings (Ctrl+S to enter/Esc to exit)"),
2100            );
2101        frame.render_widget(title, chunks[0]);
2102
2103        let mut strategy_style = Style::default();
2104        let mut algo_style = Style::default();
2105        let mut parallel_style = Style::default();
2106        let mut sort_criterion_style = Style::default();
2107        let mut sort_order_style = Style::default();
2108        let mut media_mode_style = Style::default();
2109        let mut media_resolution_style = Style::default();
2110        let mut media_format_style = Style::default();
2111        let mut media_similarity_style = Style::default();
2112
2113        match app.state.selected_setting_category_index {
2114            0 => {
2115                strategy_style = strategy_style
2116                    .fg(Color::Yellow)
2117                    .add_modifier(Modifier::BOLD)
2118            }
2119            1 => algo_style = algo_style.fg(Color::Yellow).add_modifier(Modifier::BOLD),
2120            2 => {
2121                parallel_style = parallel_style
2122                    .fg(Color::Yellow)
2123                    .add_modifier(Modifier::BOLD)
2124            }
2125            3 => {
2126                sort_criterion_style = sort_criterion_style
2127                    .fg(Color::Yellow)
2128                    .add_modifier(Modifier::BOLD)
2129            }
2130            4 => {
2131                sort_order_style = sort_order_style
2132                    .fg(Color::Yellow)
2133                    .add_modifier(Modifier::BOLD)
2134            }
2135            5 => {
2136                media_mode_style = media_mode_style
2137                    .fg(Color::Yellow)
2138                    .add_modifier(Modifier::BOLD)
2139            }
2140            6 => {
2141                media_resolution_style = media_resolution_style
2142                    .fg(Color::Yellow)
2143                    .add_modifier(Modifier::BOLD)
2144            }
2145            7 => {
2146                media_format_style = media_format_style
2147                    .fg(Color::Yellow)
2148                    .add_modifier(Modifier::BOLD)
2149            }
2150            8 => {
2151                media_similarity_style = media_similarity_style
2152                    .fg(Color::Yellow)
2153                    .add_modifier(Modifier::BOLD)
2154            }
2155            _ => {}
2156        }
2157
2158        let settings_text = vec![
2159            Line::from(Span::styled(format!("1. File Selection Strategy: {:?}", app.state.default_selection_strategy), strategy_style)),
2160            Line::from(Span::styled("   (n:newest, o:oldest, s:shortest, l:longest)".to_string(), strategy_style)),
2161            Line::from(Span::raw("")),
2162            Line::from(Span::styled(format!("2. Hashing Algorithm: {}", app.state.current_algorithm), algo_style)),
2163            Line::from(Span::styled("   (m:md5, a:sha256, b:blake3, x:xxhash, g:gxhash, f:fnv1a, c:crc32)".to_string(), algo_style)),
2164            Line::from(Span::raw("")),
2165            Line::from(Span::styled(format!("3. Parallel Cores: {}",
2166                app.state.current_parallel.map_or_else(
2167                    || format!("Auto ({} cores)", num_cpus::get()),
2168                    |c| c.to_string()
2169                )
2170            ), parallel_style)),
2171            Line::from(Span::styled("   (0 for auto, 1-N, +/-, requires rescan)".to_string(), parallel_style)),
2172            Line::from(Span::raw("")),
2173            Line::from(Span::styled(format!("4. Sort Files By: {:?}", app.state.current_sort_criterion), sort_criterion_style)),
2174            Line::from(Span::styled("   (f:name, z:size, c:created, m:modified, p:path length)".to_string(), sort_criterion_style)),
2175            Line::from(Span::raw("")),
2176            Line::from(Span::styled(format!("5. Sort Order: {:?}", app.state.current_sort_order), sort_order_style)),
2177            Line::from(Span::styled("   (a:ascending, d:descending)".to_string(), sort_order_style)),
2178            Line::from(Span::raw("")),
2179
2180            // Media deduplication options
2181            Line::from(Span::styled("--- Media Deduplication ---", Style::default().add_modifier(Modifier::BOLD))),
2182            Line::from(Span::raw("")),
2183            Line::from(Span::styled(format!("6. Media Mode: {}",
2184                if app.state.media_mode {
2185                    if crate::media_dedup::is_ffmpeg_available() {
2186                        "Enabled"
2187                    } else {
2188                        "Enabled (ffmpeg not found, limited functionality)"
2189                    }
2190                } else {
2191                    "Disabled"
2192                }
2193            ), media_mode_style)),
2194            Line::from(Span::styled("   (e:toggle, requires rescan)".to_string(), media_mode_style)),
2195            Line::from(Span::raw("")),
2196            Line::from(Span::styled(format!("7. Media Resolution Preference: {}", app.state.media_resolution), media_resolution_style)),
2197            Line::from(Span::styled("   (h:highest, l:lowest, c:custom, requires rescan)".to_string(), media_resolution_style)),
2198            Line::from(Span::raw("")),
2199            Line::from(Span::styled(format!("8. Media Format Preference: {}",
2200                app.state.media_formats.iter().take(3).cloned().collect::<Vec<_>>().join(" > ")), media_format_style)),
2201            Line::from(Span::styled("   (r:raw first, p:png first, j:jpg first, requires rescan)".to_string(), media_format_style)),
2202            Line::from(Span::raw("")),
2203            Line::from(Span::styled(format!("9. Media Similarity Threshold: {}%", app.state.media_similarity), media_similarity_style)),
2204            Line::from(Span::styled("   (1:95% strict, 2:90% default, 3:85% relaxed, 4:75% very relaxed, requires rescan)".to_string(), media_similarity_style)),
2205            Line::from(Span::raw("")),
2206            Line::from(Span::raw(if app.state.rescan_needed && app.state.sort_settings_changed {
2207                "[!] Algorithm/Parallelism/Media and Sort settings changed. Ctrl+R to rescan, Sort applied on Esc."
2208            } else if app.state.rescan_needed {
2209                "[!] Algorithm/Parallelism/Media settings changed. Press Ctrl+R to rescan."
2210            } else if app.state.sort_settings_changed {
2211                "[!] Sort settings changed. Applied on exiting settings (Esc)."
2212            } else {
2213                "No pending setting changes."
2214            })),
2215        ];
2216        let settings_paragraph = Paragraph::new(settings_text)
2217            .block(Block::default().borders(Borders::ALL).title("Options"))
2218            .wrap(Wrap { trim: true });
2219        frame.render_widget(settings_paragraph, chunks[1]);
2220
2221        let hint = Paragraph::new("Esc: Exit Settings | Use indicated keys to change values.")
2222            .alignment(Alignment::Center);
2223        frame.render_widget(hint, chunks[2]);
2224    } else if app.state.input_mode == InputMode::Help {
2225        let help_chunks = Layout::default()
2226            .direction(Direction::Vertical)
2227            .margin(1)
2228            .constraints([
2229                Constraint::Length(3), // Title
2230                Constraint::Min(0),    // Content
2231                Constraint::Length(1), // Footer
2232            ])
2233            .split(frame.size());
2234
2235        let title = Paragraph::new("--- Dedup TUI Help ---")
2236            .alignment(Alignment::Center)
2237            .block(Block::default().borders(Borders::ALL).title("Help Screen"));
2238        frame.render_widget(title, help_chunks[0]);
2239
2240        let help_text_lines = vec![
2241            Line::from(Span::styled("General Navigation:", Style::default().add_modifier(Modifier::BOLD))),
2242            Line::from("  q          : Quit application"),
2243            Line::from("  Tab        : Cycle focus between Panels (Sets/Folders -> Files -> Jobs)"),
2244            Line::from("  h          : Show this Help screen (Esc to close)"),
2245            Line::from("  Ctrl+R     : Trigger a rescan with current settings"),
2246            Line::from("  Ctrl+S     : Open Settings menu (Esc to close)"),
2247            Line::from("  Ctrl+E     : Execute all pending jobs"),
2248            Line::from("  Ctrl+D     : Toggle Dry Run mode (simulates actions without making changes)"),
2249            Line::from(""),
2250            Line::from(Span::styled("Sets/Folders Panel (Left):", Style::default().add_modifier(Modifier::BOLD))),
2251            Line::from("  Up/k       : Select previous folder/set"),
2252            Line::from("  Down/j     : Select next folder/set"),
2253            Line::from("  Enter/l    : Focus Files panel for selected set / Expand/Collapse folder (TODO)"),
2254            Line::from("  d          : Mark all but one file (per strategy) in selected set for deletion"),
2255            // Line::from("  Ctrl+A : Select all files in all sets for action (TODO)"),
2256            // Line::from("  /        : Filter sets by regex (TODO)"),
2257            Line::from(""),
2258            Line::from(Span::styled("Files Panel (Middle):", Style::default().add_modifier(Modifier::BOLD))),
2259            Line::from("  Up/k       : Select previous file in set"),
2260            Line::from("  Down/j     : Select next file in set"),
2261            Line::from("  Left/h     : Focus Sets/Folders panel"),
2262            Line::from("  s          : Mark selected file to be KEPT (others in set marked for DELETE)"),
2263            Line::from("  d          : Mark selected file for DELETE"),
2264            Line::from("  c          : Mark selected file for COPY (prompts for destination)"),
2265            Line::from("  i          : Mark selected file to be IGNORED (won't be deleted/moved/copied)"),
2266            Line::from(""),
2267            Line::from(Span::styled("Jobs Panel (Right):", Style::default().add_modifier(Modifier::BOLD))),
2268            Line::from("  Up/k       : Select previous job"),
2269            Line::from("  Down/j     : Select next job"),
2270            Line::from("  x/Del/Bsp  : Remove selected job"),
2271            Line::from(""),
2272            Line::from(Span::styled("Settings Menu (Ctrl+S to access):", Style::default().add_modifier(Modifier::BOLD))),
2273            Line::from("  Up/Down    : Navigate setting categories"),
2274            Line::from("  Strategy   : n (Newest), o (Oldest), s (Shortest Path), l (Longest Path)"),
2275            Line::from("  Algorithm  : m (md5), a (sha256), b (blake3), x (xxhash), g (gxhash), f (fnv1a), c (crc32) - requires rescan"),
2276            Line::from("  Parallelism: 0 (Auto), 1-9, + (Increment), - (Decrement) - requires rescan"),
2277            Line::from("  Sorting    : (TODO: Sort By, Sort Order)"),
2278            Line::from("  Esc        : Exit settings menu"),
2279            Line::from(""),
2280            Line::from(Span::styled("Input Prompts (e.g., Copy Destination):", Style::default().add_modifier(Modifier::BOLD))),
2281            Line::from("  Enter      : Confirm input"),
2282            Line::from("  Esc        : Cancel input"),
2283        ];
2284
2285        let help_paragraph = Paragraph::new(help_text_lines)
2286            .block(Block::default().borders(Borders::ALL).title("Keybindings"))
2287            .wrap(Wrap { trim: true });
2288        frame.render_widget(help_paragraph, help_chunks[1]);
2289
2290        let footer = Paragraph::new("Press 'Esc' to close Help.").alignment(Alignment::Center);
2291        frame.render_widget(footer, help_chunks[2]);
2292    } else {
2293        // Main UI (3 panels + status bar)
2294        let main_chunks = Layout::default()
2295            .direction(Direction::Horizontal)
2296            .constraints([
2297                Constraint::Percentage(35), // Sets/Folders panel
2298                Constraint::Percentage(35), // Files panel
2299                Constraint::Percentage(30), // Jobs panel
2300            ])
2301            .split(chunks[2]);
2302
2303        // Helper to create a block with a title and border, highlighting if active
2304        let create_block = |title_string: String, is_active: bool| {
2305            let base_style = if is_active {
2306                Style::default().fg(Color::Yellow)
2307            } else {
2308                Style::default().fg(Color::White)
2309            };
2310            Block::default()
2311                .borders(Borders::ALL)
2312                .title(Span::styled(title_string, base_style))
2313                .border_style(base_style)
2314        };
2315
2316        // Left Panel: Duplicate Sets (actually folders and sets)
2317        let sets_panel_title_string = format!(
2318            "Parent Folders / Duplicate Sets ({}/{}) (Tab to navigate)",
2319            app.state
2320                .selected_display_list_index
2321                .saturating_add(1)
2322                .min(app.state.display_list.len()),
2323            app.state.display_list.len()
2324        );
2325        let sets_block = create_block(
2326            sets_panel_title_string,
2327            app.state.active_panel == ActivePanel::Sets
2328                && app.state.input_mode == InputMode::Normal,
2329        );
2330
2331        let list_items: Vec<ListItem> = app
2332            .state
2333            .display_list
2334            .iter()
2335            .map(|item| match item {
2336                DisplayListItem::Folder {
2337                    path,
2338                    is_expanded,
2339                    set_count,
2340                    ..
2341                } => {
2342                    let prefix = if *is_expanded { "[-]" } else { "[+]" };
2343                    ListItem::new(Line::from(Span::styled(
2344                        format!("{} {} ({} sets)", prefix, path.display(), set_count),
2345                        Style::default().add_modifier(Modifier::BOLD),
2346                    )))
2347                }
2348                DisplayListItem::SetEntry {
2349                    set_hash_preview,
2350                    set_total_size,
2351                    file_count_in_set,
2352                    indent,
2353                    ..
2354                } => {
2355                    let indent_str = if *indent { "  " } else { "" };
2356                    ListItem::new(Line::from(Span::styled(
2357                        format!(
2358                            "{}Hash: {}... ({} files, {})",
2359                            indent_str,
2360                            set_hash_preview,
2361                            file_count_in_set,
2362                            format_file_size(*set_total_size, app.cli_config.raw_sizes)
2363                        ),
2364                        Style::default(),
2365                    )))
2366                }
2367            })
2368            .collect();
2369
2370        let sets_list = List::new(list_items)
2371            .block(sets_block)
2372            .highlight_style(
2373                Style::default()
2374                    .add_modifier(Modifier::BOLD)
2375                    .bg(Color::Blue),
2376            )
2377            .highlight_symbol(">> ");
2378        let mut sets_list_state = ListState::default();
2379        if !app.state.display_list.is_empty() {
2380            sets_list_state.select(Some(app.state.selected_display_list_index));
2381        }
2382        frame.render_stateful_widget(sets_list, main_chunks[0], &mut sets_list_state);
2383
2384        // Middle Panel: Files in Selected Set
2385        let (files_panel_title_string, file_items) = if let Some(selected_set) =
2386            app.current_selected_set_from_display_list()
2387        {
2388            let title = format!(
2389                "Files ({}/{}) (s:keep d:del c:copy i:ign h:back)",
2390                app.state
2391                    .selected_file_index_in_set
2392                    .saturating_add(1)
2393                    .min(selected_set.files.len()),
2394                selected_set.files.len()
2395            );
2396            let items: Vec<ListItem> = selected_set
2397                .files
2398                .iter()
2399                .map(|file_info| {
2400                    let mut style = Style::default();
2401                    let mut prefix = "   ";
2402                    if let Some(job) = app
2403                        .state
2404                        .jobs
2405                        .iter()
2406                        .find(|j| j.file_info.path == file_info.path)
2407                    {
2408                        match job.action {
2409                            ActionType::Keep => {
2410                                style = style.fg(Color::Green).add_modifier(Modifier::BOLD);
2411                                prefix = "[K]";
2412                            }
2413                            ActionType::Delete => {
2414                                style = style.fg(Color::Red).add_modifier(Modifier::CROSSED_OUT);
2415                                prefix = "[D]";
2416                            }
2417                            ActionType::Copy(_) => {
2418                                style = style.fg(Color::Cyan);
2419                                prefix = "[C]";
2420                            }
2421                            ActionType::Move(_) => {
2422                                style = style.fg(Color::Magenta);
2423                                prefix = "[M]";
2424                            }
2425                            ActionType::Ignore => {
2426                                style = style.fg(Color::DarkGray);
2427                                prefix = "[I]";
2428                            }
2429                        }
2430                    } else if let Ok((default_kept, _)) = file_utils::determine_action_targets(
2431                        selected_set,
2432                        app.state.default_selection_strategy,
2433                    ) {
2434                        if default_kept.path == file_info.path {
2435                            style = style.fg(Color::Green);
2436                            prefix = "[k]";
2437                        }
2438                    }
2439                    ListItem::new(Line::from(vec![
2440                        Span::styled(format!("{} ", prefix), style),
2441                        Span::styled(file_info.path.display().to_string(), style),
2442                    ]))
2443                })
2444                .collect();
2445            (title, items)
2446        } else {
2447            (
2448                "Files (0/0)".to_string(),
2449                vec![ListItem::new("No set selected or set is empty")],
2450            )
2451        };
2452        let files_block = create_block(
2453            files_panel_title_string,
2454            app.state.active_panel == ActivePanel::Files
2455                && app.state.input_mode == InputMode::Normal,
2456        );
2457        let files_list = List::new(file_items)
2458            .block(files_block)
2459            .highlight_style(
2460                Style::default()
2461                    .add_modifier(Modifier::BOLD)
2462                    .bg(Color::DarkGray),
2463            )
2464            .highlight_symbol("> ");
2465
2466        let mut files_list_state = ListState::default();
2467        if app
2468            .current_selected_set_from_display_list()
2469            .is_some_and(|s| !s.files.is_empty())
2470        {
2471            files_list_state.select(Some(app.state.selected_file_index_in_set));
2472        }
2473        frame.render_stateful_widget(files_list, main_chunks[1], &mut files_list_state);
2474
2475        // Right Panel: Jobs
2476        let jobs_panel_title_string =
2477            format!("Jobs ({}) (Ctrl+E: Exec, x:del)", app.state.jobs.len());
2478        let jobs_block = create_block(
2479            jobs_panel_title_string,
2480            app.state.active_panel == ActivePanel::Jobs
2481                && app.state.input_mode == InputMode::Normal,
2482        );
2483        let job_items: Vec<ListItem> = app
2484            .state
2485            .jobs
2486            .iter()
2487            .map(|job| {
2488                let action_str = match &job.action {
2489                    ActionType::Keep => "KEEP".to_string(),
2490                    ActionType::Delete => "DELETE".to_string(),
2491                    ActionType::Move(dest) => format!("MOVE to {}", dest.display()),
2492                    ActionType::Copy(dest) => format!("COPY to {}", dest.display()),
2493                    ActionType::Ignore => "IGNORE".to_string(),
2494                };
2495                let content = Line::from(Span::raw(format!(
2496                    "{} - {:?}",
2497                    action_str,
2498                    job.file_info.path.file_name().unwrap_or_default()
2499                )));
2500                ListItem::new(content)
2501            })
2502            .collect();
2503        let jobs_list_widget = List::new(job_items)
2504            .block(jobs_block)
2505            .highlight_style(
2506                Style::default()
2507                    .add_modifier(Modifier::BOLD)
2508                    .bg(Color::Magenta),
2509            )
2510            .highlight_symbol(">> ");
2511        let mut jobs_list_state = ListState::default();
2512        if !app.state.jobs.is_empty() {
2513            jobs_list_state.select(Some(app.state.selected_job_index));
2514        }
2515        frame.render_stateful_widget(jobs_list_widget, main_chunks[2], &mut jobs_list_state);
2516
2517        // Status Bar / Input Area
2518        match app.state.input_mode {
2519            InputMode::Normal => {
2520                // Show custom status message if available, otherwise show controls
2521                let mut status_text = app.state.status_message.as_deref().unwrap_or(
2522                    "q/Ctrl+C:quit | Tab:cycle | Arrows/jk:nav | a:toggle s:keep d:del c:copy i:ign | Ctrl+E:exec | Ctrl+R:rescan | Ctrl+S:settings | x:del job"
2523                ).to_string();
2524
2525                // Add dry run indicator if enabled
2526                if app.state.dry_run {
2527                    status_text = format!("[DRY RUN MODE] {} (Ctrl+D: Toggle)", status_text);
2528                } else {
2529                    status_text = format!("{} (Ctrl+D: Dry Run)", status_text);
2530                }
2531
2532                let status_style = if app.state.dry_run {
2533                    // Use yellow for dry run mode to make it more obvious
2534                    Style::default().fg(Color::Yellow)
2535                } else {
2536                    Style::default().fg(Color::LightCyan)
2537                };
2538
2539                let status_bar = Paragraph::new(status_text)
2540                    .style(status_style)
2541                    .alignment(Alignment::Left);
2542                frame.render_widget(status_bar, chunks[3]);
2543            }
2544            InputMode::CopyDestination => {
2545                let input_chunks = Layout::default()
2546                    .direction(Direction::Vertical)
2547                    .constraints([Constraint::Length(1), Constraint::Length(1)])
2548                    .split(chunks[3]);
2549                let prompt_text = app
2550                    .state
2551                    .status_message
2552                    .as_deref()
2553                    .unwrap_or("Enter destination path for copy (Enter:confirm, Esc:cancel):");
2554                let prompt_p = Paragraph::new(prompt_text).fg(Color::Yellow);
2555                frame.render_widget(prompt_p, input_chunks[0]);
2556                let input_field = Paragraph::new(app.state.current_input.value())
2557                    .block(
2558                        Block::default()
2559                            .borders(Borders::TOP)
2560                            .title("Path")
2561                            .border_style(Style::default().fg(Color::Yellow)),
2562                    )
2563                    .fg(Color::White);
2564                frame.render_widget(input_field, input_chunks[1]);
2565                frame.set_cursor(
2566                    input_chunks[1].x + app.state.current_input.visual_cursor() as u16 + 1,
2567                    input_chunks[1].y + 1,
2568                );
2569            }
2570            InputMode::Settings => {
2571                // The Settings mode has its own full-screen UI, so no specific status bar here.
2572            }
2573            InputMode::Help => {
2574                // The Help mode has its own full-screen UI, so no specific status bar here.
2575            }
2576        }
2577
2578        // Draw progress bar (if any) just above the help bar
2579        use ratatui::widgets::Gauge;
2580        if app.state.is_processing_jobs {
2581            let (done, total) = app.state.job_progress;
2582            let percent = if total > 0 {
2583                done as f64 / total as f64
2584            } else {
2585                0.0
2586            };
2587
2588            // Create a progress display area for job processing
2589            let progress_layout = Layout::default()
2590                .direction(Direction::Vertical)
2591                .constraints([
2592                    Constraint::Length(1), // Top bar
2593                    Constraint::Length(1), // Bottom bar
2594                ])
2595                .split(chunks[4]);
2596
2597            // Top gauge shows overall progress
2598            let top_gauge = Gauge::default()
2599                .block(Block::default().borders(Borders::NONE).title(""))
2600                .gauge_style(Style::default().fg(Color::Cyan).bg(Color::Black))
2601                .label(format!(
2602                    "Overall: {}/{} jobs ({:.1}%)",
2603                    done,
2604                    total,
2605                    percent * 100.0
2606                ))
2607                .ratio(percent);
2608
2609            // Bottom gauge shows per-job details
2610            let bottom_gauge = Gauge::default()
2611                .block(Block::default().borders(Borders::NONE).title(""))
2612                .gauge_style(Style::default().fg(Color::Green).bg(Color::Black))
2613                .label(format!(
2614                    "Current job: {}/{} - {}",
2615                    done, total, app.state.job_processing_message
2616                ))
2617                .ratio(if done < total {
2618                    (done as f64 + 0.5) / total as f64
2619                } else {
2620                    1.0
2621                });
2622
2623            frame.render_widget(top_gauge, progress_layout[0]);
2624            frame.render_widget(bottom_gauge, progress_layout[1]);
2625        } else if app.state.is_loading {
2626            // Extract progress information from the loading message
2627            let (stage_str, progress_text, percentage) =
2628                parse_progress_from_message(&app.state.loading_message);
2629
2630            // Calculate total progress across stages
2631            let (current_stage, total_stages) = parse_stage_numbers(&stage_str);
2632            let total_progress = if let (Some(current), Some(total), Some(pct)) =
2633                (current_stage, total_stages, percentage)
2634            {
2635                ((current - 1) as f64 / total as f64) + (pct / 100.0 / total as f64)
2636            } else {
2637                // Animate when no percentage available
2638                let now = std::time::Instant::now();
2639                let secs = now.elapsed().as_secs_f64();
2640                (secs % 3.0) / 3.0 // Cycles every 3 seconds (0.0 to 1.0)
2641            };
2642
2643            // Create a progress display area with two progress bars
2644            let progress_layout = Layout::default()
2645                .direction(Direction::Vertical)
2646                .constraints([
2647                    Constraint::Length(1), // Top bar for overall progress
2648                    Constraint::Length(1), // Bottom bar for stage progress
2649                ])
2650                .split(chunks[4]);
2651
2652            // Top gauge shows overall progress across all stages
2653            let top_gauge = Gauge::default()
2654                .block(Block::default().borders(Borders::NONE).title(""))
2655                .gauge_style(Style::default().fg(Color::Cyan).bg(Color::Black))
2656                .label(
2657                    if let (Some(current), Some(total)) = (current_stage, total_stages) {
2658                        format!(
2659                            "Total Progress: Stage {} of {} ({:.1}%)",
2660                            current,
2661                            total,
2662                            total_progress * 100.0
2663                        )
2664                    } else {
2665                        "Processing...".to_string()
2666                    },
2667                )
2668                .ratio(total_progress);
2669
2670            // Bottom gauge shows progress for current stage
2671            let stage_progress_value = if let Some(pct) = percentage {
2672                pct / 100.0
2673            } else if let Some(counts) = extract_scan_counts(&app.state.loading_message) {
2674                counts.0 as f64 / counts.1 as f64
2675            } else {
2676                // Animate when no percentage available
2677                let now = std::time::Instant::now();
2678                let secs = now.elapsed().as_secs_f64();
2679                (secs % 3.0) / 3.0 // Cycles every 3 seconds (0.0 to 1.0)
2680            };
2681
2682            let bottom_gauge = Gauge::default()
2683                .block(Block::default().borders(Borders::NONE).title(""))
2684                .gauge_style(Style::default().fg(Color::White).bg(Color::Black))
2685                .label(progress_text)
2686                .ratio(stage_progress_value);
2687
2688            frame.render_widget(top_gauge, progress_layout[0]);
2689            frame.render_widget(bottom_gauge, progress_layout[1]);
2690        } else if !app.state.jobs.is_empty() && app.state.input_mode == InputMode::Normal {
2691            let total = app.state.jobs.len();
2692            let completed = 0; // You can track completed jobs if you add a field
2693            let percent = if total > 0 {
2694                completed as f64 / total as f64
2695            } else {
2696                0.0
2697            };
2698            let gauge = Gauge::default()
2699                .block(Block::default().borders(Borders::ALL).title("Job Progress"))
2700                .gauge_style(Style::default().fg(Color::White).bg(Color::Black))
2701                .label(format!(
2702                    "Pending jobs: {} | Ctrl+E: Execute, x: Remove job",
2703                    total
2704                ))
2705                .ratio(percent);
2706            frame.render_widget(gauge, chunks[4]);
2707        } else {
2708            // Draw an empty block if no progress
2709            let empty = Block::default();
2710            frame.render_widget(empty, chunks[4]);
2711        }
2712
2713        // Draw help bar at the very bottom
2714        let help =
2715            "h: Help | ↑/↓: Navigate | Space: Toggle | a: Toggle Keep/Delete | q/Ctrl+C: Quit";
2716        let help_bar = ratatui::widgets::Paragraph::new(help)
2717            .style(Style::default().fg(Color::DarkGray))
2718            .alignment(Alignment::Center);
2719        frame.render_widget(help_bar, chunks[5]);
2720
2721        // Draw log area (scrollable)
2722        let log_height = 5;
2723        let log_len = app.state.log_messages.len();
2724        let scroll = app.state.log_scroll.min(log_len.saturating_sub(log_height));
2725        let log_lines: Vec<ratatui::text::Line> = app
2726            .state
2727            .log_messages
2728            .iter()
2729            .filter(|msg| {
2730                app.state
2731                    .log_filter
2732                    .as_ref()
2733                    .is_none_or(|f| msg.contains(f))
2734            })
2735            .skip(scroll)
2736            .take(log_height)
2737            .map(|msg| ratatui::text::Line::from(msg.clone()))
2738            .collect();
2739        let log_block = if app.state.log_focus {
2740            Block::default()
2741                .borders(Borders::ALL)
2742                .title("Log (FOCUSED)")
2743        } else {
2744            Block::default().borders(Borders::ALL).title("Log")
2745        };
2746        let log_paragraph = ratatui::widgets::Paragraph::new(log_lines)
2747            .block(log_block)
2748            .scroll((0, 0));
2749        frame.render_widget(log_paragraph, chunks[3]);
2750    }
2751}
2752
2753// Helper function to extract scan counts from loading messages
2754// Returns (current_count, total_count) if available
2755fn extract_scan_counts(message: &str) -> Option<(usize, usize)> {
2756    // Look for patterns like "Found 123/456 files" or "Scanned 123/456 files"
2757    if let Some(idx) = message.find('/') {
2758        let before = &message[..idx];
2759        let after = &message[idx + 1..];
2760
2761        // Extract current count from before the slash
2762        let current = before
2763            .chars()
2764            .rev()
2765            .take_while(|c| c.is_ascii_digit())
2766            .collect::<String>()
2767            .chars()
2768            .rev()
2769            .collect::<String>()
2770            .parse::<usize>()
2771            .ok()?;
2772
2773        // Extract total count from after the slash
2774        let total = after
2775            .chars()
2776            .take_while(|c| c.is_ascii_digit())
2777            .collect::<String>()
2778            .parse::<usize>()
2779            .ok()?;
2780
2781        if total > 0 {
2782            return Some((current, total));
2783        }
2784    }
2785
2786    None
2787}
2788
2789// Helper function to parse stage numbers from a stage string like "1/3 Discovery"
2790fn parse_stage_numbers(stage_str: &str) -> (Option<usize>, Option<usize>) {
2791    // Look for patterns like "1/3" or "0/3"
2792    if let Some(idx) = stage_str.find('/') {
2793        if idx > 0 && idx + 1 < stage_str.len() {
2794            let current_str = &stage_str[..idx];
2795            let rest = &stage_str[idx + 1..];
2796
2797            // Extract current stage number
2798            let current = current_str
2799                .chars()
2800                .rev()
2801                .take_while(|c| c.is_ascii_digit())
2802                .collect::<String>()
2803                .chars()
2804                .rev()
2805                .collect::<String>()
2806                .parse::<usize>()
2807                .ok();
2808
2809            // Extract total stages number
2810            let total = if let Some(space_idx) = rest.find(' ') {
2811                let total_str = &rest[..space_idx];
2812                total_str.parse::<usize>().ok()
2813            } else {
2814                rest.parse::<usize>().ok()
2815            };
2816
2817            return (current, total);
2818        }
2819    }
2820
2821    // Default if we couldn't parse the stage numbers
2822    (None, None)
2823}