Skip to main content

cleansys_tui/
app.rs

1use anyhow::Result;
2use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
3use crossterm::terminal;
4use ratatui::widgets::ListState;
5use std::time::Instant;
6
7use crate::components::password_prompt::PasswordPrompt;
8use cleansys_core::{check_root, format_size, CleanerCategory, CleanerFn, Status};
9use std::time::SystemTime;
10
11#[derive(Debug, Clone)]
12pub struct DetailedCleanedItem {
13    pub path: String,
14    pub size: u64,
15    pub category: String,
16    pub cleaner_name: String,
17    pub timestamp: SystemTime,
18    pub item_type: CleanedItemType,
19}
20
21#[derive(Debug, Clone, PartialEq)]
22pub enum CleanedItemType {
23    File,
24    Directory,
25    Log,
26}
27
28impl From<cleansys_core::CleanedItemType> for CleanedItemType {
29    fn from(value: cleansys_core::CleanedItemType) -> Self {
30        match value {
31            cleansys_core::CleanedItemType::File => CleanedItemType::File,
32            cleansys_core::CleanedItemType::Directory => CleanedItemType::Directory,
33            cleansys_core::CleanedItemType::SymLink => CleanedItemType::File,
34        }
35    }
36}
37
38/// Type alias for pending operations: (category_index, item_index, name, function, requires_root)
39pub type PendingOperation = (usize, usize, String, CleanerFn, bool);
40
41#[derive(Debug, Clone, PartialEq)]
42pub enum ViewMode {
43    Standard,
44    Compact,
45    Detailed,
46    Performance,
47}
48
49#[derive(Debug, Clone, PartialEq)]
50pub enum SortMode {
51    Name,
52    Size,
53    Status,
54    Category,
55}
56
57#[derive(Debug, Clone, PartialEq)]
58pub enum FilterMode {
59    All,
60    Selected,
61    Completed,
62    Errors,
63    UserOnly,
64    SystemOnly,
65}
66
67#[derive(Debug, Clone, PartialEq)]
68pub enum ChartType {
69    Bar,
70    PieCount,
71    PieSize,
72}
73
74// `Status`, `CleanerItem`, and `CleanerCategory` now live in `cleansys-core`
75// so the TUI and GUI front-ends share the exact same domain model.
76
77pub struct App {
78    pub categories: Vec<CleanerCategory>,
79    pub category_index: usize,
80    pub item_list_state: ListState,
81    pub is_root: bool,
82    pub is_running: bool,
83    pub operation_start_time: Option<Instant>,
84    pub operation_end_time: Option<Instant>,
85    pub total_bytes_cleaned: u64,
86    pub show_help: bool,
87    pub result_messages: Vec<String>,
88    pub detailed_view: bool,
89    pub current_cleaner_index: usize,
90    pub animation_frame: usize,
91    pub last_frame_time: Instant,
92    pub terminal_width: u16,
93    pub terminal_height: u16,
94    pub compact_mode: bool,
95    pub show_performance_stats: bool,
96    pub operation_count: usize,
97    pub errors_count: usize,
98    pub paused: bool,
99    pub confirmation_mode: bool,
100    pub selected_cleaners_count: usize,
101    pub view_mode: ViewMode,
102    pub sort_mode: SortMode,
103    pub filter_mode: FilterMode,
104    pub detailed_cleaned_items: Vec<DetailedCleanedItem>,
105    pub detailed_list_scroll_state: ListState,
106    pub search_query: String,
107    pub search_active: bool,
108    pub detailed_view_filter: String,
109    pub demo_operation_timer: Option<Instant>,
110    pub demo_operations_completed: usize,
111    pub chart_type: ChartType,
112    pub operation_logs: Vec<String>,
113    pub show_progress_screen: bool,
114    pub password_prompt: PasswordPrompt,
115    pub needs_sudo: bool,
116    pub pending_operations: Vec<PendingOperation>,
117    /// Whether the "confirm this run" overlay is currently shown, awaiting
118    /// a yes/no answer before `pending_operations` actually executes.
119    pub awaiting_run_confirmation: bool,
120    /// Whether the preview (dry-run) results overlay is currently shown.
121    pub preview_open: bool,
122    /// Results of the most recent preview run: `(cleaner_name, result)`.
123    pub preview_results: Vec<(String, cleansys_core::CleaningResult)>,
124    /// Whether the "needs Administrator" notice is shown (Windows only;
125    /// there is no interactive sudo-password flow there).
126    pub needs_admin_notice: bool,
127    /// Selected cleaners staged while `awaiting_run_confirmation` is true.
128    pub pending_run_selection: Vec<PendingOperation>,
129}
130
131impl Default for App {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl App {
138    pub fn new() -> Self {
139        // Get initial terminal size
140        let (width, height) = terminal::size().unwrap_or((80, 24));
141
142        let mut app = App {
143            categories: Vec::new(),
144            category_index: 0,
145            item_list_state: ListState::default(),
146            is_root: check_root(),
147            is_running: false,
148            operation_start_time: None,
149            operation_end_time: None,
150            total_bytes_cleaned: 0,
151            show_help: false,
152            result_messages: Vec::new(),
153            detailed_view: false,
154            current_cleaner_index: 0,
155            animation_frame: 0,
156            last_frame_time: Instant::now(),
157            terminal_width: width,
158            terminal_height: height,
159            compact_mode: height < 25,
160            show_performance_stats: false,
161            operation_count: 0,
162            errors_count: 0,
163            paused: false,
164            confirmation_mode: true,
165            selected_cleaners_count: 0,
166            view_mode: if height < 25 {
167                ViewMode::Compact
168            } else {
169                ViewMode::Standard
170            },
171            sort_mode: SortMode::Category,
172            filter_mode: FilterMode::All,
173            detailed_cleaned_items: Vec::new(),
174            detailed_list_scroll_state: ListState::default(),
175            search_query: String::new(),
176            search_active: false,
177            detailed_view_filter: String::new(),
178            demo_operation_timer: None,
179            demo_operations_completed: 0,
180            chart_type: ChartType::PieCount,
181            operation_logs: Vec::new(),
182            show_progress_screen: false,
183            password_prompt: PasswordPrompt::new(),
184            needs_sudo: false,
185            pending_operations: Vec::new(),
186            awaiting_run_confirmation: false,
187            preview_open: false,
188            preview_results: Vec::new(),
189            needs_admin_notice: false,
190            pending_run_selection: Vec::new(),
191        };
192        app.item_list_state.select(Some(0));
193
194        app
195    }
196
197    pub fn toggle_search(&mut self) {
198        self.search_active = !self.search_active;
199        if !self.search_active {
200            self.search_query.clear();
201        }
202    }
203
204    pub fn clear_search(&mut self) {
205        self.search_active = false;
206        self.search_query.clear();
207        self.detailed_view_filter.clear();
208    }
209
210    pub fn add_search_char(&mut self, c: char) {
211        if self.search_active {
212            self.search_query.push(c);
213        }
214    }
215
216    pub fn remove_search_char(&mut self) {
217        if self.search_active {
218            self.search_query.pop();
219        }
220    }
221
222    pub fn get_category_distribution(&self) -> Vec<(String, usize, u64)> {
223        let mut category_map: std::collections::HashMap<String, (usize, u64)> =
224            std::collections::HashMap::new();
225
226        for item in &self.detailed_cleaned_items {
227            // Create a unique key that combines cleaner name with category type
228            // This differentiates between user and system cleaners with the same name
229            let display_name = if item.category.contains("System") {
230                format!("{} (System)", item.cleaner_name)
231            } else {
232                item.cleaner_name.clone()
233            };
234
235            let entry = category_map.entry(display_name).or_insert((0, 0));
236            entry.0 += 1;
237            entry.1 += item.size;
238        }
239
240        let mut categories: Vec<(String, usize, u64)> = category_map
241            .into_iter()
242            .map(|(name, (count, size))| (name, count, size))
243            .collect();
244
245        categories.sort_by_key(|b| std::cmp::Reverse(b.2)); // Sort by size descending
246        categories
247    }
248
249    pub fn next_item(&mut self) {
250        let items = &self.categories[self.category_index].items;
251        let i = match self.item_list_state.selected() {
252            Some(i) => {
253                if i >= items.len() - 1 {
254                    0
255                } else {
256                    i + 1
257                }
258            }
259            None => 0,
260        };
261        self.item_list_state.select(Some(i));
262    }
263
264    pub fn previous_item(&mut self) {
265        let items = &self.categories[self.category_index].items;
266        let i = match self.item_list_state.selected() {
267            Some(i) => {
268                if i == 0 {
269                    items.len() - 1
270                } else {
271                    i - 1
272                }
273            }
274            None => 0,
275        };
276        self.item_list_state.select(Some(i));
277    }
278
279    pub fn toggle_selected(&mut self) {
280        if let Some(i) = self.item_list_state.selected() {
281            let item = &mut self.categories[self.category_index].items[i];
282            // Allow selection even for root items, will prompt for password later
283            item.selected = !item.selected;
284        }
285    }
286
287    pub fn next_category(&mut self) {
288        if self.category_index < self.categories.len() - 1 {
289            self.category_index += 1;
290        } else {
291            self.category_index = 0;
292        }
293        // Reset selection in new category
294        self.item_list_state.select(Some(0));
295    }
296
297    pub fn previous_category(&mut self) {
298        if self.category_index > 0 {
299            self.category_index -= 1;
300        } else {
301            self.category_index = self.categories.len() - 1;
302        }
303        // Reset selection in new category
304        self.item_list_state.select(Some(0));
305    }
306
307    pub fn toggle_help(&mut self) {
308        self.show_help = !self.show_help;
309    }
310
311    pub fn select_all(&mut self) {
312        for item in &mut self.categories[self.category_index].items {
313            // Allow selection of all items, will handle root permissions later
314            item.selected = true;
315        }
316    }
317
318    pub fn deselect_all(&mut self) {
319        for item in &mut self.categories[self.category_index].items {
320            item.selected = false;
321        }
322    }
323
324    /// Select every item across every category (not just the active tab).
325    pub fn select_all_everywhere(&mut self) {
326        for category in &mut self.categories {
327            for item in &mut category.items {
328                item.selected = true;
329            }
330        }
331    }
332
333    /// Deselect every item across every category (not just the active tab).
334    pub fn deselect_all_everywhere(&mut self) {
335        for category in &mut self.categories {
336            for item in &mut category.items {
337                item.selected = false;
338            }
339        }
340    }
341
342    /// Gather selected cleaners and either show the confirmation overlay
343    /// (when `confirmation_mode` is on) or start execution immediately.
344    pub fn request_run(&mut self) -> Result<()> {
345        if self.is_running {
346            return Ok(());
347        }
348
349        let has_selected = self
350            .categories
351            .iter()
352            .any(|c| c.items.iter().any(|i| i.selected));
353
354        if !has_selected {
355            self.result_messages
356                .push("No items selected. Please select items to clean.".to_string());
357            return Ok(());
358        }
359
360        let mut selected_cleaners = Vec::new();
361        for (cat_idx, category) in self.categories.iter().enumerate() {
362            for (item_idx, item) in category.items.iter().enumerate() {
363                if item.selected {
364                    let name = item.name.clone();
365                    let function = item.function;
366                    selected_cleaners.push((cat_idx, item_idx, name, function, item.requires_root));
367                }
368            }
369        }
370
371        if selected_cleaners.is_empty() {
372            self.operation_logs
373                .push("No cleaners selected. Please select at least one cleaner.".to_string());
374            return Ok(());
375        }
376
377        if self.confirmation_mode {
378            self.pending_run_selection = selected_cleaners;
379            self.awaiting_run_confirmation = true;
380            Ok(())
381        } else {
382            self.begin_execution(selected_cleaners)
383        }
384    }
385
386    /// User confirmed the run in the confirmation overlay.
387    pub fn confirm_pending_run(&mut self) -> Result<()> {
388        self.awaiting_run_confirmation = false;
389        let selected_cleaners = std::mem::take(&mut self.pending_run_selection);
390        self.begin_execution(selected_cleaners)
391    }
392
393    /// User cancelled the confirmation overlay.
394    pub fn cancel_run_confirmation(&mut self) {
395        self.awaiting_run_confirmation = false;
396        self.pending_run_selection.clear();
397    }
398
399    /// Preview (dry-run) every selected cleaner synchronously: measures real
400    /// sizes/paths without deleting anything or invoking any mutating
401    /// external command, and shows the results in an overlay.
402    pub fn run_preview(&mut self) {
403        if self.is_running {
404            return;
405        }
406
407        let selected: Vec<(String, cleansys_core::CleanerFn)> = self
408            .categories
409            .iter()
410            .flat_map(|c| c.items.iter())
411            .filter(|i| i.selected)
412            .map(|i| (i.name.clone(), i.function))
413            .collect();
414
415        if selected.is_empty() {
416            self.result_messages
417                .push("No items selected. Please select items to preview.".to_string());
418            return;
419        }
420
421        self.preview_results.clear();
422        for (name, function) in selected {
423            match function(cleansys_core::RunOptions::preview()) {
424                Ok(result) => self.preview_results.push((name, result)),
425                Err(e) => self
426                    .operation_logs
427                    .push(format!("⚠️  Preview failed for {name}: {e}")),
428            }
429        }
430        self.preview_open = true;
431    }
432
433    /// Close the preview results overlay.
434    pub fn close_preview(&mut self) {
435        self.preview_open = false;
436        self.preview_results.clear();
437    }
438
439    /// Actually kick off execution: reset per-run counters/logs, clear all
440    /// items' previous status/bytes_cleaned, and mark the given selection as
441    /// `Pending`. Shared by both the direct (already-elevated) path in
442    /// [`Self::begin_execution`] and the post-password-authentication path in
443    /// [`Self::handle_key`] so the two can never drift out of sync again (a
444    /// previous copy-pasted duplicate of this omitted the `item.status =
445    /// None` reset and the trailing `update_counters()` call, which could
446    /// leave stale status/error counts from a prior run visible after
447    /// authenticating via the sudo password prompt).
448    fn start_operations(&mut self, selected_cleaners: &[PendingOperation]) {
449        self.is_running = true;
450        self.show_progress_screen = true;
451        self.operation_start_time = Some(Instant::now());
452        self.operation_end_time = None;
453        self.total_bytes_cleaned = 0;
454        self.demo_operation_timer = Some(Instant::now());
455        self.demo_operations_completed = 0;
456        self.result_messages.clear();
457        self.operation_logs.clear();
458        self.detailed_cleaned_items.clear(); // Clear previous cleaning results
459        self.current_cleaner_index = 0;
460
461        // Reset status and bytes_cleaned for all items to start fresh
462        for category in &mut self.categories {
463            for item in &mut category.items {
464                item.bytes_cleaned = 0;
465                item.status = None;
466            }
467        }
468
469        // Set all selected cleaners to Pending
470        for (cat_idx, item_idx, _, _, _) in selected_cleaners {
471            self.categories[*cat_idx].items[*item_idx].status = Some(Status::Pending);
472        }
473
474        self.update_counters();
475
476        // Operations will be processed by update_demo_operations over time.
477        // The is_running flag will be automatically turned off when all
478        // operations complete.
479    }
480
481    /// Test-only public wrapper around the private [`Self::start_operations`],
482    /// so integration tests in `tests/` (a separate crate, which can only
483    /// see `pub` items) can drive the exact same post-authentication code
484    /// path `handle_key` uses. Only compiled into debug builds.
485    #[cfg(debug_assertions)]
486    pub fn start_operations_for_tests(&mut self, selected_cleaners: &[PendingOperation]) {
487        self.start_operations(selected_cleaners);
488    }
489
490    /// Actually start execution of the given selected cleaners: prompts for
491    /// elevation if needed (sudo password on Unix, an "Administrator
492    /// required" notice on Windows), or starts the run directly.
493    fn begin_execution(&mut self, selected_cleaners: Vec<PendingOperation>) -> Result<()> {
494        let has_root_operations = selected_cleaners.iter().any(|(_, _, _, _, root)| *root);
495
496        // Check if we need elevation. `is_root` reflects whether the process
497        // itself was launched with actual root privileges (e.g. `sudo
498        // cleansys`) and never changes at runtime; `password_prompt` tracks
499        // whether the user has already authenticated via the in-app sudo
500        // dialog this session. Both must be considered here, or a user who
501        // already authenticated once would be re-prompted for their
502        // password on every subsequent run.
503        if has_root_operations && !self.is_root && !self.password_prompt.is_authenticated() {
504            self.pending_operations.clone_from(&selected_cleaners);
505            if cleansys_core::utils::supports_sudo_prompt() {
506                self.needs_sudo = true;
507                self.password_prompt.show();
508            } else {
509                self.needs_admin_notice = true;
510            }
511            return Ok(());
512        }
513
514        self.start_operations(&selected_cleaners);
515
516        Ok(())
517    }
518
519    pub fn update_animation(&mut self) {
520        let now = Instant::now();
521        if now.duration_since(self.last_frame_time).as_millis() > 100 {
522            self.animation_frame = (self.animation_frame + 1) % 10;
523            self.last_frame_time = now;
524        }
525
526        // Update demo operations if running
527        if self.is_running {
528            self.update_demo_operations();
529        }
530    }
531
532    pub fn update_demo_operations(&mut self) {
533        if let Some(start_time) = self.demo_operation_timer {
534            let elapsed = start_time.elapsed().as_millis();
535
536            // Find next pending operation to start
537            type Operation = (usize, usize, String, CleanerFn, bool);
538            let mut pending_operations: Vec<Operation> = Vec::new();
539            for (cat_idx, category) in self.categories.iter().enumerate() {
540                for (item_idx, item) in category.items.iter().enumerate() {
541                    if matches!(item.status, Some(Status::Pending)) {
542                        pending_operations.push((
543                            cat_idx,
544                            item_idx,
545                            item.name.to_string(),
546                            item.function,
547                            item.requires_root,
548                        ));
549                    }
550                }
551            }
552
553            // Start next operation every 1.5 seconds (paced so the progress
554            // screen shows operations completing one at a time rather than
555            // all at once, even though each one runs synchronously).
556            let operations_to_start = (elapsed / 1500) as usize;
557            if operations_to_start > self.demo_operations_completed
558                && !pending_operations.is_empty()
559            {
560                if let Some((cat_idx, item_idx, _name, _function, _requires_root)) =
561                    pending_operations.first()
562                {
563                    // Set to running
564                    self.categories[*cat_idx].items[*item_idx].status = Some(Status::Running);
565                    self.demo_operations_completed += 1;
566                }
567            }
568
569            // Complete running operations after 2 seconds
570            let mut running_operations: Vec<Operation> = Vec::new();
571            for (cat_idx, category) in self.categories.iter().enumerate() {
572                for (item_idx, item) in category.items.iter().enumerate() {
573                    if matches!(item.status, Some(Status::Running)) {
574                        running_operations.push((
575                            cat_idx,
576                            item_idx,
577                            item.name.to_string(),
578                            item.function,
579                            item.requires_root,
580                        ));
581                    }
582                }
583            }
584
585            // Complete operations that have been running for at least 2 seconds
586            for (cat_idx, item_idx, name, function, requires_root) in running_operations {
587                self.operation_logs.push(format!("Starting: {}", name));
588
589                // Check if operation requires root and we don't have it
590                let result: anyhow::Result<cleansys_core::CleaningResult> =
591                    if requires_root && !self.is_root && !self.password_prompt.is_authenticated() {
592                        // Show password prompt and pause operations
593                        self.needs_sudo = true;
594                        self.password_prompt.show();
595                        self.is_running = false;
596                        self.operation_logs
597                            .push(format!("🔒 {}: Waiting for sudo authentication...", name));
598                        // Return error to mark this operation as pending
599                        Err(anyhow::anyhow!("Waiting for sudo authentication"))
600                    } else {
601                        self.operation_logs.push(format!("🔄 Executing: {}", name));
602                        function(cleansys_core::RunOptions::execute())
603                    };
604
605                // Process result
606                match result {
607                    Ok(cleaning_result) => {
608                        let bytes = cleaning_result.total_bytes;
609                        let msg = if requires_root {
610                            format!(
611                                "Cleaned {} (root) ({}, {} item(s))",
612                                name,
613                                format_size(bytes),
614                                cleaning_result.item_count()
615                            )
616                        } else {
617                            format!(
618                                "Cleaned {} ({}, {} item(s))",
619                                name,
620                                format_size(bytes),
621                                cleaning_result.item_count()
622                            )
623                        };
624                        self.categories[cat_idx].items[item_idx].status =
625                            Some(Status::Success(msg));
626                        self.categories[cat_idx].items[item_idx].bytes_cleaned = bytes;
627                        self.total_bytes_cleaned += bytes;
628                        self.operation_logs.push(format!(
629                            "✅ Completed {}: {} freed across {} item(s)",
630                            name,
631                            format_size(bytes),
632                            cleaning_result.item_count()
633                        ));
634
635                        // Record real per-item detail (path + size) for the detailed view.
636                        let category_name = self.categories[cat_idx].name.clone();
637                        for item in &cleaning_result.items {
638                            self.operation_logs.push(format!(
639                                "  → {} ({})",
640                                item.path_str(),
641                                format_size(item.size)
642                            ));
643                            self.add_detailed_cleaned_item(
644                                item.path_str(),
645                                item.size,
646                                category_name.clone(),
647                                name.clone(),
648                                item.item_type.clone().into(),
649                            );
650                        }
651                        self.categories[cat_idx].items[item_idx].last_result =
652                            Some(cleaning_result);
653
654                        if bytes == 0 {
655                            self.operation_logs.push(format!(
656                                "ℹ️  {}: nothing to clean (already empty on {})",
657                                name,
658                                cleansys_core::cleaners::platform::platform_name()
659                            ));
660                        }
661                    }
662                    Err(e) => {
663                        let error_msg = if requires_root && !self.is_root {
664                            "Requires sudo - restart with 'sudo cleansys'".to_string()
665                        } else {
666                            format!(
667                                "Failed: {}",
668                                e.to_string()
669                                    .split(':')
670                                    .next_back()
671                                    .unwrap_or("Unknown error")
672                                    .trim()
673                            )
674                        };
675                        self.categories[cat_idx].items[item_idx].status =
676                            Some(Status::Error(error_msg.clone()));
677                        self.operation_logs
678                            .push(format!("❌ Failed {}: {}", name, error_msg));
679
680                        // Add helpful message for sudo requirement
681                        if requires_root
682                            && !self.is_root
683                            && !self
684                                .result_messages
685                                .iter()
686                                .any(|msg| msg.contains("sudo cleansys"))
687                        {
688                            self.result_messages.push(
689                                "💡 System cleaners require root privileges. Run 'sudo cleansys' to clean system files.".to_string()
690                            );
691                        }
692                    }
693                }
694            }
695        }
696    }
697
698    pub fn cancel_sudo_operations(&mut self) {
699        // Mark all operations as cancelled
700        for category in &mut self.categories {
701            for item in &mut category.items {
702                if item.selected && matches!(item.status, Some(Status::Running | Status::Pending)) {
703                    item.status = Some(Status::Error("Operation cancelled by user".to_string()));
704                    item.selected = false; // Deselect the item
705                }
706            }
707        }
708
709        self.result_messages
710            .push("Cleaning operations cancelled by user.".to_string());
711    }
712
713    pub fn handle_key(&mut self, key: KeyEvent) -> Result<bool> {
714        // If password prompt is visible, handle password input first
715        if self.password_prompt.is_visible() {
716            match key.code {
717                KeyCode::Enter => {
718                    // Submit password and authenticate
719                    match self.password_prompt.submit() {
720                        Ok(true) => {
721                            // Authentication successful, proceed with operations
722                            self.needs_sudo = false;
723                            self.password_prompt.hide();
724
725                            // Now start the actual cleaning operations
726                            let selected_cleaners = self.pending_operations.clone();
727                            self.pending_operations.clear();
728
729                            if !selected_cleaners.is_empty() {
730                                self.start_operations(&selected_cleaners);
731                            }
732                        }
733                        Ok(false) => {
734                            // Authentication failed, stay on prompt
735                        }
736                        Err(e) => {
737                            self.operation_logs
738                                .push(format!("❌ Authentication error: {}", e));
739                            self.password_prompt.hide();
740                            self.needs_sudo = false;
741                            self.pending_operations.clear();
742                        }
743                    }
744                }
745                KeyCode::Esc => {
746                    // Cancel password prompt
747                    self.password_prompt.cancel();
748                    self.needs_sudo = false;
749                    self.pending_operations.clear();
750                }
751                KeyCode::Char(c) => {
752                    self.password_prompt.add_char(c);
753                }
754                KeyCode::Backspace => {
755                    self.password_prompt.remove_char();
756                }
757                _ => {}
758            }
759            return Ok(false);
760        }
761
762        // "Needs Administrator" notice (Windows only — no interactive sudo flow there)
763        if self.needs_admin_notice {
764            match key.code {
765                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
766                    self.needs_admin_notice = false;
767                    self.pending_operations.clear();
768                }
769                _ => {}
770            }
771            return Ok(false);
772        }
773
774        // Run confirmation overlay
775        if self.awaiting_run_confirmation {
776            match key.code {
777                KeyCode::Enter | KeyCode::Char('y') => {
778                    self.confirm_pending_run()?;
779                }
780                KeyCode::Esc | KeyCode::Char('n') => {
781                    self.cancel_run_confirmation();
782                }
783                _ => {}
784            }
785            return Ok(false);
786        }
787
788        // Preview results overlay
789        if self.preview_open {
790            match key.code {
791                KeyCode::Esc | KeyCode::Enter | KeyCode::Char('q') => {
792                    self.close_preview();
793                }
794                _ => {}
795            }
796            return Ok(false);
797        }
798
799        match (key.code, key.modifiers) {
800            // Quit
801            (KeyCode::Char('q'), _) => {
802                if self.show_help {
803                    self.show_help = false;
804                } else if self.is_running {
805                    // Cancel current cleaning operations
806                    self.is_running = false;
807                    self.cancel_sudo_operations();
808                } else {
809                    return Ok(true);
810                }
811            }
812
813            // Navigation
814            (KeyCode::Down, _) => {
815                if !self.show_help {
816                    if self.is_running || self.show_progress_screen {
817                        self.scroll_detailed_list_down();
818                    } else {
819                        self.next_item();
820                    }
821                }
822            }
823            (KeyCode::Up, _) => {
824                if !self.show_help {
825                    if self.is_running || self.show_progress_screen {
826                        self.scroll_detailed_list_up();
827                    } else {
828                        self.previous_item();
829                    }
830                }
831            }
832            (KeyCode::Tab, _) => {
833                if !self.show_help {
834                    self.next_category();
835                }
836            }
837            (KeyCode::BackTab, _) => {
838                if !self.show_help {
839                    self.previous_category();
840                }
841            }
842            // Selection
843            (KeyCode::Char(' '), KeyModifiers::NONE) => {
844                if !self.show_help {
845                    self.toggle_selected();
846                }
847            }
848            // Run cleaners (shows a confirmation overlay first, unless
849            // confirmation prompts are disabled via 'y').
850            (KeyCode::Enter, _) => {
851                if !self.show_help {
852                    self.request_run()?;
853                }
854            }
855            // Preview (dry-run) selected cleaners — measures real sizes/paths
856            // without deleting anything.
857            (KeyCode::Char('d'), _) => {
858                if !self.show_help && !self.is_running {
859                    self.run_preview();
860                }
861            }
862            // Help dialog
863            (KeyCode::Char('?' | 'h'), _) => {
864                self.toggle_help();
865            }
866
867            // Toggle search in removed items view
868            (KeyCode::Char('/'), _) => {
869                if !self.show_help {
870                    self.toggle_search();
871                }
872            }
873            // Clear search or cancel operations or return to main menu
874            (KeyCode::Esc, _) => {
875                if self.search_active {
876                    self.clear_search();
877                } else if self.is_running {
878                    self.is_running = false;
879                    self.cancel_sudo_operations();
880                } else if self.show_progress_screen {
881                    // Return to main menu from completed operations screen
882                    self.show_progress_screen = false;
883                }
884            }
885            // Scroll removed items list
886            (KeyCode::Char('j'), _) => {
887                if !self.show_help {
888                    self.scroll_detailed_list_down();
889                }
890            }
891            (KeyCode::Char('k'), _) => {
892                if !self.show_help {
893                    self.scroll_detailed_list_up();
894                }
895            }
896            // Select all in current category
897            (KeyCode::Char('a'), _) => {
898                if !self.show_help {
899                    self.select_all();
900                }
901            }
902            // Deselect all in current category
903            (KeyCode::Char('n'), _) => {
904                if !self.show_help {
905                    self.deselect_all();
906                }
907            }
908            // Select all across every category
909            (KeyCode::Char('A'), _) => {
910                if !self.show_help {
911                    self.select_all_everywhere();
912                }
913            }
914            // Deselect all across every category
915            (KeyCode::Char('N'), _) => {
916                if !self.show_help {
917                    self.deselect_all_everywhere();
918                }
919            }
920
921            // Toggle compact mode
922            (KeyCode::Char('m'), _) => {
923                if !self.show_help {
924                    self.toggle_compact_mode();
925                }
926            }
927            // Toggle auto scroll log
928            (KeyCode::Char('s'), _) => {
929                if !self.show_help && self.is_running {
930                    self.toggle_auto_scroll();
931                }
932            }
933            // Toggle performance stats
934            (KeyCode::Char('p'), _) => {
935                if !self.show_help {
936                    self.toggle_performance_stats();
937                }
938            }
939            // Cycle view mode
940            (KeyCode::Char('v'), _) => {
941                if !self.show_help {
942                    self.cycle_view_mode();
943                }
944            }
945            // Cycle sort mode
946            (KeyCode::Char('o'), _) => {
947                if !self.show_help {
948                    self.cycle_sort_mode();
949                }
950            }
951            // Cycle filter mode
952            (KeyCode::Char('f'), _) => {
953                if !self.show_help {
954                    self.cycle_filter_mode();
955                }
956            }
957            // Toggle pause/resume operations
958            (KeyCode::Char(' '), KeyModifiers::CONTROL) => {
959                if self.is_running {
960                    self.toggle_pause();
961                }
962            }
963            // Toggle confirmation mode
964            (KeyCode::Char('y'), _) => {
965                if !self.show_help {
966                    self.toggle_confirmation_mode();
967                }
968            }
969            // Toggle chart type
970            (KeyCode::Char('c'), _) => {
971                if !self.show_help {
972                    self.toggle_chart_type();
973                }
974            }
975            // Clear all errors
976            (KeyCode::Char('x'), _) => {
977                if !self.show_help {
978                    self.clear_errors();
979                }
980            }
981            // Handle search input (only when search is active)
982            (KeyCode::Char(c), _) => {
983                if self.search_active {
984                    self.add_search_char(c);
985                } else if !self.show_help {
986                    self.toggle_selected();
987                }
988            }
989            // Backspace in search
990            (KeyCode::Backspace, _) => {
991                if self.search_active {
992                    self.remove_search_char();
993                }
994            }
995            // Page scrolling for removed items (when in progress view)
996            (KeyCode::PageUp, _) => {
997                if self.is_running || self.show_progress_screen {
998                    // Scroll up by 10 items
999                    for _ in 0..10 {
1000                        self.scroll_detailed_list_up();
1001                    }
1002                }
1003            }
1004            (KeyCode::PageDown, _) => {
1005                if self.is_running || self.show_progress_screen {
1006                    // Scroll down by 10 items
1007                    for _ in 0..10 {
1008                        self.scroll_detailed_list_down();
1009                    }
1010                }
1011            }
1012            // Enhanced navigation with Ctrl modifiers
1013            (KeyCode::Home, _) => {
1014                if !self.show_help {
1015                    if self.is_running || self.show_progress_screen {
1016                        self.detailed_list_scroll_state.select(Some(0));
1017                    } else {
1018                        self.item_list_state.select(Some(0));
1019                    }
1020                }
1021            }
1022            (KeyCode::End, _) if !self.show_help => {
1023                if self.is_running || self.show_progress_screen {
1024                    if !self.detailed_cleaned_items.is_empty() {
1025                        let last_index = (self.detailed_cleaned_items.len() * 3).saturating_sub(1);
1026                        self.detailed_list_scroll_state.select(Some(last_index));
1027                    }
1028                } else {
1029                    let len = self.categories[self.category_index].items.len();
1030                    if len > 0 {
1031                        self.item_list_state.select(Some(len - 1));
1032                    }
1033                }
1034            }
1035            _ => {}
1036        }
1037
1038        Ok(false)
1039    }
1040
1041    pub fn handle_resize(&mut self, width: u16, height: u16) {
1042        self.terminal_width = width;
1043        self.terminal_height = height;
1044    }
1045
1046    pub fn toggle_compact_mode(&mut self) {
1047        self.compact_mode = !self.compact_mode;
1048        self.view_mode = if self.compact_mode {
1049            ViewMode::Compact
1050        } else {
1051            ViewMode::Standard
1052        };
1053    }
1054
1055    pub fn toggle_auto_scroll(&mut self) {
1056        // Auto scroll functionality for operation logs
1057    }
1058
1059    pub fn toggle_performance_stats(&mut self) {
1060        self.show_performance_stats = !self.show_performance_stats;
1061    }
1062
1063    pub fn cycle_view_mode(&mut self) {
1064        self.view_mode = match self.view_mode {
1065            ViewMode::Standard => ViewMode::Compact,
1066            ViewMode::Compact => ViewMode::Detailed,
1067            ViewMode::Detailed => ViewMode::Performance,
1068            ViewMode::Performance => ViewMode::Standard,
1069        };
1070    }
1071
1072    pub fn cycle_sort_mode(&mut self) {
1073        self.sort_mode = match self.sort_mode {
1074            SortMode::Name => SortMode::Size,
1075            SortMode::Size => SortMode::Status,
1076            SortMode::Status => SortMode::Category,
1077            SortMode::Category => SortMode::Name,
1078        };
1079    }
1080
1081    pub fn cycle_filter_mode(&mut self) {
1082        self.filter_mode = match self.filter_mode {
1083            FilterMode::All => FilterMode::Selected,
1084            FilterMode::Selected => FilterMode::Completed,
1085            FilterMode::Completed => FilterMode::Errors,
1086            FilterMode::Errors => FilterMode::UserOnly,
1087            FilterMode::UserOnly => FilterMode::SystemOnly,
1088            FilterMode::SystemOnly => FilterMode::All,
1089        };
1090    }
1091
1092    pub fn toggle_pause(&mut self) {
1093        self.paused = !self.paused;
1094    }
1095
1096    pub fn toggle_confirmation_mode(&mut self) {
1097        self.confirmation_mode = !self.confirmation_mode;
1098    }
1099
1100    pub fn update_counters(&mut self) {
1101        self.selected_cleaners_count = self
1102            .categories
1103            .iter()
1104            .flat_map(|cat| &cat.items)
1105            .filter(|item| item.selected)
1106            .count();
1107
1108        self.errors_count = self
1109            .categories
1110            .iter()
1111            .flat_map(|cat| &cat.items)
1112            .filter(|item| matches!(item.status, Some(Status::Error(_))))
1113            .count();
1114
1115        self.operation_count = self
1116            .categories
1117            .iter()
1118            .flat_map(|cat| &cat.items)
1119            .filter(|item| item.status.is_some())
1120            .count();
1121
1122        // Auto-complete when all operations are finished
1123        if self.is_running && self.operation_count > 0 {
1124            let running_count = self
1125                .categories
1126                .iter()
1127                .flat_map(|cat| &cat.items)
1128                .filter(|item| matches!(item.status, Some(Status::Running)))
1129                .count();
1130
1131            let pending_count = self
1132                .categories
1133                .iter()
1134                .flat_map(|cat| &cat.items)
1135                .filter(|item| matches!(item.status, Some(Status::Pending)))
1136                .count();
1137
1138            let selected_count = self
1139                .categories
1140                .iter()
1141                .flat_map(|cat| &cat.items)
1142                .filter(|item| item.selected)
1143                .count();
1144
1145            // If no operations are running or pending, and we have selected items, mark as complete
1146            if running_count == 0 && pending_count == 0 && selected_count > 0 {
1147                self.is_running = false;
1148                self.demo_operation_timer = None;
1149                self.operation_end_time = Some(Instant::now());
1150
1151                // Add completion message
1152                if !self
1153                    .result_messages
1154                    .iter()
1155                    .any(|msg| msg.contains("Completed"))
1156                {
1157                    let summary = format!(
1158                        "Cleaning completed! Total space freed: {}",
1159                        format_size(self.total_bytes_cleaned)
1160                    );
1161                    self.result_messages
1162                        .push(format!("✅ {summary} (Press ESC to return to main menu)"));
1163                    crate::notifications::notify_completion(&summary);
1164                }
1165                // Keep show_progress_screen true so user stays on details screen
1166            }
1167        }
1168    }
1169
1170    pub fn clear_errors(&mut self) {
1171        for category in &mut self.categories {
1172            for item in &mut category.items {
1173                if matches!(item.status, Some(Status::Error(_))) {
1174                    item.status = None;
1175                }
1176            }
1177        }
1178        self.errors_count = 0;
1179    }
1180
1181    pub fn get_elapsed_time(&self) -> String {
1182        if let Some(start_time) = self.operation_start_time {
1183            let elapsed = if let Some(end_time) = self.operation_end_time {
1184                // Operation completed, show total time
1185                end_time.duration_since(start_time)
1186            } else {
1187                // Operation still running, show current elapsed time
1188                start_time.elapsed()
1189            };
1190
1191            if elapsed.as_secs() < 60 {
1192                format!("{}s", elapsed.as_secs())
1193            } else {
1194                format!("{}m {}s", elapsed.as_secs() / 60, elapsed.as_secs() % 60)
1195            }
1196        } else {
1197            "0s".to_string()
1198        }
1199    }
1200
1201    pub fn add_detailed_cleaned_item(
1202        &mut self,
1203        path: String,
1204        size: u64,
1205        category: String,
1206        cleaner_name: String,
1207        item_type: CleanedItemType,
1208    ) {
1209        let item = DetailedCleanedItem {
1210            path,
1211            size,
1212            category,
1213            cleaner_name,
1214            timestamp: SystemTime::now(),
1215            item_type,
1216        };
1217        self.detailed_cleaned_items.push(item);
1218
1219        // Keep only last 1000 items to prevent memory issues
1220        if self.detailed_cleaned_items.len() > 1000 {
1221            self.detailed_cleaned_items.remove(0);
1222        }
1223    }
1224
1225    pub fn scroll_detailed_list_up(&mut self) {
1226        if let Some(selected) = self.detailed_list_scroll_state.selected() {
1227            if selected > 0 {
1228                self.detailed_list_scroll_state.select(Some(selected - 1));
1229            }
1230        } else {
1231            // Start from the bottom when first navigating
1232            let total_items = if !self.detailed_cleaned_items.is_empty() {
1233                self.detailed_cleaned_items.len() * 3 // Account for spacing between items
1234            } else {
1235                45 // Sample items count for demo
1236            };
1237            if total_items > 0 {
1238                self.detailed_list_scroll_state
1239                    .select(Some(total_items - 1));
1240            }
1241        }
1242    }
1243
1244    pub fn scroll_detailed_list_down(&mut self) {
1245        let total_items = if !self.detailed_cleaned_items.is_empty() {
1246            self.detailed_cleaned_items.len() * 3 // Account for spacing between items
1247        } else {
1248            45 // Sample items count for demo
1249        };
1250
1251        if let Some(selected) = self.detailed_list_scroll_state.selected() {
1252            if selected < total_items.saturating_sub(1) {
1253                self.detailed_list_scroll_state.select(Some(selected + 1));
1254            }
1255        } else if total_items > 0 {
1256            self.detailed_list_scroll_state.select(Some(0));
1257        }
1258    }
1259
1260    pub fn get_filtered_detailed_items(&self) -> Vec<&DetailedCleanedItem> {
1261        let mut items: Vec<&DetailedCleanedItem> = self
1262            .detailed_cleaned_items
1263            .iter()
1264            .filter(|item| {
1265                // Apply search filter
1266                if !self.search_query.is_empty() {
1267                    let query_lower = self.search_query.to_lowercase();
1268                    return item.path.to_lowercase().contains(&query_lower)
1269                        || item.category.to_lowercase().contains(&query_lower)
1270                        || item.cleaner_name.to_lowercase().contains(&query_lower);
1271                }
1272
1273                // Apply category filter
1274                if !self.detailed_view_filter.is_empty() {
1275                    return item
1276                        .category
1277                        .to_lowercase()
1278                        .contains(&self.detailed_view_filter.to_lowercase());
1279                }
1280
1281                true
1282            })
1283            .collect();
1284
1285        // Sort based on current sort mode
1286        match self.sort_mode {
1287            SortMode::Name => items.sort_by(|a, b| a.path.cmp(&b.path)),
1288            SortMode::Size => items.sort_by_key(|b| std::cmp::Reverse(b.size)), // Largest first
1289            SortMode::Category => items.sort_by(|a, b| a.category.cmp(&b.category)),
1290            SortMode::Status => items.sort_by_key(|b| std::cmp::Reverse(b.timestamp)), // Most recent first
1291        }
1292
1293        items
1294    }
1295
1296    pub fn toggle_chart_type(&mut self) {
1297        self.chart_type = match self.chart_type {
1298            ChartType::Bar => ChartType::PieCount,
1299            ChartType::PieCount => ChartType::PieSize,
1300            ChartType::PieSize => ChartType::Bar,
1301        };
1302    }
1303}