gravityfile_tui/
app.rs

1//! Main application state and logic.
2
3use std::collections::HashSet;
4use std::fs;
5use std::path::PathBuf;
6use std::time::Duration;
7
8use crossterm::event::{Event, EventStream, KeyCode, KeyEvent, KeyModifiers};
9use futures::StreamExt;
10use ratatui::buffer::Buffer;
11use ratatui::layout::{Constraint, Layout, Rect};
12use ratatui::style::{Modifier, Style};
13use ratatui::text::{Line, Span};
14use ratatui::widgets::{Block, Borders, Clear, Paragraph, Tabs, Widget};
15use ratatui::{DefaultTerminal, Frame};
16use strum::{Display, EnumIter, FromRepr, IntoEnumIterator};
17use tokio::sync::mpsc;
18
19use gravityfile_analyze::{
20    AgeAnalyzer, AgeConfig, AgeReport, DuplicateConfig, DuplicateFinder, DuplicateReport,
21};
22use gravityfile_core::FileTree;
23use gravityfile_scan::{JwalkScanner, ScanConfig, ScanProgress};
24
25use crate::event::KeyAction;
26use crate::theme::Theme;
27use crate::ui::{format_relative_time, format_size, AppLayout, HelpOverlay, TreeState, TreeView};
28
29/// Application result type.
30pub type AppResult<T> = color_eyre::Result<T>;
31
32/// Application mode.
33#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
34pub enum AppMode {
35    #[default]
36    Normal,
37    Scanning,
38    Help,
39    /// Confirming deletion of marked items.
40    ConfirmDelete,
41    /// Command palette input mode (vim-style :command).
42    Command,
43    Quit,
44}
45
46/// Active view/tab during normal mode.
47#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Display, EnumIter, FromRepr)]
48pub enum View {
49    #[default]
50    Explorer,
51    Duplicates,
52    Age,
53    Errors,
54}
55
56/// Active view/tab during scanning mode.
57#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
58pub enum ScanView {
59    #[default]
60    Progress,
61    Errors,
62}
63
64impl ScanView {
65    fn next(self) -> Self {
66        match self {
67            ScanView::Progress => ScanView::Errors,
68            ScanView::Errors => ScanView::Progress,
69        }
70    }
71
72    fn prev(self) -> Self {
73        self.next() // Only 2 options, so same as next
74    }
75}
76
77impl View {
78    fn next(self) -> Self {
79        let current = self as usize;
80        let next = (current + 1) % Self::iter().count();
81        Self::from_repr(next).unwrap_or_default()
82    }
83
84    fn prev(self) -> Self {
85        let current = self as usize;
86        let count = Self::iter().count();
87        let prev = (current + count - 1) % count;
88        Self::from_repr(prev).unwrap_or_default()
89    }
90}
91
92/// Result from a background scan operation.
93enum ScanResult {
94    Progress(ScanProgress),
95    #[allow(dead_code)] // For future real-time warning streaming
96    Warning(gravityfile_core::ScanWarning),
97    ScanComplete(Result<FileTree, gravityfile_scan::ScanError>),
98    AnalysisComplete {
99        duplicates: DuplicateReport,
100        age_report: AgeReport,
101    },
102}
103
104/// Main application state.
105pub struct App {
106    /// Path being analyzed (scan root).
107    path: PathBuf,
108    /// Current view root for drill-down navigation (can be different from scan root).
109    view_root: PathBuf,
110    /// Navigation history stack for going back up (path, selected_index, expanded_set).
111    view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
112    /// Current mode.
113    mode: AppMode,
114    /// Current view (normal mode).
115    view: View,
116    /// Current view during scanning.
117    scan_view: ScanView,
118    /// Color theme.
119    theme: Theme,
120    /// Scanned file tree.
121    tree: Option<FileTree>,
122    /// Tree view state.
123    tree_state: TreeState,
124    /// Cached count of visible tree items (to avoid flatten on every nav).
125    cached_tree_len: usize,
126    /// Duplicate analysis report.
127    duplicates: Option<DuplicateReport>,
128    /// Age analysis report.
129    age_report: Option<AgeReport>,
130    /// Selected duplicate group index.
131    selected_dup_group: usize,
132    /// Selected stale directory index.
133    selected_stale_dir: usize,
134    /// Show details panel.
135    show_details: bool,
136    /// Error message to display.
137    error: Option<String>,
138    /// Paths marked for deletion.
139    marked_for_deletion: HashSet<PathBuf>,
140    /// Last deletion result message.
141    deletion_message: Option<(bool, String)>,
142    /// Current scan progress (for display during scanning).
143    scan_progress: Option<ScanProgress>,
144    /// Channel for receiving scan results.
145    scan_rx: Option<mpsc::Receiver<ScanResult>>,
146    /// Scan warnings/errors (populated in real-time during scan).
147    warnings: Vec<gravityfile_core::ScanWarning>,
148    /// Selected warning index.
149    selected_warning: usize,
150    /// Whether analysis is running.
151    analyzing: bool,
152    /// Flag indicating UI needs redraw.
153    needs_redraw: bool,
154    /// Command palette input buffer.
155    command_input: String,
156    /// Command palette cursor position.
157    command_cursor: usize,
158}
159
160impl App {
161    /// Create a new application.
162    pub fn new(path: PathBuf) -> Self {
163        Self {
164            path: path.clone(),
165            view_root: path.clone(),
166            view_history: Vec::new(),
167            mode: AppMode::default(),
168            view: View::default(),
169            scan_view: ScanView::default(),
170            theme: Theme::dark(),
171            tree: None,
172            tree_state: TreeState::new(path),
173            cached_tree_len: 0,
174            duplicates: None,
175            age_report: None,
176            selected_dup_group: 0,
177            selected_stale_dir: 0,
178            show_details: true,
179            error: None,
180            marked_for_deletion: HashSet::new(),
181            deletion_message: None,
182            scan_progress: None,
183            scan_rx: None,
184            warnings: Vec::new(),
185            selected_warning: 0,
186            analyzing: false,
187            needs_redraw: true,
188            command_input: String::new(),
189            command_cursor: 0,
190        }
191    }
192
193    /// Run the application with async event loop.
194    pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
195        // Start initial scan
196        self.start_scan();
197
198        // Use a longer interval - only for catching background updates
199        let period = Duration::from_millis(50);
200        let mut interval = tokio::time::interval(period);
201        let mut events = EventStream::new();
202
203        // Main async event loop using tokio::select!
204        while self.mode != AppMode::Quit {
205            // Only render when needed
206            if self.needs_redraw {
207                terminal.draw(|frame| self.render(frame))?;
208                self.needs_redraw = false;
209            }
210
211            tokio::select! {
212                biased; // Prioritize in order listed
213
214                // Handle terminal events first - drain all pending events before rendering
215                Some(Ok(event)) = events.next() => {
216                    if let Event::Key(key_event) = event {
217                        // Only process key press events, ignore releases and repeats for navigation
218                        if key_event.kind == crossterm::event::KeyEventKind::Press {
219                            // Command mode uses raw key input
220                            if self.mode == AppMode::Command {
221                                self.handle_command_input(key_event);
222                            } else {
223                                let action = KeyAction::from_key_event(key_event);
224                                self.handle_action(action);
225                            }
226                        }
227                    }
228
229                    // Drain any additional pending events (prevents input lag)
230                    while crossterm::event::poll(Duration::ZERO)? {
231                        if let Ok(Event::Key(key_event)) = crossterm::event::read() {
232                            if key_event.kind == crossterm::event::KeyEventKind::Press {
233                                if self.mode == AppMode::Command {
234                                    self.handle_command_input(key_event);
235                                } else {
236                                    let action = KeyAction::from_key_event(key_event);
237                                    self.handle_action(action);
238                                }
239                                // Exit early if quitting
240                                if self.mode == AppMode::Quit {
241                                    break;
242                                }
243                            }
244                        }
245                    }
246                    self.needs_redraw = true;
247                }
248
249                // Handle scan progress/completion
250                Some(result) = async {
251                    if let Some(rx) = &mut self.scan_rx {
252                        rx.recv().await
253                    } else {
254                        std::future::pending().await
255                    }
256                } => {
257                    self.handle_scan_result(result);
258                    self.needs_redraw = true;
259                }
260
261                // Periodic tick for background updates (scan progress)
262                _ = interval.tick() => {
263                    // Just tick - scan results will set needs_redraw
264                }
265            }
266        }
267
268        Ok(())
269    }
270
271    /// Start a background scan.
272    fn start_scan(&mut self) {
273        self.mode = AppMode::Scanning;
274        self.scan_view = ScanView::Progress;
275        self.scan_progress = Some(ScanProgress::new());
276        self.tree = None;
277        self.duplicates = None;
278        self.age_report = None;
279        self.warnings.clear();
280        self.selected_warning = 0;
281        self.cached_tree_len = 0;
282
283        let (tx, rx) = mpsc::channel(100);
284        self.scan_rx = Some(rx);
285
286        let path = self.path.clone();
287
288        // Spawn background scan task
289        tokio::spawn(async move {
290            let config = ScanConfig::new(&path);
291            let scanner = JwalkScanner::new();
292            let mut progress_rx = scanner.subscribe();
293
294            // Spawn task to forward progress updates
295            let tx_progress = tx.clone();
296            let progress_task = tokio::spawn(async move {
297                while let Ok(progress) = progress_rx.recv().await {
298                    if tx_progress.send(ScanResult::Progress(progress)).await.is_err() {
299                        break;
300                    }
301                }
302            });
303
304            // Run scan in blocking task (jwalk uses rayon internally)
305            let result = tokio::task::spawn_blocking(move || scanner.scan(&config))
306                .await
307                .unwrap_or_else(|e| Err(gravityfile_scan::ScanError::Other {
308                    message: e.to_string(),
309                }));
310
311            // Cancel progress task and send final result
312            progress_task.abort();
313            let _ = tx.send(ScanResult::ScanComplete(result)).await;
314        });
315    }
316
317    /// Handle a scan result from the background task.
318    fn handle_scan_result(&mut self, result: ScanResult) {
319        match result {
320            ScanResult::Progress(progress) => {
321                self.scan_progress = Some(progress);
322            }
323            ScanResult::Warning(warning) => {
324                // Real-time warning collection during scan
325                self.warnings.push(warning);
326            }
327            ScanResult::ScanComplete(Ok(tree)) => {
328                let root_path = tree.root_path.clone();
329
330                // Merge any final warnings from tree (in case some weren't streamed)
331                for warning in &tree.warnings {
332                    if !self.warnings.iter().any(|w| w.path == warning.path) {
333                        self.warnings.push(warning.clone());
334                    }
335                }
336
337                // Clone tree for analysis before storing
338                let tree_for_analysis = tree.clone();
339
340                self.tree = Some(tree);
341                self.tree_state = TreeState::new(root_path);
342                self.update_cached_tree_len();
343                self.mode = AppMode::Normal;
344                self.error = None;
345                self.scan_progress = None;
346
347                // Start background analysis (after storing tree)
348                self.analyzing = true;
349                self.start_analysis(tree_for_analysis);
350            }
351            ScanResult::ScanComplete(Err(e)) => {
352                self.error = Some(e.to_string());
353                self.mode = AppMode::Normal;
354                self.scan_progress = None;
355                self.scan_rx = None;
356                self.analyzing = false;
357            }
358            ScanResult::AnalysisComplete { duplicates, age_report } => {
359                self.duplicates = Some(duplicates);
360                self.age_report = Some(age_report);
361                self.analyzing = false;
362                self.scan_rx = None;
363            }
364        }
365    }
366
367    /// Update cached tree item count.
368    fn update_cached_tree_len(&mut self) {
369        if let Some((node, root_path)) = self.get_view_root_node() {
370            let items = TreeView::new(node, &root_path, &self.theme)
371                .flatten(&self.tree_state);
372            self.cached_tree_len = items.len();
373        } else {
374            self.cached_tree_len = 0;
375        }
376    }
377
378    /// Start background analysis of the tree.
379    fn start_analysis(&mut self, tree: FileTree) {
380        // Create a new channel for analysis results
381        let (tx, rx) = mpsc::channel(10);
382        self.scan_rx = Some(rx);
383
384        tokio::spawn(async move {
385            // Run analysis in blocking task
386            let result = tokio::task::spawn_blocking(move || {
387                let dup_config = DuplicateConfig::builder()
388                    .min_size(1024u64)
389                    .max_groups(100usize)
390                    .build()
391                    .unwrap();
392                let finder = DuplicateFinder::with_config(dup_config);
393                let duplicates = finder.find_duplicates(&tree);
394
395                let age_config = AgeConfig::default();
396                let analyzer = AgeAnalyzer::with_config(age_config);
397                let age_report = analyzer.analyze(&tree);
398
399                (duplicates, age_report)
400            })
401            .await;
402
403            if let Ok((duplicates, age_report)) = result {
404                let _ = tx.send(ScanResult::AnalysisComplete { duplicates, age_report }).await;
405            }
406        });
407    }
408
409    /// Render the application.
410    fn render(&self, frame: &mut Frame) {
411        frame.render_widget(self, frame.area());
412    }
413
414    /// Handle a key action.
415    fn handle_action(&mut self, action: KeyAction) {
416        // Clear deletion message on any action
417        self.deletion_message = None;
418
419        // Handle special modes
420        match self.mode {
421            AppMode::Help => {
422                match action {
423                    KeyAction::ToggleHelp | KeyAction::Quit => {
424                        // Return to scanning mode if scan is still in progress
425                        if self.scan_progress.is_some() && self.tree.is_none() {
426                            self.mode = AppMode::Scanning;
427                        } else {
428                            self.mode = AppMode::Normal;
429                        }
430                    }
431                    _ => {}
432                }
433                return;
434            }
435            AppMode::ConfirmDelete => {
436                match action {
437                    KeyAction::Confirm => {
438                        self.execute_deletion();
439                        self.mode = AppMode::Normal;
440                    }
441                    KeyAction::Quit | KeyAction::Cancel => {
442                        self.mode = AppMode::Normal;
443                    }
444                    _ => {}
445                }
446                return;
447            }
448            AppMode::Command => {
449                // Command mode is handled separately via raw key events
450                // This branch handles mapped actions that might come through
451                match action {
452                    KeyAction::Quit | KeyAction::Cancel => {
453                        self.command_input.clear();
454                        self.command_cursor = 0;
455                        self.mode = AppMode::Normal;
456                    }
457                    _ => {}
458                }
459                return;
460            }
461            _ => {}
462        }
463
464        match action {
465            KeyAction::Quit | KeyAction::ForceQuit => {
466                self.mode = AppMode::Quit;
467            }
468
469            // View switching - different behavior during scan
470            KeyAction::NextTab => {
471                if self.mode == AppMode::Scanning {
472                    self.scan_view = self.scan_view.next();
473                } else {
474                    self.view = self.view.next();
475                }
476            }
477            KeyAction::PrevTab => {
478                if self.mode == AppMode::Scanning {
479                    self.scan_view = self.scan_view.prev();
480                } else {
481                    self.view = self.view.prev();
482                }
483            }
484
485            // Navigation based on current view
486            KeyAction::MoveUp => self.move_up(),
487            KeyAction::MoveDown => self.move_down(),
488            KeyAction::PageUp => self.page_up(),
489            KeyAction::PageDown => self.page_down(),
490            KeyAction::JumpToTop => self.jump_to_top(),
491            KeyAction::JumpToBottom => self.jump_to_bottom(),
492
493            KeyAction::MoveRight | KeyAction::Expand => {
494                if self.view == View::Explorer {
495                    self.expand_selected();
496                }
497            }
498            KeyAction::MoveLeft | KeyAction::Collapse => {
499                if self.view == View::Explorer {
500                    self.collapse_selected();
501                }
502            }
503            KeyAction::ToggleExpand => {
504                if self.view == View::Explorer {
505                    self.toggle_selected();
506                }
507            }
508
509            // Deletion
510            KeyAction::Delete => {
511                self.toggle_mark_for_deletion();
512            }
513            KeyAction::Confirm => {
514                // Enter: if items are marked, show confirmation; otherwise toggle expand in explorer
515                if !self.marked_for_deletion.is_empty() {
516                    self.mode = AppMode::ConfirmDelete;
517                } else if self.view == View::Explorer {
518                    self.toggle_selected();
519                }
520            }
521            KeyAction::ClearMarks => {
522                self.marked_for_deletion.clear();
523            }
524
525            // Directory drill-down navigation
526            KeyAction::DrillDown => {
527                if self.view == View::Explorer {
528                    self.drill_into_selected();
529                }
530            }
531            KeyAction::NavigateBack => {
532                if self.view == View::Explorer {
533                    self.navigate_back();
534                }
535            }
536
537            // Command palette
538            KeyAction::CommandMode => {
539                self.command_input.clear();
540                self.command_cursor = 0;
541                self.mode = AppMode::Command;
542            }
543
544            KeyAction::ToggleDetails => {
545                self.show_details = !self.show_details;
546            }
547            KeyAction::ToggleHelp => {
548                self.mode = AppMode::Help;
549            }
550            KeyAction::ToggleTheme => {
551                self.theme = self.theme.toggle();
552            }
553            KeyAction::Refresh => {
554                self.marked_for_deletion.clear();
555                self.start_scan();
556            }
557
558            _ => {}
559        }
560    }
561
562    /// Toggle marking the currently selected item for deletion.
563    fn toggle_mark_for_deletion(&mut self) {
564        let path = match self.view {
565            View::Explorer => {
566                if let Some((node, root_path)) = self.get_view_root_node() {
567                    let items = TreeView::new(node, &root_path, &self.theme)
568                        .flatten(&self.tree_state);
569                    items.get(self.tree_state.selected).map(|i| i.path.clone())
570                } else {
571                    None
572                }
573            }
574            View::Duplicates => {
575                if let Some(dups) = &self.duplicates {
576                    dups.groups
577                        .get(self.selected_dup_group)
578                        .and_then(|g| g.paths.first().cloned())
579                } else {
580                    None
581                }
582            }
583            View::Age => {
584                if let Some(age) = &self.age_report {
585                    age.stale_directories
586                        .get(self.selected_stale_dir)
587                        .map(|d| d.path.clone())
588                } else {
589                    None
590                }
591            }
592            View::Errors => {
593                // Errors view doesn't support marking for deletion
594                None
595            }
596        };
597
598        if let Some(path) = path {
599            // Don't allow marking the root path
600            if path != self.path {
601                if self.marked_for_deletion.contains(&path) {
602                    self.marked_for_deletion.remove(&path);
603                } else {
604                    self.marked_for_deletion.insert(path);
605                }
606            }
607        }
608    }
609
610    /// Execute deletion of all marked items.
611    fn execute_deletion(&mut self) {
612        let mut deleted = 0;
613        let mut failed = 0;
614        let mut bytes_freed: u64 = 0;
615
616        // Collect paths and sizes before draining
617        let paths_with_sizes: Vec<(PathBuf, u64)> = self
618            .marked_for_deletion
619            .iter()
620            .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
621            .collect();
622
623        self.marked_for_deletion.clear();
624
625        for (path, size) in paths_with_sizes {
626            let result = if path.is_dir() {
627                fs::remove_dir_all(&path)
628            } else {
629                fs::remove_file(&path)
630            };
631
632            match result {
633                Ok(()) => {
634                    deleted += 1;
635                    bytes_freed += size;
636                }
637                Err(_) => {
638                    failed += 1;
639                }
640            }
641        }
642
643        let msg = if failed == 0 {
644            format!(
645                "Deleted {} items, freed {}",
646                deleted,
647                format_size(bytes_freed)
648            )
649        } else {
650            format!(
651                "Deleted {}, failed {} (freed {})",
652                deleted,
653                failed,
654                format_size(bytes_freed)
655            )
656        };
657
658        self.deletion_message = Some((failed == 0, msg));
659
660        // Refresh scan after deletion
661        self.start_scan();
662    }
663
664    /// Get the size of a path from the tree.
665    fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
666        let tree = self.tree.as_ref()?;
667
668        fn find_size(
669            node: &gravityfile_core::FileNode,
670            target: &PathBuf,
671            current: &PathBuf,
672        ) -> Option<u64> {
673            if current == target {
674                return Some(node.size);
675            }
676            for child in &node.children {
677                let child_path = current.join(&*child.name);
678                if let Some(size) = find_size(child, target, &child_path) {
679                    return Some(size);
680                }
681            }
682            None
683        }
684
685        find_size(&tree.root, target, &tree.root_path)
686    }
687
688    fn move_up(&mut self) {
689        match self.view {
690            View::Explorer => self.tree_state.move_up(1),
691            View::Duplicates => {
692                self.selected_dup_group = self.selected_dup_group.saturating_sub(1);
693            }
694            View::Age => {
695                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
696            }
697            View::Errors => {
698                self.selected_warning = self.selected_warning.saturating_sub(1);
699            }
700        }
701    }
702
703    fn move_down(&mut self) {
704        match self.view {
705            View::Explorer => {
706                self.tree_state.move_down(1, self.cached_tree_len);
707            }
708            View::Duplicates => {
709                if let Some(dups) = &self.duplicates {
710                    let max = dups.groups.len().saturating_sub(1);
711                    self.selected_dup_group = (self.selected_dup_group + 1).min(max);
712                }
713            }
714            View::Age => {
715                if let Some(age) = &self.age_report {
716                    let max = age.stale_directories.len().saturating_sub(1);
717                    self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
718                }
719            }
720            View::Errors => {
721                let max = self.warnings.len().saturating_sub(1);
722                self.selected_warning = (self.selected_warning + 1).min(max);
723            }
724        }
725    }
726
727    fn page_up(&mut self) {
728        match self.view {
729            View::Explorer => self.tree_state.move_up(10),
730            View::Duplicates => {
731                self.selected_dup_group = self.selected_dup_group.saturating_sub(10);
732            }
733            View::Age => {
734                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(10);
735            }
736            View::Errors => {
737                self.selected_warning = self.selected_warning.saturating_sub(10);
738            }
739        }
740    }
741
742    fn page_down(&mut self) {
743        match self.view {
744            View::Explorer => {
745                self.tree_state.move_down(10, self.cached_tree_len);
746            }
747            View::Duplicates => {
748                if let Some(dups) = &self.duplicates {
749                    let max = dups.groups.len().saturating_sub(1);
750                    self.selected_dup_group = (self.selected_dup_group + 10).min(max);
751                }
752            }
753            View::Age => {
754                if let Some(age) = &self.age_report {
755                    let max = age.stale_directories.len().saturating_sub(1);
756                    self.selected_stale_dir = (self.selected_stale_dir + 10).min(max);
757                }
758            }
759            View::Errors => {
760                let max = self.warnings.len().saturating_sub(1);
761                self.selected_warning = (self.selected_warning + 10).min(max);
762            }
763        }
764    }
765
766    fn jump_to_top(&mut self) {
767        match self.view {
768            View::Explorer => self.tree_state.jump_to_top(),
769            View::Duplicates => self.selected_dup_group = 0,
770            View::Age => self.selected_stale_dir = 0,
771            View::Errors => self.selected_warning = 0,
772        }
773    }
774
775    fn jump_to_bottom(&mut self) {
776        match self.view {
777            View::Explorer => {
778                self.tree_state.jump_to_bottom(self.cached_tree_len);
779            }
780            View::Duplicates => {
781                if let Some(dups) = &self.duplicates {
782                    self.selected_dup_group = dups.groups.len().saturating_sub(1);
783                }
784            }
785            View::Age => {
786                if let Some(age) = &self.age_report {
787                    self.selected_stale_dir = age.stale_directories.len().saturating_sub(1);
788                }
789            }
790            View::Errors => {
791                self.selected_warning = self.warnings.len().saturating_sub(1);
792            }
793        }
794    }
795
796    fn expand_selected(&mut self) {
797        if let Some((node, root_path)) = self.get_view_root_node() {
798            let items =
799                TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
800            if let Some(item) = items.get(self.tree_state.selected) {
801                self.tree_state.expand(&item.path);
802                self.update_cached_tree_len();
803            }
804        }
805    }
806
807    fn collapse_selected(&mut self) {
808        if let Some((node, root_path)) = self.get_view_root_node() {
809            let items =
810                TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
811            if let Some(item) = items.get(self.tree_state.selected) {
812                self.tree_state.collapse(&item.path);
813                self.update_cached_tree_len();
814            }
815        }
816    }
817
818    fn toggle_selected(&mut self) {
819        if let Some((node, root_path)) = self.get_view_root_node() {
820            let items =
821                TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
822            if let Some(item) = items.get(self.tree_state.selected) {
823                self.tree_state.toggle_expand(&item.path);
824                self.update_cached_tree_len();
825            }
826        }
827    }
828
829    fn get_selected_info(&self) -> Option<SelectedInfo> {
830        let tree = self.tree.as_ref()?;
831        let (view_node, view_path) = self.get_view_root_node()?;
832        let items =
833            TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
834        let item = items.get(self.tree_state.selected)?;
835
836        // Find node in full tree for complete info
837        let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
838
839        Some(SelectedInfo {
840            name: item.node.name.clone(),
841            path: item.path.clone(),
842            size: node.size,
843            file_count: node.file_count(),
844            dir_count: node.dir_count(),
845            modified: node.timestamps.modified,
846            is_dir: node.is_dir(),
847        })
848    }
849
850    /// Drill into the currently selected directory.
851    fn drill_into_selected(&mut self) {
852        let Some((view_node, view_path)) = self.get_view_root_node() else { return };
853
854        // Get the selected item
855        let items = TreeView::new(view_node, &view_path, &self.theme)
856            .flatten(&self.tree_state);
857        let Some(item) = items.get(self.tree_state.selected) else { return };
858
859        // Only drill into directories
860        if !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
861            return;
862        }
863
864        // Don't drill into if already at view root (can use expand instead)
865        if item.path == self.view_root {
866            // Just expand instead
867            self.tree_state.expand(&item.path);
868            self.update_cached_tree_len();
869            return;
870        }
871
872        // Save the entire state before drilling: view_root, selected index, and all expanded paths
873        let saved_expanded = self.tree_state.expanded.clone();
874        let saved_selected = self.tree_state.selected;
875        self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
876
877        // Set new view root
878        self.view_root = item.path.clone();
879
880        // Reset selection and expand the new root
881        self.tree_state.selected = 0;
882        self.tree_state.offset = 0;
883        self.tree_state.expand(&self.view_root);
884        self.update_cached_tree_len();
885    }
886
887    /// Navigate back up to the previous view root.
888    fn navigate_back(&mut self) {
889        // If we have history, restore the entire saved state
890        if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
891            // Restore view root
892            self.view_root = prev_root;
893
894            // Restore the entire expanded set (this preserves exactly what was open/closed)
895            self.tree_state.expanded = saved_expanded;
896
897            // Restore selection position
898            self.tree_state.selected = saved_selected;
899            self.tree_state.offset = 0;
900            self.update_cached_tree_len();
901        } else if self.view_root != self.path {
902            // No history but view_root differs from scan root - go to parent
903            if let Some(parent) = self.view_root.parent() {
904                // Check if parent is within or equal to scan root
905                if parent.starts_with(&self.path) || parent == self.path {
906                    // Collapse the current directory when backing out
907                    let dir_we_drilled_into = self.view_root.clone();
908                    self.tree_state.collapse(&dir_we_drilled_into);
909
910                    self.view_root = parent.to_path_buf();
911                    self.tree_state.expand(&self.view_root);
912                    self.tree_state.selected = 0;
913                    self.tree_state.offset = 0;
914                    self.update_cached_tree_len();
915                }
916            }
917        }
918    }
919
920    /// Find a node by path in the tree.
921    fn find_node_at_path<'a>(
922        node: &'a gravityfile_core::FileNode,
923        target_path: &PathBuf,
924        current_path: &PathBuf,
925    ) -> Option<&'a gravityfile_core::FileNode> {
926        if current_path == target_path {
927            return Some(node);
928        }
929        for child in &node.children {
930            let child_path = current_path.join(&*child.name);
931            if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
932                return Some(found);
933            }
934        }
935        None
936    }
937
938    /// Get the current view root node (for drill-down rendering).
939    fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
940        let tree = self.tree.as_ref()?;
941        let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
942        Some((node, self.view_root.clone()))
943    }
944
945    /// Handle raw key input for command mode.
946    fn handle_command_input(&mut self, key: KeyEvent) {
947        match (key.code, key.modifiers) {
948            // Execute command on Enter
949            (KeyCode::Enter, _) => {
950                let cmd = self.command_input.clone();
951                self.command_input.clear();
952                self.command_cursor = 0;
953                self.mode = AppMode::Normal;
954                self.execute_command(&cmd);
955            }
956            // Cancel on Escape
957            (KeyCode::Esc, _) => {
958                self.command_input.clear();
959                self.command_cursor = 0;
960                self.mode = AppMode::Normal;
961            }
962            // Delete char before cursor
963            (KeyCode::Backspace, _) => {
964                if self.command_cursor > 0 {
965                    self.command_cursor -= 1;
966                    self.command_input.remove(self.command_cursor);
967                } else if self.command_input.is_empty() {
968                    // Exit command mode if input is empty and backspace pressed
969                    self.mode = AppMode::Normal;
970                }
971            }
972            // Delete char at cursor
973            (KeyCode::Delete, _) => {
974                if self.command_cursor < self.command_input.len() {
975                    self.command_input.remove(self.command_cursor);
976                }
977            }
978            // Move cursor
979            (KeyCode::Left, _) => {
980                self.command_cursor = self.command_cursor.saturating_sub(1);
981            }
982            (KeyCode::Right, _) => {
983                self.command_cursor = (self.command_cursor + 1).min(self.command_input.len());
984            }
985            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
986                self.command_cursor = 0;
987            }
988            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
989                self.command_cursor = self.command_input.len();
990            }
991            // Clear line
992            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
993                self.command_input.clear();
994                self.command_cursor = 0;
995            }
996            // Type character
997            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
998                self.command_input.insert(self.command_cursor, c);
999                self.command_cursor += 1;
1000            }
1001            _ => {}
1002        }
1003    }
1004
1005    /// Execute a command from the command palette.
1006    fn execute_command(&mut self, cmd: &str) {
1007        let parts: Vec<&str> = cmd.trim().split_whitespace().collect();
1008        if parts.is_empty() {
1009            return;
1010        }
1011
1012        match parts[0] {
1013            // Quit commands
1014            "q" | "quit" | "exit" => {
1015                self.mode = AppMode::Quit;
1016            }
1017            // Refresh/rescan
1018            "r" | "refresh" | "rescan" => {
1019                self.marked_for_deletion.clear();
1020                self.start_scan();
1021            }
1022            // Change directory (cd path)
1023            "cd" => {
1024                if parts.len() > 1 {
1025                    let target = parts[1..].join(" ");
1026                    self.cd_to_path(&target);
1027                } else {
1028                    // cd with no args goes to scan root
1029                    self.go_to_root();
1030                }
1031            }
1032            // Go to root
1033            "root" | "top" => {
1034                self.go_to_root();
1035            }
1036            // Go back
1037            "back" | "up" | ".." => {
1038                self.navigate_back();
1039            }
1040            // Help
1041            "help" | "?" => {
1042                self.mode = AppMode::Help;
1043            }
1044            // View switching
1045            "explorer" | "e" | "tree" => {
1046                self.view = View::Explorer;
1047            }
1048            "duplicates" | "dups" | "d" => {
1049                self.view = View::Duplicates;
1050            }
1051            "age" | "a" => {
1052                self.view = View::Age;
1053            }
1054            "errors" | "err" => {
1055                self.view = View::Errors;
1056            }
1057            // Clear marks
1058            "clear" | "unmark" => {
1059                self.marked_for_deletion.clear();
1060            }
1061            // Toggle details
1062            "details" | "info" | "i" => {
1063                self.show_details = !self.show_details;
1064            }
1065            // Theme commands
1066            "theme" | "t" => {
1067                if parts.len() > 1 {
1068                    match parts[1] {
1069                        "dark" => self.theme = Theme::dark(),
1070                        "light" => self.theme = Theme::light(),
1071                        "toggle" => self.theme = self.theme.toggle(),
1072                        _ => {}
1073                    }
1074                } else {
1075                    // Toggle by default
1076                    self.theme = self.theme.toggle();
1077                }
1078            }
1079            "dark" => {
1080                self.theme = Theme::dark();
1081            }
1082            "light" => {
1083                self.theme = Theme::light();
1084            }
1085            _ => {
1086                // Unknown command - could show error message
1087            }
1088        }
1089    }
1090
1091    /// Go to the scan root (resetting navigation history).
1092    fn go_to_root(&mut self) {
1093        self.view_root = self.path.clone();
1094        self.view_history.clear();
1095        self.tree_state.selected = 0;
1096        self.tree_state.offset = 0;
1097        self.tree_state.expand(&self.view_root);
1098        self.update_cached_tree_len();
1099    }
1100
1101    /// Change directory to a path.
1102    fn cd_to_path(&mut self, target: &str) {
1103        // Handle special navigation commands first (don't need tree)
1104        if target == "/" || target == "~" {
1105            self.go_to_root();
1106            return;
1107        }
1108        if target == ".." {
1109            self.navigate_back();
1110            return;
1111        }
1112
1113        // For other paths, we need the tree to verify the target exists
1114        let Some(tree) = &self.tree else { return };
1115
1116        let target_path = if target.starts_with('/') {
1117            // Absolute path (but constrained to scan root)
1118            let relative = target.trim_start_matches('/');
1119            self.path.join(relative)
1120        } else {
1121            // Relative path from current view_root
1122            self.view_root.join(target)
1123        };
1124
1125        // Verify the target exists in the tree and is a directory
1126        if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
1127            if node.is_dir() {
1128                // Save current state before navigating
1129                let saved_expanded = self.tree_state.expanded.clone();
1130                let saved_selected = self.tree_state.selected;
1131                self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1132
1133                self.view_root = target_path;
1134                self.tree_state.selected = 0;
1135                self.tree_state.offset = 0;
1136                self.tree_state.expand(&self.view_root);
1137                self.update_cached_tree_len();
1138            }
1139        }
1140    }
1141}
1142
1143struct SelectedInfo {
1144    name: String,
1145    path: PathBuf,
1146    size: u64,
1147    file_count: u64,
1148    dir_count: u64,
1149    modified: std::time::SystemTime,
1150    is_dir: bool,
1151}
1152
1153impl Widget for &App {
1154    fn render(self, area: Rect, buf: &mut Buffer) {
1155        // Fill entire area with theme background color
1156        let base_style = Style::default().bg(self.theme.background).fg(self.theme.foreground);
1157        buf.set_style(area, base_style);
1158
1159        // Layout: header, tabs, content, footer
1160        let [header, tabs_area, content, footer] = Layout::vertical([
1161            Constraint::Length(1),
1162            Constraint::Length(1),
1163            Constraint::Min(10),
1164            Constraint::Length(1),
1165        ])
1166        .areas(area);
1167
1168        // Render header
1169        self.render_header(header, buf);
1170
1171        // Render tabs
1172        self.render_tabs(tabs_area, buf);
1173
1174        // Render content based on mode and view
1175        // Show scan content if scanning is in progress (even if in Help mode overlay)
1176        let is_scanning = self.scan_progress.is_some() && self.tree.is_none();
1177
1178        if is_scanning {
1179            match self.scan_view {
1180                ScanView::Progress => self.render_scanning(content, buf),
1181                ScanView::Errors => self.render_errors(content, buf),
1182            }
1183        } else {
1184            match self.view {
1185                View::Explorer => self.render_explorer(content, buf),
1186                View::Duplicates => self.render_duplicates(content, buf),
1187                View::Age => self.render_age(content, buf),
1188                View::Errors => self.render_errors(content, buf),
1189            }
1190        }
1191
1192        // Render footer
1193        self.render_footer(footer, buf);
1194
1195        // Render overlays
1196        match self.mode {
1197            AppMode::Help => {
1198                HelpOverlay::new(&self.theme).render(area, buf);
1199            }
1200            AppMode::ConfirmDelete => {
1201                self.render_delete_confirmation(area, buf);
1202            }
1203            AppMode::Command => {
1204                self.render_command_palette(footer, buf);
1205            }
1206            _ => {}
1207        }
1208    }
1209}
1210
1211impl App {
1212    fn render_header(&self, area: Rect, buf: &mut Buffer) {
1213        let title = Span::styled(
1214            " gravityfile ",
1215            self.theme.title.add_modifier(Modifier::BOLD),
1216        );
1217
1218        let stats = if let Some(tree) = &self.tree {
1219            format!(
1220                " {} in {} files, {} dirs ",
1221                format_size(tree.stats.total_size),
1222                tree.stats.total_files,
1223                tree.stats.total_dirs
1224            )
1225        } else {
1226            String::new()
1227        };
1228
1229        let stats_span = Span::styled(stats, self.theme.header);
1230
1231        // Show marked items count or deletion message
1232        let status = if let Some((success, msg)) = &self.deletion_message {
1233            let color = if *success {
1234                self.theme.success
1235            } else {
1236                self.theme.warning
1237            };
1238            Span::styled(format!(" {} ", msg), Style::default().fg(color))
1239        } else if !self.marked_for_deletion.is_empty() {
1240            let total_size: u64 = self
1241                .marked_for_deletion
1242                .iter()
1243                .filter_map(|p| self.get_path_size(p))
1244                .sum();
1245            Span::styled(
1246                format!(
1247                    " {} marked ({}) [Enter to delete] ",
1248                    self.marked_for_deletion.len(),
1249                    format_size(total_size)
1250                ),
1251                Style::default().fg(self.theme.warning),
1252            )
1253        } else {
1254            Span::raw("")
1255        };
1256
1257        let line = Line::from(vec![title, Span::raw(" "), stats_span, status]);
1258
1259        Paragraph::new(line)
1260            .style(self.theme.header)
1261            .render(area, buf);
1262    }
1263
1264    fn render_delete_confirmation(&self, area: Rect, buf: &mut Buffer) {
1265        // Calculate popup area
1266        let popup_width = 60.min(area.width.saturating_sub(4));
1267        let popup_height = (self.marked_for_deletion.len() as u16 + 8).min(area.height.saturating_sub(4));
1268
1269        let popup_x = (area.width.saturating_sub(popup_width)) / 2 + area.x;
1270        let popup_y = (area.height.saturating_sub(popup_height)) / 2 + area.y;
1271
1272        let popup_area = Rect::new(popup_x, popup_y, popup_width, popup_height);
1273
1274        // Clear the popup area
1275        Clear.render(popup_area, buf);
1276
1277        // Draw border
1278        let block = Block::default()
1279            .title(" Confirm Deletion ")
1280            .title_style(Style::default().fg(self.theme.error).add_modifier(Modifier::BOLD))
1281            .borders(Borders::ALL)
1282            .border_style(Style::default().fg(self.theme.error));
1283
1284        let inner = block.inner(popup_area);
1285        block.render(popup_area, buf);
1286
1287        // Calculate total size
1288        let total_size: u64 = self
1289            .marked_for_deletion
1290            .iter()
1291            .filter_map(|p| self.get_path_size(p))
1292            .sum();
1293
1294        // Build content
1295        let mut lines = vec![
1296            Line::styled(
1297                format!(
1298                    "Delete {} items ({})? This cannot be undone!",
1299                    self.marked_for_deletion.len(),
1300                    format_size(total_size)
1301                ),
1302                Style::default().fg(self.theme.warning).add_modifier(Modifier::BOLD),
1303            ),
1304            Line::raw(""),
1305        ];
1306
1307        // List items (limited)
1308        let max_items = (inner.height as usize).saturating_sub(5);
1309        for path in self.marked_for_deletion.iter().take(max_items) {
1310            let name = path
1311                .file_name()
1312                .map(|n| n.to_string_lossy().to_string())
1313                .unwrap_or_else(|| path.display().to_string());
1314            let prefix = if path.is_dir() { "📁 " } else { "📄 " };
1315            lines.push(Line::raw(format!("  {}{}", prefix, name)));
1316        }
1317
1318        if self.marked_for_deletion.len() > max_items {
1319            lines.push(Line::styled(
1320                format!("  ... and {} more", self.marked_for_deletion.len() - max_items),
1321                Style::default().fg(self.theme.muted),
1322            ));
1323        }
1324
1325        lines.push(Line::raw(""));
1326        lines.push(Line::from(vec![
1327            Span::styled(" y/Enter ", self.theme.help_key),
1328            Span::raw("Confirm  "),
1329            Span::styled(" n/Esc ", self.theme.help_key),
1330            Span::raw("Cancel"),
1331        ]));
1332
1333        Paragraph::new(lines).render(inner, buf);
1334    }
1335
1336    fn render_tabs(&self, area: Rect, buf: &mut Buffer) {
1337        // Show scan tabs if scanning is in progress (even if in Help mode)
1338        let is_scanning = self.scan_progress.is_some() && self.tree.is_none();
1339
1340        if is_scanning {
1341            // During scan, show only Progress and Errors tabs
1342            // Use scan_progress.errors_count since warnings aren't populated until scan completes
1343            let error_count = self.scan_progress
1344                .as_ref()
1345                .map(|p| p.errors_count)
1346                .unwrap_or(0);
1347            let titles = vec![
1348                " Progress ".to_string(),
1349                if error_count > 0 {
1350                    format!(" Errors ({}) ", error_count)
1351                } else {
1352                    " Errors ".to_string()
1353                },
1354            ];
1355
1356            let tabs = Tabs::new(titles)
1357                .select(self.scan_view as usize)
1358                .style(self.theme.footer)
1359                .highlight_style(self.theme.selected);
1360
1361            tabs.render(area, buf);
1362        } else {
1363            // Normal mode - show all tabs
1364            let titles: Vec<String> = View::iter().map(|v| format!(" {} ", v)).collect();
1365
1366            let tabs = Tabs::new(titles)
1367                .select(self.view as usize)
1368                .style(self.theme.footer)
1369                .highlight_style(self.theme.selected);
1370
1371            tabs.render(area, buf);
1372        }
1373    }
1374
1375    fn render_scanning(&self, area: Rect, buf: &mut Buffer) {
1376        let block = Block::default()
1377            .borders(Borders::ALL)
1378            .border_style(self.theme.border)
1379            .title(format!(" Scanning {} ", self.path.display()))
1380            .title_style(self.theme.title);
1381
1382        let inner = block.inner(area);
1383        block.render(area, buf);
1384
1385        let mut lines = vec![
1386            Line::raw(""),
1387            Line::styled(
1388                "  Scanning directory...",
1389                Style::default().fg(self.theme.info).add_modifier(Modifier::BOLD),
1390            ),
1391            Line::raw(""),
1392        ];
1393
1394        if let Some(progress) = &self.scan_progress {
1395            lines.push(Line::from(vec![
1396                Span::styled("  Files: ", self.theme.help_desc),
1397                Span::raw(progress.files_scanned.to_string()),
1398            ]));
1399            lines.push(Line::from(vec![
1400                Span::styled("  Dirs:  ", self.theme.help_desc),
1401                Span::raw(progress.dirs_scanned.to_string()),
1402            ]));
1403            lines.push(Line::from(vec![
1404                Span::styled("  Size:  ", self.theme.help_desc),
1405                Span::raw(format_size(progress.bytes_scanned)),
1406            ]));
1407
1408            if progress.errors_count > 0 {
1409                lines.push(Line::from(vec![
1410                    Span::styled("  Errors: ", Style::default().fg(self.theme.warning)),
1411                    Span::styled(
1412                        progress.errors_count.to_string(),
1413                        Style::default().fg(self.theme.warning),
1414                    ),
1415                ]));
1416            }
1417
1418            // Show current path (truncated)
1419            let current = progress.current_path.display().to_string();
1420            let max_width = inner.width.saturating_sub(4) as usize;
1421            let display_path = if current.len() > max_width {
1422                format!("...{}", &current[current.len().saturating_sub(max_width - 3)..])
1423            } else {
1424                current
1425            };
1426            lines.push(Line::raw(""));
1427            lines.push(Line::styled(
1428                format!("  {}", display_path),
1429                Style::default().fg(self.theme.muted),
1430            ));
1431        }
1432
1433        Paragraph::new(lines).render(inner, buf);
1434    }
1435
1436    fn render_explorer(&self, area: Rect, buf: &mut Buffer) {
1437        let layout = AppLayout::new(area, self.show_details);
1438
1439        if let Some((view_node, view_path)) = self.get_view_root_node() {
1440            // Build title showing navigation context
1441            let title = if self.view_root != self.path {
1442                // Show breadcrumb-style path when drilled in
1443                let relative = self.view_root.strip_prefix(&self.path)
1444                    .map(|p| p.display().to_string())
1445                    .unwrap_or_else(|_| self.view_root.display().to_string());
1446                format!(" {} (← Backspace) ", relative)
1447            } else {
1448                format!(" {} ", view_path.display())
1449            };
1450
1451            let tree_view = TreeView::new(view_node, &view_path, &self.theme).block(
1452                Block::default()
1453                    .borders(Borders::ALL)
1454                    .border_style(self.theme.border)
1455                    .title(title)
1456                    .title_style(self.theme.title),
1457            );
1458
1459            let mut tree_state = self.tree_state.clone();
1460            ratatui::widgets::StatefulWidget::render(tree_view, layout.main, buf, &mut tree_state);
1461        } else if let Some(error) = &self.error {
1462            let error_block = Block::default()
1463                .borders(Borders::ALL)
1464                .border_style(self.theme.border)
1465                .title(" Error ")
1466                .title_style(Style::default().fg(self.theme.error));
1467
1468            let error_text = Paragraph::new(error.as_str())
1469                .block(error_block)
1470                .style(Style::default().fg(self.theme.error));
1471
1472            error_text.render(layout.main, buf);
1473        }
1474
1475        if let Some(details_area) = layout.details {
1476            self.render_details(details_area, buf);
1477        }
1478    }
1479
1480    fn render_duplicates(&self, area: Rect, buf: &mut Buffer) {
1481        let block = Block::default()
1482            .borders(Borders::ALL)
1483            .border_style(self.theme.border)
1484            .title(" Duplicates ")
1485            .title_style(self.theme.title);
1486
1487        let inner = block.inner(area);
1488        block.render(area, buf);
1489
1490        if let Some(dups) = &self.duplicates {
1491            if dups.groups.is_empty() {
1492                let msg = Paragraph::new("No duplicate files found.")
1493                    .style(Style::default().fg(self.theme.muted));
1494                msg.render(inner, buf);
1495                return;
1496            }
1497
1498            // Header
1499            let header = format!(
1500                " {} groups, {} wasted",
1501                dups.group_count,
1502                format_size(dups.total_wasted_space)
1503            );
1504            let header_line = Line::styled(header, self.theme.title);
1505            let header_area = Rect::new(inner.x, inner.y, inner.width, 1);
1506            Paragraph::new(header_line).render(header_area, buf);
1507
1508            // List of groups
1509            let list_area = Rect::new(inner.x, inner.y + 2, inner.width, inner.height.saturating_sub(2));
1510            let visible_height = list_area.height as usize;
1511
1512            // Calculate scroll offset
1513            let scroll_offset = if self.selected_dup_group >= visible_height {
1514                self.selected_dup_group - visible_height + 1
1515            } else {
1516                0
1517            };
1518
1519            for (i, group) in dups.groups.iter().enumerate().skip(scroll_offset).take(visible_height) {
1520                let y = list_area.y + (i - scroll_offset) as u16;
1521                let is_selected = i == self.selected_dup_group;
1522
1523                let line = format!(
1524                    " {} files, {} each ({} wasted)",
1525                    group.count(),
1526                    format_size(group.size),
1527                    format_size(group.wasted_bytes)
1528                );
1529
1530                let style = if is_selected {
1531                    self.theme.selected
1532                } else {
1533                    Style::default()
1534                };
1535
1536                let line = Line::styled(line, style);
1537                let line_area = Rect::new(list_area.x, y, list_area.width, 1);
1538                Paragraph::new(line).render(line_area, buf);
1539            }
1540        } else {
1541            let msg = Paragraph::new("Analyzing duplicates...")
1542                .style(Style::default().fg(self.theme.muted));
1543            msg.render(inner, buf);
1544        }
1545    }
1546
1547    fn render_age(&self, area: Rect, buf: &mut Buffer) {
1548        let block = Block::default()
1549            .borders(Borders::ALL)
1550            .border_style(self.theme.border)
1551            .title(" Age Analysis ")
1552            .title_style(self.theme.title);
1553
1554        let inner = block.inner(area);
1555        block.render(area, buf);
1556
1557        if let Some(age) = &self.age_report {
1558            // Age distribution chart
1559            let max_size = age.buckets.iter().map(|b| b.total_size).max().unwrap_or(1);
1560            let chart_height = age.buckets.len().min(inner.height as usize / 2);
1561
1562            for (i, bucket) in age.buckets.iter().enumerate().take(chart_height) {
1563                let y = inner.y + i as u16;
1564                let bar_width = if max_size > 0 {
1565                    ((bucket.total_size as f64 / max_size as f64) * 20.0) as usize
1566                } else {
1567                    0
1568                };
1569
1570                let bar = "█".repeat(bar_width);
1571                let line = format!(
1572                    " {:<12} {:>10} {:>8} files  {}",
1573                    bucket.name,
1574                    format_size(bucket.total_size),
1575                    bucket.file_count,
1576                    bar
1577                );
1578
1579                let line_area = Rect::new(inner.x, y, inner.width, 1);
1580                Paragraph::new(line).render(line_area, buf);
1581            }
1582
1583            // Stale directories header
1584            let stale_y = inner.y + chart_height as u16 + 1;
1585            if stale_y < inner.y + inner.height {
1586                let stale_header = if age.stale_directories.is_empty() {
1587                    " No stale directories found.".to_string()
1588                } else {
1589                    format!(
1590                        " Stale Directories ({}, {} total)",
1591                        age.stale_directories.len(),
1592                        format_size(age.total_stale_size())
1593                    )
1594                };
1595                let header_area = Rect::new(inner.x, stale_y, inner.width, 1);
1596                Paragraph::new(Line::styled(stale_header, self.theme.title)).render(header_area, buf);
1597
1598                // List stale directories
1599                let list_y = stale_y + 1;
1600                let list_height = (inner.y + inner.height).saturating_sub(list_y) as usize;
1601
1602                for (i, dir) in age.stale_directories.iter().enumerate().take(list_height) {
1603                    let y = list_y + i as u16;
1604                    let is_selected = i == self.selected_stale_dir;
1605
1606                    let line = format!(
1607                        "   {} ({}, {} old)",
1608                        dir.path.file_name().map(|n| n.to_string_lossy()).unwrap_or_default(),
1609                        format_size(dir.size),
1610                        gravityfile_analyze::format_age(dir.newest_file_age)
1611                    );
1612
1613                    let style = if is_selected {
1614                        self.theme.selected
1615                    } else {
1616                        Style::default()
1617                    };
1618
1619                    let line_area = Rect::new(inner.x, y, inner.width, 1);
1620                    Paragraph::new(Line::styled(line, style)).render(line_area, buf);
1621                }
1622            }
1623        } else {
1624            let msg = Paragraph::new("Analyzing file ages...")
1625                .style(Style::default().fg(self.theme.muted));
1626            msg.render(inner, buf);
1627        }
1628    }
1629
1630    fn render_errors(&self, area: Rect, buf: &mut Buffer) {
1631        let title = if self.mode == AppMode::Scanning {
1632            // During scan, use error count from progress since warnings aren't populated yet
1633            let count = self.scan_progress
1634                .as_ref()
1635                .map(|p| p.errors_count)
1636                .unwrap_or(0);
1637            format!(" Errors & Warnings ({}) ", count)
1638        } else if !self.warnings.is_empty() {
1639            format!(" Scan Errors & Warnings ({}) ", self.warnings.len())
1640        } else {
1641            " Scan Errors & Warnings ".to_string()
1642        };
1643
1644        let block = Block::default()
1645            .borders(Borders::ALL)
1646            .border_style(self.theme.border)
1647            .title(title)
1648            .title_style(self.theme.title);
1649
1650        let inner = block.inner(area);
1651        block.render(area, buf);
1652
1653        if self.warnings.is_empty() {
1654            // During scanning, warnings aren't available yet - they come with the tree
1655            let lines = if self.mode == AppMode::Scanning {
1656                let error_count = self.scan_progress
1657                    .as_ref()
1658                    .map(|p| p.errors_count)
1659                    .unwrap_or(0);
1660                if error_count > 0 {
1661                    vec![
1662                        Line::raw(""),
1663                        Line::styled(
1664                            format!("  {} errors encountered during scan", error_count),
1665                            Style::default().fg(self.theme.warning),
1666                        ),
1667                        Line::raw(""),
1668                        Line::styled(
1669                            "  Error details will be available after scan completes.",
1670                            Style::default().fg(self.theme.muted),
1671                        ),
1672                    ]
1673                } else {
1674                    vec![Line::styled(
1675                        "No errors yet...",
1676                        Style::default().fg(self.theme.muted),
1677                    )]
1678                }
1679            } else {
1680                vec![Line::styled(
1681                    "No errors or warnings during scan.",
1682                    Style::default().fg(self.theme.muted),
1683                )]
1684            };
1685            Paragraph::new(lines).render(inner, buf);
1686            return;
1687        }
1688
1689        // Each warning takes 2 lines: kind + path on first, message on second
1690        let lines_per_item = 2;
1691        let visible_items = inner.height as usize / lines_per_item;
1692
1693        // Calculate scroll offset
1694        let scroll_offset = if self.selected_warning >= visible_items {
1695            self.selected_warning - visible_items + 1
1696        } else {
1697            0
1698        };
1699
1700        for (i, warning) in self.warnings.iter().enumerate().skip(scroll_offset).take(visible_items) {
1701            let base_y = inner.y + ((i - scroll_offset) * lines_per_item) as u16;
1702            let is_selected = i == self.selected_warning;
1703
1704            // Kind label with icon
1705            let (icon, kind_label) = match warning.kind {
1706                gravityfile_core::WarningKind::PermissionDenied => ("🔒", "Permission Denied"),
1707                gravityfile_core::WarningKind::BrokenSymlink => ("🔗", "Broken Symlink"),
1708                gravityfile_core::WarningKind::ReadError => ("⚠", "Read Error"),
1709                gravityfile_core::WarningKind::MetadataError => ("📋", "Metadata Error"),
1710                gravityfile_core::WarningKind::CrossFilesystem => ("💾", "Cross Filesystem"),
1711            };
1712
1713            // First line: icon + kind + path
1714            let path_str = warning.path.display().to_string();
1715            let prefix = format!(" {} {} ", icon, kind_label);
1716            let available_width = (inner.width as usize).saturating_sub(prefix.len() + 1);
1717            let display_path = if path_str.len() > available_width {
1718                format!("...{}", &path_str[path_str.len().saturating_sub(available_width - 3)..])
1719            } else {
1720                path_str
1721            };
1722
1723            let style = if is_selected {
1724                self.theme.selected
1725            } else {
1726                Style::default().fg(self.theme.warning)
1727            };
1728
1729            let line1 = Line::from(vec![
1730                Span::styled(prefix, style),
1731                Span::styled(display_path, style),
1732            ]);
1733            let line1_area = Rect::new(inner.x, base_y, inner.width, 1);
1734            Paragraph::new(line1).render(line1_area, buf);
1735
1736            // Second line: message (indented, muted unless selected)
1737            if base_y + 1 < inner.y + inner.height {
1738                let msg_style = if is_selected {
1739                    self.theme.selected
1740                } else {
1741                    Style::default().fg(self.theme.muted)
1742                };
1743
1744                // Truncate message if too long
1745                let max_msg_len = (inner.width as usize).saturating_sub(4);
1746                let msg = if warning.message.len() > max_msg_len {
1747                    format!("{}...", &warning.message[..max_msg_len.saturating_sub(3)])
1748                } else {
1749                    warning.message.clone()
1750                };
1751
1752                let line2 = Line::styled(format!("    {}", msg), msg_style);
1753                let line2_area = Rect::new(inner.x, base_y + 1, inner.width, 1);
1754                Paragraph::new(line2).render(line2_area, buf);
1755            }
1756        }
1757    }
1758
1759    fn render_details(&self, area: Rect, buf: &mut Buffer) {
1760        let block = Block::default()
1761            .borders(Borders::ALL)
1762            .border_style(self.theme.border)
1763            .title(" Details ")
1764            .title_style(self.theme.title);
1765
1766        let inner = block.inner(area);
1767        block.render(area, buf);
1768
1769        if let Some(info) = self.get_selected_info() {
1770            let mut lines = vec![
1771                Line::from(Span::styled(
1772                    &info.name,
1773                    self.theme.title.add_modifier(Modifier::BOLD),
1774                )),
1775                Line::raw(""),
1776                Line::from(vec![
1777                    Span::styled("Size: ", self.theme.help_desc),
1778                    Span::raw(format_size(info.size)),
1779                ]),
1780            ];
1781
1782            if info.is_dir {
1783                lines.push(Line::from(vec![
1784                    Span::styled("Files: ", self.theme.help_desc),
1785                    Span::raw(info.file_count.to_string()),
1786                ]));
1787                lines.push(Line::from(vec![
1788                    Span::styled("Dirs: ", self.theme.help_desc),
1789                    Span::raw(info.dir_count.to_string()),
1790                ]));
1791            }
1792
1793            lines.push(Line::from(vec![
1794                Span::styled("Modified: ", self.theme.help_desc),
1795                Span::raw(format_relative_time(info.modified)),
1796            ]));
1797
1798            lines.push(Line::raw(""));
1799            lines.push(Line::styled("Path:", self.theme.help_desc));
1800
1801            let path_str = info.path.display().to_string();
1802            let max_width = inner.width.saturating_sub(2) as usize;
1803            for chunk in path_str
1804                .chars()
1805                .collect::<Vec<_>>()
1806                .chunks(max_width)
1807                .map(|c| c.iter().collect::<String>())
1808            {
1809                lines.push(Line::raw(chunk));
1810            }
1811
1812            Paragraph::new(lines).render(inner, buf);
1813        }
1814    }
1815
1816    fn render_footer(&self, area: Rect, buf: &mut Buffer) {
1817        let mut keys: Vec<(&str, &str)> = match self.view {
1818            View::Explorer => {
1819                let mut v = vec![
1820                    ("j/k", "Nav"),
1821                    ("h/l", "Fold"),
1822                    ("Enter", "Drill"),
1823                ];
1824                // Show Backspace hint when drilled in
1825                if self.view_root != self.path {
1826                    v.push(("⌫", "Back"));
1827                }
1828                v.push(("d", "Mark"));
1829                v
1830            }
1831            View::Duplicates | View::Age => vec![
1832                ("j/k", "Nav"),
1833                ("d", "Mark"),
1834            ],
1835            View::Errors => vec![
1836                ("j/k", "Nav"),
1837            ],
1838        };
1839
1840        // Add deletion-related keys if items are marked
1841        if !self.marked_for_deletion.is_empty() {
1842            keys.push(("x", "Clear"));
1843            keys.push(("y", "Delete"));
1844        }
1845
1846        keys.extend([(":", "Cmd"), ("Tab", "View"), ("?", "Help"), ("q", "Quit")]);
1847
1848        let spans: Vec<Span> = keys
1849            .iter()
1850            .flat_map(|(key, desc)| {
1851                vec![
1852                    Span::styled(format!(" {key} "), self.theme.help_key),
1853                    Span::styled(format!("{desc} "), self.theme.help_desc),
1854                ]
1855            })
1856            .collect();
1857
1858        let line = Line::from(spans);
1859
1860        Paragraph::new(line)
1861            .style(self.theme.footer)
1862            .render(area, buf);
1863    }
1864
1865    /// Render the command palette input line.
1866    fn render_command_palette(&self, area: Rect, buf: &mut Buffer) {
1867        // Clear the area first
1868        Clear.render(area, buf);
1869
1870        // Build the command line display
1871        let prompt = ":";
1872        let input = &self.command_input;
1873
1874        // Calculate cursor position for display
1875        let cursor_pos = self.command_cursor;
1876
1877        // Build the line with cursor indication
1878        let mut spans = vec![
1879            Span::styled(prompt, Style::default().fg(self.theme.info).add_modifier(Modifier::BOLD)),
1880        ];
1881
1882        if input.is_empty() {
1883            // Show placeholder
1884            spans.push(Span::styled(
1885                "type command (q, cd, help...)",
1886                Style::default().fg(self.theme.muted),
1887            ));
1888        } else {
1889            // Show input with cursor
1890            let (before, after) = input.split_at(cursor_pos.min(input.len()));
1891            spans.push(Span::raw(before));
1892
1893            if !after.is_empty() {
1894                // Show cursor under next char
1895                let (cursor_char, rest) = after.split_at(1);
1896                spans.push(Span::styled(
1897                    cursor_char,
1898                    Style::default().add_modifier(Modifier::REVERSED),
1899                ));
1900                spans.push(Span::raw(rest));
1901            } else {
1902                // Cursor at end - show block cursor
1903                spans.push(Span::styled(" ", Style::default().add_modifier(Modifier::REVERSED)));
1904            }
1905        }
1906
1907        let line = Line::from(spans);
1908        Paragraph::new(line)
1909            .style(self.theme.footer)
1910            .render(area, buf);
1911    }
1912
1913    /// Check if a path is marked for deletion.
1914    pub fn is_marked(&self, path: &PathBuf) -> bool {
1915        self.marked_for_deletion.contains(path)
1916    }
1917}