gravityfile_tui/app/
mod.rs

1//! Main application state and logic.
2
3mod commands;
4mod constants;
5mod deletion;
6mod navigation;
7mod render;
8pub mod state;
9mod scanning;
10
11use std::collections::HashSet;
12use std::path::PathBuf;
13use std::time::Duration;
14
15use crossterm::event::{Event, EventStream};
16use futures::StreamExt;
17use ratatui::buffer::Buffer;
18use ratatui::layout::Rect;
19use ratatui::widgets::Widget;
20use ratatui::{DefaultTerminal, Frame};
21use tokio::sync::mpsc;
22
23use gravityfile_analyze::{AgeReport, DuplicateReport};
24use gravityfile_core::FileTree;
25use gravityfile_scan::ScanProgress;
26
27use crate::event::KeyAction;
28use crate::theme::Theme;
29use crate::ui::{TreeState, TreeView};
30
31use self::commands::{parse_command, CommandAction, CommandInput, CommandKeyResult, ThemeCommand};
32use self::constants::{PAGE_SIZE, TICK_INTERVAL_MS};
33use self::render::{render_app, RenderContext};
34use self::state::{AppMode, DeletionProgress, ScanResult, ScanView, SelectedInfo, View};
35
36/// Application result type.
37pub type AppResult<T> = color_eyre::Result<T>;
38
39/// Main application state.
40pub struct App {
41    /// Path being analyzed (scan root).
42    path: PathBuf,
43    /// Current view root for drill-down navigation (can be different from scan root).
44    view_root: PathBuf,
45    /// Navigation history stack for going back up (path, selected_index, expanded_set).
46    view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
47    /// Current mode.
48    mode: AppMode,
49    /// Current view (normal mode).
50    view: View,
51    /// Current view during scanning.
52    scan_view: ScanView,
53    /// Color theme.
54    theme: Theme,
55    /// Scanned file tree.
56    tree: Option<FileTree>,
57    /// Tree view state.
58    tree_state: TreeState,
59    /// Cached count of visible tree items (to avoid flatten on every nav).
60    cached_tree_len: usize,
61    /// Duplicate analysis report.
62    duplicates: Option<DuplicateReport>,
63    /// Age analysis report.
64    age_report: Option<AgeReport>,
65    /// Selected duplicate group index.
66    selected_dup_group: usize,
67    /// Selected stale directory index.
68    selected_stale_dir: usize,
69    /// Show details panel.
70    show_details: bool,
71    /// Error message to display.
72    error: Option<String>,
73    /// Paths marked for deletion.
74    marked_for_deletion: HashSet<PathBuf>,
75    /// Last deletion result message.
76    deletion_message: Option<(bool, String)>,
77    /// Current deletion progress (during async deletion).
78    deletion_progress: Option<DeletionProgress>,
79    /// Current scan progress (for display during scanning).
80    scan_progress: Option<ScanProgress>,
81    /// Channel for receiving scan results.
82    scan_rx: Option<mpsc::Receiver<ScanResult>>,
83    /// Scan warnings/errors (populated in real-time during scan).
84    warnings: Vec<gravityfile_core::ScanWarning>,
85    /// Selected warning index.
86    selected_warning: usize,
87    /// Whether analysis is running.
88    analyzing: bool,
89    /// Flag indicating UI needs redraw.
90    needs_redraw: bool,
91    /// Command palette input state.
92    command_input: CommandInput,
93}
94
95impl App {
96    /// Create a new application.
97    pub fn new(path: PathBuf) -> Self {
98        Self {
99            path: path.clone(),
100            view_root: path.clone(),
101            view_history: Vec::new(),
102            mode: AppMode::default(),
103            view: View::default(),
104            scan_view: ScanView::default(),
105            theme: Theme::dark(),
106            tree: None,
107            tree_state: TreeState::new(path),
108            cached_tree_len: 0,
109            duplicates: None,
110            age_report: None,
111            selected_dup_group: 0,
112            selected_stale_dir: 0,
113            show_details: true,
114            error: None,
115            marked_for_deletion: HashSet::new(),
116            deletion_message: None,
117            deletion_progress: None,
118            scan_progress: None,
119            scan_rx: None,
120            warnings: Vec::new(),
121            selected_warning: 0,
122            analyzing: false,
123            needs_redraw: true,
124            command_input: CommandInput::new(),
125        }
126    }
127
128    /// Run the application with async event loop.
129    pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
130        // Start initial scan
131        self.start_scan();
132
133        let period = Duration::from_millis(TICK_INTERVAL_MS);
134        let mut interval = tokio::time::interval(period);
135        let mut events = EventStream::new();
136
137        while self.mode != AppMode::Quit {
138            if self.needs_redraw {
139                terminal.draw(|frame| self.render(frame))?;
140                self.needs_redraw = false;
141            }
142
143            tokio::select! {
144                biased;
145
146                Some(Ok(event)) = events.next() => {
147                    if let Event::Key(key_event) = event {
148                        if key_event.kind == crossterm::event::KeyEventKind::Press {
149                            if self.mode == AppMode::Command {
150                                self.handle_command_input(key_event);
151                            } else {
152                                let action = KeyAction::from_key_event(key_event);
153                                self.handle_action(action);
154                            }
155                        }
156                    }
157
158                    // Drain any additional pending events
159                    while crossterm::event::poll(Duration::ZERO)? {
160                        if let Ok(Event::Key(key_event)) = crossterm::event::read() {
161                            if key_event.kind == crossterm::event::KeyEventKind::Press {
162                                if self.mode == AppMode::Command {
163                                    self.handle_command_input(key_event);
164                                } else {
165                                    let action = KeyAction::from_key_event(key_event);
166                                    self.handle_action(action);
167                                }
168                                if self.mode == AppMode::Quit {
169                                    break;
170                                }
171                            }
172                        }
173                    }
174                    self.needs_redraw = true;
175                }
176
177                Some(result) = async {
178                    if let Some(rx) = &mut self.scan_rx {
179                        rx.recv().await
180                    } else {
181                        std::future::pending().await
182                    }
183                } => {
184                    self.handle_scan_result(result);
185                    self.needs_redraw = true;
186                }
187
188                _ = interval.tick() => {
189                    // Periodic tick for background updates
190                }
191            }
192        }
193
194        Ok(())
195    }
196
197    /// Start a background scan.
198    fn start_scan(&mut self) {
199        self.mode = AppMode::Scanning;
200        self.scan_view = ScanView::Progress;
201        self.scan_progress = Some(ScanProgress::new());
202        self.tree = None;
203        self.duplicates = None;
204        self.age_report = None;
205        self.warnings.clear();
206        self.selected_warning = 0;
207        self.cached_tree_len = 0;
208
209        self.scan_rx = Some(scanning::start_scan(self.path.clone()));
210    }
211
212    /// Handle a scan result from the background task.
213    fn handle_scan_result(&mut self, result: ScanResult) {
214        match result {
215            ScanResult::Progress(progress) => {
216                self.scan_progress = Some(progress);
217            }
218            ScanResult::Warning(warning) => {
219                self.warnings.push(warning);
220            }
221            ScanResult::ScanComplete(Ok(tree)) => {
222                let root_path = tree.root_path.clone();
223
224                // Merge any final warnings from tree
225                for warning in &tree.warnings {
226                    if !self.warnings.iter().any(|w| w.path == warning.path) {
227                        self.warnings.push(warning.clone());
228                    }
229                }
230
231                let tree_for_analysis = tree.clone();
232
233                self.tree = Some(tree);
234                self.tree_state = TreeState::new(root_path);
235                self.update_cached_tree_len();
236                self.mode = AppMode::Normal;
237                self.error = None;
238                self.scan_progress = None;
239
240                // Start background analysis
241                self.analyzing = true;
242                self.scan_rx = Some(scanning::start_analysis(tree_for_analysis));
243            }
244            ScanResult::ScanComplete(Err(e)) => {
245                self.error = Some(e.to_string());
246                self.mode = AppMode::Normal;
247                self.scan_progress = None;
248                self.scan_rx = None;
249                self.analyzing = false;
250            }
251            ScanResult::AnalysisComplete {
252                duplicates,
253                age_report,
254            } => {
255                self.duplicates = Some(duplicates);
256                self.age_report = Some(age_report);
257                self.analyzing = false;
258                self.scan_rx = None;
259            }
260            ScanResult::DeletionProgress(progress) => {
261                self.deletion_progress = Some(progress);
262            }
263            ScanResult::DeletionComplete {
264                deleted,
265                failed,
266                bytes_freed,
267            } => {
268                self.deletion_progress = None;
269                self.scan_rx = None;
270
271                let (success, msg) = deletion::format_deletion_result(deleted, failed, bytes_freed);
272                self.deletion_message = Some((success, msg));
273                self.mode = AppMode::Normal;
274
275                // Refresh scan after deletion
276                self.start_scan();
277            }
278        }
279    }
280
281    /// Update cached tree item count.
282    fn update_cached_tree_len(&mut self) {
283        if let Some((node, root_path)) = self.get_view_root_node() {
284            let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
285            self.cached_tree_len = items.len();
286        } else {
287            self.cached_tree_len = 0;
288        }
289    }
290
291    /// Render the application.
292    fn render(&self, frame: &mut Frame) {
293        frame.render_widget(self, frame.area());
294    }
295
296    /// Handle a key action.
297    fn handle_action(&mut self, action: KeyAction) {
298        // Clear deletion message on any action
299        self.deletion_message = None;
300
301        // Handle special modes
302        match self.mode {
303            AppMode::Help => {
304                match action {
305                    KeyAction::ToggleHelp | KeyAction::Quit => {
306                        if self.scan_progress.is_some() && self.tree.is_none() {
307                            self.mode = AppMode::Scanning;
308                        } else {
309                            self.mode = AppMode::Normal;
310                        }
311                    }
312                    _ => {}
313                }
314                return;
315            }
316            AppMode::ConfirmDelete => {
317                match action {
318                    KeyAction::Confirm | KeyAction::DrillDown => {
319                        self.execute_deletion();
320                    }
321                    KeyAction::Quit | KeyAction::Cancel => {
322                        self.mode = AppMode::Normal;
323                    }
324                    _ => {}
325                }
326                return;
327            }
328            AppMode::Deleting => {
329                return;
330            }
331            AppMode::Command => {
332                match action {
333                    KeyAction::Quit | KeyAction::Cancel => {
334                        self.command_input.clear();
335                        self.mode = AppMode::Normal;
336                    }
337                    _ => {}
338                }
339                return;
340            }
341            _ => {}
342        }
343
344        match action {
345            KeyAction::Quit | KeyAction::ForceQuit => {
346                self.mode = AppMode::Quit;
347            }
348
349            KeyAction::NextTab => {
350                if self.mode == AppMode::Scanning {
351                    self.scan_view = self.scan_view.next();
352                } else {
353                    self.view = self.view.next();
354                }
355            }
356            KeyAction::PrevTab => {
357                if self.mode == AppMode::Scanning {
358                    self.scan_view = self.scan_view.prev();
359                } else {
360                    self.view = self.view.prev();
361                }
362            }
363
364            KeyAction::MoveUp => self.move_up(),
365            KeyAction::MoveDown => self.move_down(),
366            KeyAction::PageUp => self.page_up(),
367            KeyAction::PageDown => self.page_down(),
368            KeyAction::JumpToTop => self.jump_to_top(),
369            KeyAction::JumpToBottom => self.jump_to_bottom(),
370
371            KeyAction::MoveRight | KeyAction::Expand => {
372                if self.view == View::Explorer {
373                    self.expand_selected();
374                }
375            }
376            KeyAction::MoveLeft | KeyAction::Collapse => {
377                if self.view == View::Explorer {
378                    self.collapse_selected();
379                }
380            }
381            KeyAction::ToggleExpand => {
382                if self.view == View::Explorer {
383                    self.toggle_selected();
384                }
385            }
386
387            KeyAction::Delete => {
388                self.toggle_mark_for_deletion();
389            }
390            KeyAction::Confirm => {
391                if !self.marked_for_deletion.is_empty() {
392                    self.mode = AppMode::ConfirmDelete;
393                } else if self.view == View::Explorer {
394                    self.toggle_selected();
395                }
396            }
397            KeyAction::ClearMarks => {
398                self.marked_for_deletion.clear();
399            }
400
401            KeyAction::DrillDown => {
402                if !self.marked_for_deletion.is_empty() {
403                    self.mode = AppMode::ConfirmDelete;
404                } else if self.view == View::Explorer {
405                    self.drill_into_selected();
406                }
407            }
408            KeyAction::NavigateBack => {
409                if self.view == View::Explorer {
410                    self.navigate_back();
411                }
412            }
413
414            KeyAction::CommandMode => {
415                self.command_input.clear();
416                self.mode = AppMode::Command;
417            }
418
419            KeyAction::ToggleDetails => {
420                self.show_details = !self.show_details;
421            }
422            KeyAction::ToggleHelp => {
423                self.mode = AppMode::Help;
424            }
425            KeyAction::ToggleTheme => {
426                self.theme = self.theme.toggle();
427            }
428            KeyAction::Refresh => {
429                self.marked_for_deletion.clear();
430                self.start_scan();
431            }
432
433            _ => {}
434        }
435    }
436
437    /// Toggle marking the currently selected item for deletion.
438    fn toggle_mark_for_deletion(&mut self) {
439        let path = match self.view {
440            View::Explorer => {
441                if let Some((node, root_path)) = self.get_view_root_node() {
442                    let items =
443                        TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
444                    items.get(self.tree_state.selected).map(|i| i.path.clone())
445                } else {
446                    None
447                }
448            }
449            View::Duplicates => {
450                if let Some((filtered, _)) = self.get_filtered_duplicates() {
451                    let selected = self.selected_dup_group.min(filtered.len().saturating_sub(1));
452                    filtered.get(selected).and_then(|g| {
453                        g.paths
454                            .iter()
455                            .find(|p| p.starts_with(&self.view_root))
456                            .cloned()
457                    })
458                } else {
459                    None
460                }
461            }
462            View::Age => {
463                if let Some(filtered) = self.get_filtered_stale_dirs() {
464                    let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
465                    filtered.get(selected).map(|d| d.path.clone())
466                } else {
467                    None
468                }
469            }
470            View::Errors => None,
471        };
472
473        if let Some(path) = path {
474            if path != self.path {
475                if self.marked_for_deletion.contains(&path) {
476                    self.marked_for_deletion.remove(&path);
477                } else {
478                    self.marked_for_deletion.insert(path);
479                }
480            }
481        }
482    }
483
484    /// Execute deletion of all marked items asynchronously.
485    fn execute_deletion(&mut self) {
486        let paths_with_sizes: Vec<(PathBuf, u64)> = self
487            .marked_for_deletion
488            .iter()
489            .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
490            .collect();
491
492        let total = paths_with_sizes.len();
493        if total == 0 {
494            return;
495        }
496
497        self.marked_for_deletion.clear();
498        self.mode = AppMode::Deleting;
499        self.deletion_progress = Some(DeletionProgress {
500            total,
501            deleted: 0,
502            failed: 0,
503            bytes_freed: 0,
504            current: paths_with_sizes.first().map(|(p, _)| p.clone()),
505        });
506
507        self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
508    }
509
510    /// Get the size of a path from the tree.
511    fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
512        let tree = self.tree.as_ref()?;
513
514        fn find_size(
515            node: &gravityfile_core::FileNode,
516            target: &PathBuf,
517            current: &PathBuf,
518        ) -> Option<u64> {
519            if current == target {
520                return Some(node.size);
521            }
522            for child in &node.children {
523                let child_path = current.join(&*child.name);
524                if let Some(size) = find_size(child, target, &child_path) {
525                    return Some(size);
526                }
527            }
528            None
529        }
530
531        find_size(&tree.root, target, &tree.root_path)
532    }
533
534    // Navigation methods
535    fn move_up(&mut self) {
536        match self.view {
537            View::Explorer => self.tree_state.move_up(1),
538            View::Duplicates => {
539                self.selected_dup_group = self.selected_dup_group.saturating_sub(1);
540            }
541            View::Age => {
542                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
543            }
544            View::Errors => {
545                self.selected_warning = self.selected_warning.saturating_sub(1);
546            }
547        }
548    }
549
550    fn move_down(&mut self) {
551        match self.view {
552            View::Explorer => {
553                self.tree_state.move_down(1, self.cached_tree_len);
554            }
555            View::Duplicates => {
556                if let Some((filtered, _)) = self.get_filtered_duplicates() {
557                    let max = filtered.len().saturating_sub(1);
558                    self.selected_dup_group = (self.selected_dup_group + 1).min(max);
559                }
560            }
561            View::Age => {
562                if let Some(filtered) = self.get_filtered_stale_dirs() {
563                    let max = filtered.len().saturating_sub(1);
564                    self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
565                }
566            }
567            View::Errors => {
568                let max = self.warnings.len().saturating_sub(1);
569                self.selected_warning = (self.selected_warning + 1).min(max);
570            }
571        }
572    }
573
574    fn page_up(&mut self) {
575        match self.view {
576            View::Explorer => self.tree_state.move_up(PAGE_SIZE),
577            View::Duplicates => {
578                self.selected_dup_group = self.selected_dup_group.saturating_sub(PAGE_SIZE);
579            }
580            View::Age => {
581                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
582            }
583            View::Errors => {
584                self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
585            }
586        }
587    }
588
589    fn page_down(&mut self) {
590        match self.view {
591            View::Explorer => {
592                self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
593            }
594            View::Duplicates => {
595                if let Some((filtered, _)) = self.get_filtered_duplicates() {
596                    let max = filtered.len().saturating_sub(1);
597                    self.selected_dup_group = (self.selected_dup_group + PAGE_SIZE).min(max);
598                }
599            }
600            View::Age => {
601                if let Some(filtered) = self.get_filtered_stale_dirs() {
602                    let max = filtered.len().saturating_sub(1);
603                    self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
604                }
605            }
606            View::Errors => {
607                let max = self.warnings.len().saturating_sub(1);
608                self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
609            }
610        }
611    }
612
613    fn jump_to_top(&mut self) {
614        match self.view {
615            View::Explorer => self.tree_state.jump_to_top(),
616            View::Duplicates => self.selected_dup_group = 0,
617            View::Age => self.selected_stale_dir = 0,
618            View::Errors => self.selected_warning = 0,
619        }
620    }
621
622    fn jump_to_bottom(&mut self) {
623        match self.view {
624            View::Explorer => {
625                self.tree_state.jump_to_bottom(self.cached_tree_len);
626            }
627            View::Duplicates => {
628                if let Some((filtered, _)) = self.get_filtered_duplicates() {
629                    self.selected_dup_group = filtered.len().saturating_sub(1);
630                }
631            }
632            View::Age => {
633                if let Some(filtered) = self.get_filtered_stale_dirs() {
634                    self.selected_stale_dir = filtered.len().saturating_sub(1);
635                }
636            }
637            View::Errors => {
638                self.selected_warning = self.warnings.len().saturating_sub(1);
639            }
640        }
641    }
642
643    fn expand_selected(&mut self) {
644        if let Some((node, root_path)) = self.get_view_root_node() {
645            let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
646            if let Some(item) = items.get(self.tree_state.selected) {
647                self.tree_state.expand(&item.path);
648                self.update_cached_tree_len();
649            }
650        }
651    }
652
653    fn collapse_selected(&mut self) {
654        if let Some((node, root_path)) = self.get_view_root_node() {
655            let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
656            if let Some(item) = items.get(self.tree_state.selected) {
657                self.tree_state.collapse(&item.path);
658                self.update_cached_tree_len();
659            }
660        }
661    }
662
663    fn toggle_selected(&mut self) {
664        if let Some((node, root_path)) = self.get_view_root_node() {
665            let items = TreeView::new(node, &root_path, &self.theme).flatten(&self.tree_state);
666            if let Some(item) = items.get(self.tree_state.selected) {
667                self.tree_state.toggle_expand(&item.path);
668                self.update_cached_tree_len();
669            }
670        }
671    }
672
673    fn get_selected_info(&self) -> Option<SelectedInfo> {
674        let tree = self.tree.as_ref()?;
675        let (view_node, view_path) = self.get_view_root_node()?;
676        let items = TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
677        let item = items.get(self.tree_state.selected)?;
678
679        let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
680
681        Some(SelectedInfo {
682            name: item.node.name.clone(),
683            path: item.path.clone(),
684            size: node.size,
685            file_count: node.file_count(),
686            dir_count: node.dir_count(),
687            modified: node.timestamps.modified,
688            is_dir: node.is_dir(),
689        })
690    }
691
692    /// Drill into the currently selected directory.
693    fn drill_into_selected(&mut self) {
694        let Some((view_node, view_path)) = self.get_view_root_node() else {
695            return;
696        };
697
698        let items = TreeView::new(view_node, &view_path, &self.theme).flatten(&self.tree_state);
699        let Some(item) = items.get(self.tree_state.selected) else {
700            return;
701        };
702
703        if !matches!(
704            item.node.kind,
705            crate::ui::VisibleNodeKind::Directory { .. }
706        ) {
707            return;
708        }
709
710        if item.path == self.view_root {
711            self.tree_state.expand(&item.path);
712            self.update_cached_tree_len();
713            return;
714        }
715
716        let saved_expanded = self.tree_state.expanded.clone();
717        let saved_selected = self.tree_state.selected;
718        self.view_history
719            .push((self.view_root.clone(), saved_selected, saved_expanded));
720
721        self.view_root = item.path.clone();
722
723        self.tree_state.selected = 0;
724        self.tree_state.offset = 0;
725        self.tree_state.expand(&self.view_root);
726        self.update_cached_tree_len();
727    }
728
729    /// Navigate back up to the previous view root.
730    fn navigate_back(&mut self) {
731        if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
732            self.view_root = prev_root;
733            self.tree_state.expanded = saved_expanded;
734            self.tree_state.selected = saved_selected;
735            self.tree_state.offset = 0;
736            self.update_cached_tree_len();
737        } else if self.view_root != self.path {
738            if let Some(parent) = self.view_root.parent() {
739                if parent.starts_with(&self.path) || parent == self.path {
740                    let dir_we_drilled_into = self.view_root.clone();
741                    self.tree_state.collapse(&dir_we_drilled_into);
742
743                    self.view_root = parent.to_path_buf();
744                    self.tree_state.expand(&self.view_root);
745                    self.tree_state.selected = 0;
746                    self.tree_state.offset = 0;
747                    self.update_cached_tree_len();
748                }
749            }
750        }
751    }
752
753    /// Find a node by path in the tree.
754    fn find_node_at_path<'a>(
755        node: &'a gravityfile_core::FileNode,
756        target_path: &PathBuf,
757        current_path: &PathBuf,
758    ) -> Option<&'a gravityfile_core::FileNode> {
759        if current_path == target_path {
760            return Some(node);
761        }
762        for child in &node.children {
763            let child_path = current_path.join(&*child.name);
764            if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
765                return Some(found);
766            }
767        }
768        None
769    }
770
771    /// Get the current view root node.
772    fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
773        let tree = self.tree.as_ref()?;
774        let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
775        Some((node, self.view_root.clone()))
776    }
777
778    /// Handle raw key input for command mode.
779    fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
780        match self.command_input.handle_key(key) {
781            CommandKeyResult::Continue => {}
782            CommandKeyResult::Cancel => {
783                self.mode = AppMode::Normal;
784            }
785            CommandKeyResult::Execute(cmd) => {
786                self.mode = AppMode::Normal;
787                self.execute_command(&cmd);
788            }
789        }
790    }
791
792    /// Execute a command from the command palette.
793    fn execute_command(&mut self, cmd: &str) {
794        match parse_command(cmd) {
795            CommandAction::None => {}
796            CommandAction::Quit => {
797                self.mode = AppMode::Quit;
798            }
799            CommandAction::Refresh => {
800                self.marked_for_deletion.clear();
801                self.start_scan();
802            }
803            CommandAction::NavigateTo(target) => {
804                self.cd_to_path(&target);
805            }
806            CommandAction::GoToRoot => {
807                self.go_to_root();
808            }
809            CommandAction::NavigateBack => {
810                self.navigate_back();
811            }
812            CommandAction::ShowHelp => {
813                self.mode = AppMode::Help;
814            }
815            CommandAction::SwitchView(view) => {
816                self.view = view;
817            }
818            CommandAction::ClearMarks => {
819                self.marked_for_deletion.clear();
820            }
821            CommandAction::ToggleDetails => {
822                self.show_details = !self.show_details;
823            }
824            CommandAction::SetTheme(theme_cmd) => match theme_cmd {
825                ThemeCommand::Dark => self.theme = Theme::dark(),
826                ThemeCommand::Light => self.theme = Theme::light(),
827                ThemeCommand::Toggle => self.theme = self.theme.toggle(),
828            },
829        }
830    }
831
832    /// Go to the scan root.
833    fn go_to_root(&mut self) {
834        self.view_root = self.path.clone();
835        self.view_history.clear();
836        self.tree_state.selected = 0;
837        self.tree_state.offset = 0;
838        self.tree_state.expand(&self.view_root);
839        self.update_cached_tree_len();
840    }
841
842    /// Change directory to a path.
843    fn cd_to_path(&mut self, target: &str) {
844        if target == "/" || target == "~" {
845            self.go_to_root();
846            return;
847        }
848        if target == ".." {
849            self.navigate_back();
850            return;
851        }
852
853        let Some(tree) = &self.tree else {
854            return;
855        };
856
857        let target_path = if target.starts_with('/') {
858            let relative = target.trim_start_matches('/');
859            self.path.join(relative)
860        } else {
861            self.view_root.join(target)
862        };
863
864        if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
865            if node.is_dir() {
866                let saved_expanded = self.tree_state.expanded.clone();
867                let saved_selected = self.tree_state.selected;
868                self.view_history
869                    .push((self.view_root.clone(), saved_selected, saved_expanded));
870
871                self.view_root = target_path;
872                self.tree_state.selected = 0;
873                self.tree_state.offset = 0;
874                self.tree_state.expand(&self.view_root);
875                self.update_cached_tree_len();
876            }
877        }
878    }
879
880    /// Check if a path is marked for deletion.
881    pub fn is_marked(&self, path: &PathBuf) -> bool {
882        self.marked_for_deletion.contains(path)
883    }
884
885    /// Get duplicate groups filtered to current view_root.
886    fn get_filtered_duplicates(
887        &self,
888    ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
889        let dups = self.duplicates.as_ref()?;
890
891        if self.view_root == self.path {
892            let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
893            return Some((dups.groups.iter().collect(), total));
894        }
895
896        let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
897            .groups
898            .iter()
899            .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
900            .collect();
901
902        let total_wasted: u64 = filtered
903            .iter()
904            .map(|g| {
905                let paths_in_view = g
906                    .paths
907                    .iter()
908                    .filter(|p| p.starts_with(&self.view_root))
909                    .count();
910                if paths_in_view > 1 {
911                    g.size * (paths_in_view as u64 - 1)
912                } else {
913                    0
914                }
915            })
916            .sum();
917
918        Some((filtered, total_wasted))
919    }
920
921    /// Get stale directories filtered to current view_root.
922    fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
923        let age = self.age_report.as_ref()?;
924
925        if self.view_root == self.path {
926            return Some(age.stale_directories.iter().collect());
927        }
928
929        let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
930            .stale_directories
931            .iter()
932            .filter(|d| d.path.starts_with(&self.view_root))
933            .collect();
934
935        Some(filtered)
936    }
937}
938
939impl Widget for &App {
940    fn render(self, area: Rect, buf: &mut Buffer) {
941        let ctx = RenderContext {
942            mode: self.mode,
943            view: self.view,
944            scan_view: self.scan_view,
945            theme: &self.theme,
946            path: &self.path,
947            view_root: &self.view_root,
948            show_details: self.show_details,
949            tree: self.tree.as_ref(),
950            tree_state: &self.tree_state,
951            scan_progress: self.scan_progress.as_ref(),
952            deletion_progress: self.deletion_progress.as_ref(),
953            duplicates: self.duplicates.as_ref(),
954            age_report: self.age_report.as_ref(),
955            warnings: &self.warnings,
956            selected_dup_group: self.selected_dup_group,
957            selected_stale_dir: self.selected_stale_dir,
958            selected_warning: self.selected_warning,
959            marked_for_deletion: &self.marked_for_deletion,
960            deletion_message: self.deletion_message.as_ref(),
961            error: self.error.as_deref(),
962            command_input: self.command_input.buffer(),
963            command_cursor: self.command_input.cursor(),
964            get_path_size: Box::new(|p| self.get_path_size(p)),
965            get_selected_info: self.get_selected_info(),
966            get_view_root_node: self.get_view_root_node(),
967            get_filtered_duplicates: self.get_filtered_duplicates(),
968            get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
969        };
970
971        render_app(&ctx, area, buf);
972    }
973}