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; use ratatui::prelude::*;
13use ratatui::widgets::*;
14use std::collections::HashMap; use std::io::{stdout, Stdout};
16use std::path::{Path, PathBuf}; use std::str::FromStr;
18use std::sync::mpsc as std_mpsc; use std::thread as std_thread; use std::time::{Duration, Instant};
21use tui_input::backend::crossterm::EventHandler; use tui_input::Input;
23
24use crate::file_utils::{
25 self, delete_files, move_files, DuplicateSet, FileInfo, SelectionStrategy, SortCriterion,
26 SortOrder,
27};
28use crate::Cli; #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub enum ActionType {
33 Keep, Delete,
35 Move(PathBuf), Copy(PathBuf), Ignore, }
39
40#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
41pub struct Job {
42 pub action: ActionType,
43 pub file_info: FileInfo,
44 }
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, Help, }
61
62#[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#[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, pub status_message: Option<String>, pub input_mode: InputMode,
100 pub current_input: Input, pub file_for_copy_move: Option<FileInfo>, pub is_loading: bool,
105 pub loading_message: String,
106 pub current_algorithm: String,
110 pub current_parallel: Option<usize>,
111 pub rescan_needed: bool, pub selected_setting_category_index: usize, pub current_sort_criterion: SortCriterion, pub current_sort_order: SortOrder, pub sort_settings_changed: bool, 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>, pub log_scroll: usize, pub log_focus: bool, pub log_filter: Option<String>, pub is_processing_jobs: bool,
131 pub job_processing_message: String,
132 pub job_progress: (usize, usize), pub dry_run: bool, }
136
137#[derive(Debug)]
139pub enum ScanMessage {
140 StatusUpdate(u8, String), 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>>, cli_config: Cli, }
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, 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, current_sort_order: cli_args.sort_order, 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, };
196
197 log::info!(
199 "Initializing TUI with directory: {:?}",
200 cli_args.directories[0]
201 );
202 let (tx, rx) = std_mpsc::channel::<ScanMessage>();
203
204 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 ¤t_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 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)); }
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 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 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 }
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; let mut current_cli_for_scan = self.cli_config.clone(); 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 current_cli_for_scan.progress = true;
372 current_cli_for_scan.progress_tui = true;
373
374 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 current_cli_for_scan.media_mode {
382 current_cli_for_scan.media_dedup_options =
384 crate::media_dedup::MediaDedupOptions::default();
385
386 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 let (tx, rx) = std_mpsc::channel::<ScanMessage>();
402 self.scan_rx = Some(rx);
403
404 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 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 ¤t_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 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 let stage_prefix = match stage {
460 0 => "⏳ [0/3] ", 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 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 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 }
511 Err(std_mpsc::TryRecvError::Disconnected) => {
512 log::warn!("Scan thread channel disconnected.");
514 if self.state.is_loading {
515 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; 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(); }
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 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 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 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 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 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 self.state.jobs.clear();
608
609 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 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 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 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 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 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(); 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 self.state.status_message = Some("Exited settings mode.".to_string());
893 }
894 self.state.sort_settings_changed = false; }
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); }
904 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 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 KeyCode::Char('0') if self.state.selected_setting_category_index == 2 => {
959 self.state.current_parallel = None; 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 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 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 current_val > 1 {
989 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 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 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 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 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 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 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 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 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(); self.state.rescan_needed = true;
1086 self.state.status_message = Some(
1087 "Media Resolution Preference: Custom (1280x720) (Rescan needed)".to_string(),
1088 );
1089 }
1090 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 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 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(); 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 self.state
1269 .jobs
1270 .retain(|job| job.file_info.path != selected_file_info.path);
1271
1272 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 ¤t_duplicate_set_ref.files {
1336 if file_in_set.path != file_to_keep_cloned.path {
1337 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 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 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 self.state.jobs.extend(jobs_to_add);
1377 } else if !jobs_to_add.is_empty() {
1378 }
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 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 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 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 for file_to_delete in files_to_delete {
1425 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; }
1488 }
1489 DisplayListItem::Folder { .. } => {
1490 self.state.selected_file_index_in_set = 0; }
1492 }
1493 } else {
1494 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 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, }
1524 } else {
1525 None
1526 }
1527 }
1528
1529 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 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<_>>(); 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 for log in logs {
1573 self.state.log_messages.push(log);
1574 }
1575 Ok(())
1576 }
1577 Ok((count, logs)) => {
1578 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 for log in logs {
1595 self.state.log_messages.push(log);
1596 }
1597 Ok(())
1598 }
1599 Ok((count, logs)) => {
1600 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 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 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 }
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(); }
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 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(); self.state.sort_settings_changed = false; 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 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(); let res = run_main_loop(&mut terminal, &mut app, true);
1855
1856 disable_raw_mode()?;
1858 execute!(
1859 terminal.backend_mut(),
1860 LeaveAlternateScreen,
1861 DisableMouseCapture
1862 )?;
1863 terminal.show_cursor()?;
1864
1865 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 let an_option_of_backtrace: Option<&std::backtrace::Backtrace> = Some(err.backtrace());
1882
1883 if let Some(bt_ref) = an_option_of_backtrace {
1885 backtrace_output = format!("Stack backtrace:\n{}", bt_ref); }
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); let mut last_tick = Instant::now();
1904
1905 if show_tui_progress {
1907 app.handle_scan_messages();
1908 }
1909
1910 loop {
1911 if show_tui_progress {
1913 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
1941fn parse_progress_from_message(message: &str) -> (String, String, Option<f64>) {
1943 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 let mut progress_text = message.to_string();
1958
1959 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 if message.contains("Scanning:") {
1969 progress_text = message.to_string();
1970 }
1971
1972 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), Constraint::Length(3), Constraint::Min(0), Constraint::Length(5), Constraint::Length(1), Constraint::Length(1), ])
2006 .split(frame.size());
2007
2008 if app.state.is_loading && app.scan_rx.is_some() {
2009 let chunks = Layout::default()
2011 .direction(Direction::Vertical)
2012 .constraints([
2013 Constraint::Percentage(20), Constraint::Length(3), Constraint::Length(1), Constraint::Length(3), Constraint::Percentage(20), ])
2019 .split(frame.size());
2020
2021 let (stage_str, progress_text, percentage) =
2023 parse_progress_from_message(&app.state.loading_message);
2024
2025 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 ((current - 1) as f64 / total as f64) + (pct / 100.0 / total as f64)
2032 } else {
2033 let now = std::time::Instant::now();
2035 let secs = now.elapsed().as_secs_f64();
2036 (secs % 2.0) / 2.0 };
2038
2039 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 let stage_progress_value = if let Some(pct) = percentage {
2067 pct / 100.0
2068 } else {
2069 let now = std::time::Instant::now();
2071 let secs = now.elapsed().as_secs_f64();
2072 (secs % 3.0) / 3.0 };
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 let chunks = Layout::default()
2085 .direction(Direction::Vertical)
2086 .margin(2)
2087 .constraints([
2088 Constraint::Length(3), Constraint::Min(10), Constraint::Length(1), ])
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 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), Constraint::Min(0), Constraint::Length(1), ])
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(""),
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 let main_chunks = Layout::default()
2295 .direction(Direction::Horizontal)
2296 .constraints([
2297 Constraint::Percentage(35), Constraint::Percentage(35), Constraint::Percentage(30), ])
2301 .split(chunks[2]);
2302
2303 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 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 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 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 match app.state.input_mode {
2519 InputMode::Normal => {
2520 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 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 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 }
2573 InputMode::Help => {
2574 }
2576 }
2577
2578 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 let progress_layout = Layout::default()
2590 .direction(Direction::Vertical)
2591 .constraints([
2592 Constraint::Length(1), Constraint::Length(1), ])
2595 .split(chunks[4]);
2596
2597 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 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 let (stage_str, progress_text, percentage) =
2628 parse_progress_from_message(&app.state.loading_message);
2629
2630 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 let now = std::time::Instant::now();
2639 let secs = now.elapsed().as_secs_f64();
2640 (secs % 3.0) / 3.0 };
2642
2643 let progress_layout = Layout::default()
2645 .direction(Direction::Vertical)
2646 .constraints([
2647 Constraint::Length(1), Constraint::Length(1), ])
2650 .split(chunks[4]);
2651
2652 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 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 let now = std::time::Instant::now();
2678 let secs = now.elapsed().as_secs_f64();
2679 (secs % 3.0) / 3.0 };
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; 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 let empty = Block::default();
2710 frame.render_widget(empty, chunks[4]);
2711 }
2712
2713 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 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
2753fn extract_scan_counts(message: &str) -> Option<(usize, usize)> {
2756 if let Some(idx) = message.find('/') {
2758 let before = &message[..idx];
2759 let after = &message[idx + 1..];
2760
2761 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 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
2789fn parse_stage_numbers(stage_str: &str) -> (Option<usize>, Option<usize>) {
2791 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 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 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 (None, None)
2823}