gravityfile_tui/app/
mod.rs

1//! Main application state and logic.
2
3mod commands;
4mod constants;
5mod deletion;
6pub mod input;
7mod navigation;
8mod render;
9pub mod state;
10mod scanning;
11
12use std::collections::{HashMap, HashSet};
13use std::path::PathBuf;
14use std::time::Duration;
15
16use crossterm::event::{Event, EventStream};
17use futures::StreamExt;
18use ratatui::buffer::Buffer;
19use ratatui::layout::Rect;
20use ratatui::widgets::Widget;
21use ratatui::{DefaultTerminal, Frame};
22use tokio::sync::mpsc;
23
24use gravityfile_analyze::{AgeReport, DuplicateReport};
25use gravityfile_core::FileTree;
26use gravityfile_ops::{
27    Conflict, CopyOptions, CopyResult, MoveOptions, MoveResult, OperationProgress, UndoLog,
28};
29use gravityfile_scan::ScanProgress;
30
31use crate::event::KeyAction;
32use crate::preview::PreviewState;
33use crate::search::SearchState;
34use crate::theme::Theme;
35use crate::ui::{TreeState, TreeView};
36use crate::TuiConfig;
37
38use self::commands::{parse_command, CommandAction, CommandInput, CommandKeyResult, LayoutCommand, SortCommand, ThemeCommand};
39use self::constants::{PAGE_SIZE, TICK_INTERVAL_MS};
40use self::input::{InputResult, InputState};
41use self::render::{render_app, RenderContext};
42use self::state::{
43    AppMode, ClipboardMode, ClipboardState, DeletionProgress, DuplicatesViewState, LayoutMode,
44    PendingOperation, ScanResult, SelectedInfo, SettingsState, SortMode, TabManager, UserSettings, View,
45};
46
47/// Application result type.
48pub type AppResult<T> = color_eyre::Result<T>;
49
50/// Data collected for synchronizing selection between layout modes.
51struct LayoutSyncData {
52    /// Current view root path.
53    view_path: PathBuf,
54    /// Index in miller view (when switching from tree to miller).
55    miller_index: Option<usize>,
56    /// Index in tree view (when switching from miller to tree).
57    tree_index: Option<usize>,
58    /// New view root if we need to change it to show the selected item.
59    new_view_root: Option<PathBuf>,
60}
61
62/// Main application state.
63pub struct App {
64    /// Path being analyzed (scan root).
65    path: PathBuf,
66    /// Current view root for drill-down navigation (can be different from scan root).
67    view_root: PathBuf,
68    /// Navigation history stack for going back up (path, selected_index, expanded_set).
69    view_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
70    /// Forward navigation history stack (for returning after going back).
71    forward_history: Vec<(PathBuf, usize, HashSet<PathBuf>)>,
72    /// Current mode.
73    mode: AppMode,
74    /// Current view (normal mode).
75    view: View,
76    /// Color theme.
77    theme: Theme,
78    /// Scanned file tree.
79    tree: Option<FileTree>,
80    /// Tree view state.
81    tree_state: TreeState,
82    /// Cached count of visible tree items (to avoid flatten on every nav).
83    cached_tree_len: usize,
84    /// Duplicate analysis report.
85    duplicates: Option<DuplicateReport>,
86    /// Age analysis report.
87    age_report: Option<AgeReport>,
88    /// Duplicates view state (expanded groups, selected files).
89    duplicates_state: DuplicatesViewState,
90    /// Selected stale directory index.
91    selected_stale_dir: usize,
92    /// Show details panel.
93    show_details: bool,
94    /// Error message to display.
95    error: Option<String>,
96    /// Paths marked/selected for operations.
97    marked: HashSet<PathBuf>,
98    /// Clipboard state for yank/cut/paste.
99    clipboard: ClipboardState,
100    /// Undo log for reversible operations.
101    #[allow(dead_code)]
102    undo_log: UndoLog,
103    /// Layout mode (tree vs miller).
104    layout_mode: LayoutMode,
105    /// Miller columns state.
106    miller_state: crate::ui::MillerState,
107    /// Cached count of entries in current directory for Miller view.
108    cached_miller_len: usize,
109    /// Input state for rename/create modes.
110    input_state: Option<InputState>,
111    /// Current file operation progress (copy/move).
112    operation_progress: Option<OperationProgress>,
113    /// Pending conflict requiring resolution.
114    pending_conflict: Option<Conflict>,
115    /// Pending operation that needs conflict resolution before proceeding.
116    pending_operation: Option<PendingOperation>,
117    /// Last operation result message.
118    operation_message: Option<(bool, String)>,
119    /// Last deletion result message.
120    deletion_message: Option<(bool, String)>,
121    /// Current deletion progress (during async deletion).
122    deletion_progress: Option<DeletionProgress>,
123    /// Current scan progress (for display during scanning).
124    scan_progress: Option<ScanProgress>,
125    /// Channel for receiving scan results.
126    scan_rx: Option<mpsc::Receiver<ScanResult>>,
127    /// Scan warnings/errors (populated in real-time during scan).
128    warnings: Vec<gravityfile_core::ScanWarning>,
129    /// Selected warning index.
130    selected_warning: usize,
131    /// Whether analysis is running.
132    analyzing: bool,
133    /// Whether a full recursive scan has been completed (vs just quick_list).
134    has_full_scan: bool,
135    /// Flag indicating UI needs redraw.
136    needs_redraw: bool,
137    /// Command palette input state.
138    command_input: CommandInput,
139    /// Current sort mode for file listings.
140    sort_mode: SortMode,
141    /// Fuzzy search state.
142    search_state: SearchState,
143    /// Directory tab manager.
144    tab_manager: TabManager,
145    /// File preview state.
146    preview_state: PreviewState,
147    /// Whether to auto-scan on startup.
148    scan_on_startup: bool,
149    /// User settings (persistent configuration).
150    user_settings: UserSettings,
151    /// Settings modal state.
152    settings_state: Option<SettingsState>,
153    /// Cached parent tree for Miller columns when at tree root.
154    /// This allows showing the parent column even when navigated beyond the original scan root.
155    cached_parent_tree: Option<FileTree>,
156    /// Pending command that requires terminal suspension (e.g., opening files in editor).
157    pending_suspend_command: Option<std::process::Command>,
158    /// Cache of scanned directories by path.
159    /// This preserves scan data when navigating to parent directories and allows
160    /// restoring it when navigating back.
161    scanned_cache: HashMap<PathBuf, FileTree>,
162}
163
164impl App {
165    /// Create a new application with default config.
166    /// Immediately loads a quick directory listing for instant display.
167    pub fn new(path: PathBuf) -> Self {
168        Self::with_config(path, TuiConfig::default())
169    }
170
171    /// Create a new application with custom config.
172    /// Immediately loads a quick directory listing for instant display,
173    /// and optionally starts a full recursive scan based on config.
174    pub fn with_config(path: PathBuf, config: TuiConfig) -> Self {
175        // Load user settings from disk
176        let user_settings = UserSettings::load();
177
178        // Load quick tree immediately for instant display
179        let quick_tree = gravityfile_scan::quick_list(&path).ok();
180
181        // Determine scan_on_startup: CLI flag overrides user settings
182        // CLI flag (-S) is explicit; if not provided, use user settings
183        let scan_on_startup = if config.scan_on_startup {
184            true // CLI flag was explicitly set
185        } else {
186            user_settings.scan_on_startup
187        };
188
189        let mut app = Self {
190            path: path.clone(),
191            view_root: path.clone(),
192            view_history: Vec::new(),
193            forward_history: Vec::new(),
194            mode: AppMode::default(),
195            view: View::default(),
196            theme: Theme::dark(),
197            tree: quick_tree,
198            tree_state: TreeState::new(path.clone()),
199            cached_tree_len: 0,
200            duplicates: None,
201            age_report: None,
202            duplicates_state: DuplicatesViewState::new(),
203            selected_stale_dir: 0,
204            show_details: true,
205            error: None,
206            marked: HashSet::new(),
207            clipboard: ClipboardState::default(),
208            undo_log: UndoLog::new(100),
209            layout_mode: LayoutMode::default(),
210            miller_state: crate::ui::MillerState::new(),
211            cached_miller_len: 0,
212            input_state: None,
213            operation_progress: None,
214            pending_conflict: None,
215            pending_operation: None,
216            operation_message: None,
217            deletion_message: None,
218            deletion_progress: None,
219            scan_progress: None,
220            scan_rx: None,
221            warnings: Vec::new(),
222            selected_warning: 0,
223            analyzing: false,
224            has_full_scan: false,
225            needs_redraw: true,
226            command_input: CommandInput::new(),
227            sort_mode: SortMode::default(),
228            search_state: SearchState::new(),
229            tab_manager: TabManager::with_initial_tab(path, 10),
230            preview_state: PreviewState::new(),
231            scan_on_startup,
232            user_settings,
233            settings_state: None,
234            cached_parent_tree: None,
235            pending_suspend_command: None,
236            scanned_cache: HashMap::new(),
237        };
238
239        // Update cached lengths for immediate navigation
240        app.update_cached_tree_len();
241        app.update_cached_miller_len();
242        // Cache parent for Miller columns display
243        app.update_cached_parent();
244
245        app
246    }
247
248    /// Run the application with async event loop.
249    pub async fn run(mut self, mut terminal: DefaultTerminal) -> AppResult<()> {
250        // Start scan if configured to do so
251        if self.scan_on_startup {
252            self.start_scan();
253        }
254        // Otherwise quick list is already loaded in new() for immediate display
255
256        let period = Duration::from_millis(TICK_INTERVAL_MS);
257        let mut interval = tokio::time::interval(period);
258        let mut events = EventStream::new();
259
260        while self.mode != AppMode::Quit {
261            if self.needs_redraw {
262                terminal.draw(|frame| self.render(frame))?;
263                self.needs_redraw = false;
264            }
265
266            tokio::select! {
267                biased;
268
269                Some(Ok(event)) = events.next() => {
270                    if let Event::Key(key_event) = event {
271                        if key_event.kind == crossterm::event::KeyEventKind::Press {
272                            if self.mode == AppMode::Command {
273                                self.handle_command_input(key_event);
274                            } else if self.mode == AppMode::Search {
275                                self.handle_search_input(key_event);
276                            } else if matches!(self.mode, AppMode::Renaming | AppMode::CreatingFile | AppMode::CreatingDirectory | AppMode::Taking | AppMode::GoingTo) {
277                                self.handle_input_event(key_event);
278                            } else if self.mode == AppMode::ConflictResolution {
279                                self.handle_conflict_key(key_event);
280                            } else {
281                                let action = KeyAction::from_key_event(key_event);
282                                self.handle_action(action);
283                            }
284                        }
285                    }
286
287                    // Drain any additional pending events
288                    while crossterm::event::poll(Duration::ZERO)? {
289                        if let Ok(Event::Key(key_event)) = crossterm::event::read() {
290                            if key_event.kind == crossterm::event::KeyEventKind::Press {
291                                if self.mode == AppMode::Command {
292                                    self.handle_command_input(key_event);
293                                } else if self.mode == AppMode::Search {
294                                    self.handle_search_input(key_event);
295                                } else if matches!(self.mode, AppMode::Renaming | AppMode::CreatingFile | AppMode::CreatingDirectory | AppMode::Taking | AppMode::GoingTo) {
296                                    self.handle_input_event(key_event);
297                                } else if self.mode == AppMode::ConflictResolution {
298                                    self.handle_conflict_key(key_event);
299                                } else {
300                                    let action = KeyAction::from_key_event(key_event);
301                                    self.handle_action(action);
302                                }
303                                if self.mode == AppMode::Quit {
304                                    break;
305                                }
306                            }
307                        }
308                    }
309                    self.needs_redraw = true;
310                }
311
312                Some(result) = async {
313                    if let Some(rx) = &mut self.scan_rx {
314                        rx.recv().await
315                    } else {
316                        std::future::pending().await
317                    }
318                } => {
319                    self.handle_scan_result(result);
320                    self.needs_redraw = true;
321                }
322
323                _ = interval.tick() => {
324                    // Periodic tick for background updates
325                }
326            }
327
328            // Handle pending suspend command (opening files in external apps)
329            if let Some(mut cmd) = self.pending_suspend_command.take() {
330                // Restore terminal before running external command
331                ratatui::restore();
332
333                // Run the command and wait for it to complete
334                let _ = cmd.status();
335
336                // Reinitialize terminal
337                terminal = ratatui::init();
338                self.needs_redraw = true;
339            }
340        }
341
342        Ok(())
343    }
344
345    /// Start a background scan of the current view_root directory.
346    /// Note: Scanning runs in the background while the app remains in Normal mode.
347    /// The UI will detect scanning state via `scan_progress.is_some()`.
348    /// If we have a quick tree, it remains visible during scanning.
349    fn start_scan(&mut self) {
350        // Update the scan root to the current view_root
351        self.path = self.view_root.clone();
352
353        self.scan_progress = Some(ScanProgress::new());
354        // Don't clear tree - keep quick listing visible during scan
355        // self.tree = None;
356        self.duplicates = None;
357        self.age_report = None;
358        self.warnings.clear();
359        self.selected_warning = 0;
360        // Don't reset cached lengths - keep them for navigation
361        // self.cached_tree_len = 0;
362
363        self.scan_rx = Some(scanning::start_scan(self.view_root.clone()));
364    }
365
366    /// Handle a scan result from the background task.
367    fn handle_scan_result(&mut self, result: ScanResult) {
368        match result {
369            ScanResult::Progress(progress) => {
370                self.scan_progress = Some(progress);
371            }
372            ScanResult::Warning(warning) => {
373                self.warnings.push(warning);
374            }
375            ScanResult::PartialTree(tree) => {
376                // Update tree with partial results during scanning
377                // This allows the UI to show progressive updates as directories are scanned
378                let root_path = tree.root_path.clone();
379                self.tree = Some(tree);
380
381                // Preserve selection position if possible
382                if self.tree_state.expanded.is_empty() {
383                    self.tree_state = TreeState::new(root_path.clone());
384                }
385                self.tree_state.expand(&root_path);
386                self.update_cached_tree_len();
387                self.update_cached_miller_len();
388
389                // Clamp selection to valid range
390                if self.tree_state.selected >= self.cached_tree_len && self.cached_tree_len > 0 {
391                    self.tree_state.selected = self.cached_tree_len - 1;
392                }
393            }
394            ScanResult::ScanComplete(Ok(tree)) => {
395                let root_path = tree.root_path.clone();
396
397                // Merge any final warnings from tree
398                for warning in &tree.warnings {
399                    if !self.warnings.iter().any(|w| w.path == warning.path) {
400                        self.warnings.push(warning.clone());
401                    }
402                }
403
404                let tree_for_analysis = tree.clone();
405
406                // Cache the scanned tree for later restoration
407                self.scanned_cache.insert(root_path.clone(), tree.clone());
408
409                self.tree = Some(tree);
410
411                // Preserve tree state (selection, expanded) across rescans
412                // Only reset if this is a different root path
413                if self.tree_state.expanded.is_empty()
414                    || !self.tree_state.expanded.iter().any(|p| p.starts_with(&root_path))
415                {
416                    self.tree_state = TreeState::new(root_path);
417                } else {
418                    // Just ensure root is expanded
419                    self.tree_state.expand(&root_path);
420                }
421                self.update_cached_tree_len();
422                self.update_cached_miller_len();
423
424                // Update cached parent tree for Miller columns display
425                self.update_cached_parent();
426
427                // Clamp selection to valid range
428                if self.tree_state.selected >= self.cached_tree_len && self.cached_tree_len > 0 {
429                    self.tree_state.selected = self.cached_tree_len - 1;
430                }
431                self.mode = AppMode::Normal;
432                self.error = None;
433                self.scan_progress = None;
434                self.has_full_scan = true;
435
436                // Start background analysis
437                self.analyzing = true;
438                self.scan_rx = Some(scanning::start_analysis(tree_for_analysis));
439            }
440            ScanResult::ScanComplete(Err(e)) => {
441                self.error = Some(e.to_string());
442                self.mode = AppMode::Normal;
443                self.scan_progress = None;
444                self.scan_rx = None;
445                self.analyzing = false;
446            }
447            ScanResult::AnalysisComplete {
448                duplicates,
449                age_report,
450            } => {
451                self.duplicates = Some(duplicates);
452                self.age_report = Some(age_report);
453                self.analyzing = false;
454                self.scan_rx = None;
455            }
456            ScanResult::DeletionProgress(progress) => {
457                self.deletion_progress = Some(progress);
458            }
459            ScanResult::DeletionComplete {
460                deleted,
461                failed,
462                bytes_freed,
463            } => {
464                self.deletion_progress = None;
465                self.scan_rx = None;
466
467                let (success, msg) = deletion::format_deletion_result(deleted, failed, bytes_freed);
468                self.deletion_message = Some((success, msg));
469                self.mode = AppMode::Normal;
470
471                // Refresh scan after deletion
472                self.start_scan();
473            }
474            ScanResult::OperationProgress(progress) => {
475                self.operation_progress = Some(progress);
476            }
477            ScanResult::OperationConflict(conflict) => {
478                self.pending_conflict = Some(conflict);
479                self.mode = AppMode::ConflictResolution;
480            }
481            ScanResult::OperationComplete {
482                operation_type,
483                succeeded,
484                failed,
485                bytes_processed: _,
486            } => {
487                self.operation_progress = None;
488                self.scan_rx = None;
489
490                let action = match operation_type {
491                    gravityfile_ops::OperationType::Copy => "Copied",
492                    gravityfile_ops::OperationType::Move => "Moved",
493                    gravityfile_ops::OperationType::Delete => "Deleted",
494                    gravityfile_ops::OperationType::Rename => "Renamed",
495                    gravityfile_ops::OperationType::CreateFile => "Created file",
496                    gravityfile_ops::OperationType::CreateDirectory => "Created directory",
497                };
498
499                let success = failed == 0;
500                let msg = if success {
501                    format!("{} {} items", action, succeeded)
502                } else {
503                    format!("{} {} items, {} failed", action, succeeded, failed)
504                };
505                self.operation_message = Some((success, msg));
506                self.mode = AppMode::Normal;
507
508                // Refresh scan after operation
509                self.start_scan();
510            }
511        }
512    }
513
514    /// Update cached tree item count.
515    fn update_cached_tree_len(&mut self) {
516        if let Some((node, root_path)) = self.get_view_root_node() {
517            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
518            self.cached_tree_len = items.len();
519        } else {
520            self.cached_tree_len = 0;
521        }
522    }
523
524    /// Update cached miller entry count.
525    fn update_cached_miller_len(&mut self) {
526        if let Some((node, _)) = self.get_view_root_node() {
527            self.cached_miller_len = node.children.len();
528        } else {
529            self.cached_miller_len = 0;
530        }
531    }
532
533    /// Synchronize selection state when toggling between Tree and Miller layouts.
534    /// Maintains the same selected item across layout switches.
535    fn sync_layout_selection(&mut self) {
536        // Collect all data needed before mutating self
537        let sync_data = self.collect_layout_sync_data();
538
539        let Some(data) = sync_data else {
540            // No view node, just toggle and reset
541            self.layout_mode = self.layout_mode.toggle();
542            if self.layout_mode == LayoutMode::Miller {
543                self.miller_state.reset();
544                self.update_cached_miller_len();
545                self.update_preview();
546            } else {
547                self.update_cached_tree_len();
548                self.preview_state.content = crate::preview::PreviewContent::Empty;
549            }
550            return;
551        };
552
553        match self.layout_mode {
554            LayoutMode::Tree => {
555                // Switching Tree → Miller
556                self.layout_mode = LayoutMode::Miller;
557
558                // If we need to change view_root to show the selected item
559                if let Some(new_view_root) = data.new_view_root {
560                    self.view_root = new_view_root;
561                    self.tree_state.expand(&self.view_root);
562                }
563
564                if let Some(miller_idx) = data.miller_index {
565                    self.miller_state.selected = miller_idx;
566                    self.miller_state.offset = 0;
567                    self.update_cached_miller_len();
568                    self.miller_state.ensure_visible(20);
569                    self.update_preview();
570                } else {
571                    // Fallback: reset miller state
572                    self.miller_state.reset();
573                    self.update_cached_miller_len();
574                    self.update_preview();
575                }
576            }
577            LayoutMode::Miller => {
578                // Switching Miller → Tree
579                self.layout_mode = LayoutMode::Tree;
580                self.preview_state.content = crate::preview::PreviewContent::Empty;
581
582                // Ensure the view_root is expanded in tree
583                self.tree_state.expand(&data.view_path);
584
585                if let Some(tree_idx) = data.tree_index {
586                    self.tree_state.selected = tree_idx;
587                    self.tree_state.offset = 0;
588                    self.tree_state.ensure_visible(20);
589                }
590                self.update_cached_tree_len();
591            }
592        }
593    }
594
595    /// Collect data needed for layout synchronization without holding borrows.
596    fn collect_layout_sync_data(&self) -> Option<LayoutSyncData> {
597        let (view_node, view_path) = self.get_view_root_node()?;
598
599        match self.layout_mode {
600            LayoutMode::Tree => {
601                // Get the currently selected path in tree view
602                let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
603                    .flatten(&self.tree_state);
604
605                let selected_path = items.get(self.tree_state.selected).map(|item| item.path.clone());
606
607                let Some(selected) = selected_path else {
608                    return Some(LayoutSyncData {
609                        view_path: view_path.clone(),
610                        miller_index: None,
611                        tree_index: None,
612                        new_view_root: None,
613                    });
614                };
615
616                // If we're on the root itself, preserve current miller selection
617                if selected == view_path {
618                    let miller_index = if self.miller_state.selected < view_node.children.len() {
619                        Some(self.miller_state.selected)
620                    } else if view_node.children.is_empty() {
621                        None
622                    } else {
623                        Some(0)
624                    };
625                    return Some(LayoutSyncData {
626                        view_path: view_path.clone(),
627                        miller_index,
628                        tree_index: None,
629                        new_view_root: None,
630                    });
631                }
632
633                // Check if selected is a direct child of view_root
634                let is_direct_child = selected.parent() == Some(view_path.as_path());
635
636                if is_direct_child {
637                    // Direct child - find it in miller
638                    let name = selected.file_name()?.to_string_lossy();
639                    let miller_index = view_node.children.iter().position(|c| c.name.as_str() == name);
640                    Some(LayoutSyncData {
641                        view_path: view_path.clone(),
642                        miller_index,
643                        tree_index: None,
644                        new_view_root: None,
645                    })
646                } else {
647                    // Nested item - need to change view_root to the parent of selected
648                    // so that Miller can display the selected item
649                    let new_view_root = selected.parent()?.to_path_buf();
650
651                    // Find the selected item's name to locate it in the new view
652                    let selected_name = selected.file_name()?.to_string_lossy().to_string();
653
654                    // We need to find the node at new_view_root to get its children
655                    let tree = self.tree.as_ref()?;
656                    let new_view_node = Self::find_node_at_path(&tree.root, &new_view_root, &tree.root_path)?;
657                    let miller_index = new_view_node.children.iter().position(|c| c.name.as_str() == selected_name);
658
659                    Some(LayoutSyncData {
660                        view_path: view_path.clone(),
661                        miller_index,
662                        tree_index: None,
663                        new_view_root: Some(new_view_root),
664                    })
665                }
666            }
667            LayoutMode::Miller => {
668                // Get the target path from miller selection
669                let target_path = view_node
670                    .children
671                    .get(self.miller_state.selected)
672                    .map(|c| view_path.join(&*c.name));
673
674                // Use existing tree_state expansion to find the index
675                // First ensure view_path is expanded
676                let mut tree_state_for_search = self.tree_state.clone();
677                tree_state_for_search.expand(&view_path);
678
679                let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
680                    .flatten(&tree_state_for_search);
681
682                let tree_index = target_path.as_ref().and_then(|target| {
683                    items.iter().position(|item| &item.path == target)
684                });
685
686                Some(LayoutSyncData {
687                    view_path: view_path.clone(),
688                    miller_index: None,
689                    tree_index,
690                    new_view_root: None,
691                })
692            }
693        }
694    }
695
696    /// Update the file preview for the currently selected item in Miller mode.
697    fn update_preview(&mut self) {
698        // Only update preview in Miller mode for non-directory items
699        if self.layout_mode != LayoutMode::Miller {
700            self.preview_state.content = crate::preview::PreviewContent::Empty;
701            return;
702        }
703
704        let Some((view_node, _)) = self.get_view_root_node() else {
705            self.preview_state.content = crate::preview::PreviewContent::Empty;
706            return;
707        };
708
709        let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
710            self.preview_state.content = crate::preview::PreviewContent::Empty;
711            return;
712        };
713
714        // Only show file preview for non-directories
715        if selected_child.is_dir() {
716            // For directories, we show child list directly via miller columns
717            self.preview_state.content = crate::preview::PreviewContent::Empty;
718            return;
719        }
720
721        // Get the full path to the selected file
722        let file_path = self.view_root.join(&*selected_child.name);
723
724        // Update preview if path changed
725        self.preview_state.update(Some(&file_path));
726    }
727
728    /// Render the application.
729    fn render(&self, frame: &mut Frame) {
730        frame.render_widget(self, frame.area());
731    }
732
733    /// Handle a key action.
734    fn handle_action(&mut self, action: KeyAction) {
735        // Clear deletion message on any action
736        self.deletion_message = None;
737
738        // Handle special modes
739        match self.mode {
740            AppMode::Help => {
741                match action {
742                    KeyAction::ToggleHelp | KeyAction::Quit | KeyAction::Cancel => {
743                        self.mode = AppMode::Normal;
744                    }
745                    _ => {}
746                }
747                return;
748            }
749            AppMode::Settings => {
750                self.handle_settings_input(action);
751                return;
752            }
753            AppMode::ConfirmDelete => {
754                match action {
755                    KeyAction::Confirm | KeyAction::DrillDown => {
756                        self.execute_deletion();
757                    }
758                    KeyAction::Quit | KeyAction::Cancel => {
759                        // Clear marks if user cancels (they may have been auto-marked)
760                        self.marked.clear();
761                        self.mode = AppMode::Normal;
762                    }
763                    _ => {}
764                }
765                return;
766            }
767            AppMode::Deleting => {
768                return;
769            }
770            AppMode::Command => {
771                match action {
772                    KeyAction::Quit | KeyAction::Cancel => {
773                        self.command_input.clear();
774                        self.mode = AppMode::Normal;
775                    }
776                    _ => {}
777                }
778                return;
779            }
780            AppMode::ConflictResolution => {
781                // Handled in run loop via handle_conflict_key
782                return;
783            }
784            AppMode::Copying | AppMode::Moving => {
785                // Operations in progress - ignore most input
786                return;
787            }
788            _ => {}
789        }
790
791        match action {
792            KeyAction::Quit => {
793                self.mode = AppMode::Quit;
794            }
795            KeyAction::ForceQuit => {
796                self.mode = AppMode::Quit;
797            }
798            KeyAction::Cancel => {
799                // Esc clears clipboard first, then marks - does NOT quit
800                if !self.clipboard.is_empty() {
801                    self.clipboard.clear();
802                } else if !self.marked.is_empty() {
803                    self.marked.clear();
804                }
805                // If nothing to clear, Esc does nothing in normal mode
806            }
807
808            KeyAction::NextView => {
809                self.view = self.view.next();
810            }
811            KeyAction::PrevView => {
812                self.view = self.view.prev();
813            }
814
815            // Directory tabs
816            KeyAction::NewDirTab => {
817                // Open new tab with current view_root
818                self.tab_manager.new_tab(self.view_root.clone());
819            }
820            KeyAction::CloseDirTab => {
821                self.tab_manager.close_active_tab();
822            }
823            KeyAction::NextDirTab => {
824                // Only switch tabs if there are multiple
825                if self.tab_manager.len() > 1 {
826                    self.tab_manager.next_tab();
827                    self.sync_from_active_tab();
828                }
829            }
830            KeyAction::PrevDirTab => {
831                // Only switch tabs if there are multiple
832                if self.tab_manager.len() > 1 {
833                    self.tab_manager.prev_tab();
834                    self.sync_from_active_tab();
835                }
836            }
837            KeyAction::DirTab(n) => {
838                // Only switch tabs if there are multiple
839                if self.tab_manager.len() > 1 {
840                    self.tab_manager.switch_to_number(n as usize);
841                    self.sync_from_active_tab();
842                }
843            }
844
845            KeyAction::MoveUp => self.move_up(),
846            KeyAction::MoveDown => self.move_down(),
847            KeyAction::PageUp => self.page_up(),
848            KeyAction::PageDown => self.page_down(),
849            KeyAction::JumpToTop => self.jump_to_top(),
850            KeyAction::JumpToBottom => self.jump_to_bottom(),
851
852            KeyAction::MoveRight | KeyAction::Expand => {
853                if self.view == View::Explorer {
854                    match self.layout_mode {
855                        LayoutMode::Tree => self.expand_selected(),
856                        LayoutMode::Miller => self.drill_into_miller_selected(),
857                    }
858                } else if self.view == View::Duplicates {
859                    // l expands group in duplicates view
860                    if !self.duplicates_state.is_expanded(self.duplicates_state.selected_group) {
861                        self.duplicates_state.toggle_expand();
862                    }
863                }
864            }
865            KeyAction::MoveLeft | KeyAction::Collapse => {
866                if self.view == View::Explorer {
867                    match self.layout_mode {
868                        LayoutMode::Tree => self.collapse_selected(),
869                        LayoutMode::Miller => self.navigate_back(),
870                    }
871                } else if self.view == View::Duplicates {
872                    // h collapses expanded group in duplicates view
873                    if self.duplicates_state.is_expanded(self.duplicates_state.selected_group) {
874                        self.duplicates_state.toggle_expand();
875                    }
876                }
877            }
878            KeyAction::ToggleExpand => {
879                if self.view == View::Explorer && self.layout_mode == LayoutMode::Tree {
880                    self.toggle_selected();
881                } else if self.view == View::Duplicates {
882                    self.duplicates_state.toggle_expand();
883                }
884            }
885
886            // Selection
887            KeyAction::ToggleMark => {
888                self.toggle_mark();
889            }
890            KeyAction::ClearMarks => {
891                self.marked.clear();
892            }
893
894            // Clipboard operations
895            KeyAction::Yank => {
896                self.yank_selection();
897            }
898            KeyAction::Cut => {
899                self.cut_selection();
900            }
901            KeyAction::Paste => {
902                self.paste_clipboard();
903            }
904
905            // File operations
906            KeyAction::Delete => {
907                // If nothing marked, auto-mark items for deletion
908                if self.marked.is_empty() {
909                    if self.view == View::Duplicates {
910                        // In duplicates view, special handling
911                        // Collect paths first to avoid borrow issues
912                        let paths_to_mark: Vec<PathBuf> = if let Some((filtered, _)) = self.get_filtered_duplicates() {
913                            let is_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
914                                || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
915
916                            if is_header {
917                                // Mark all duplicates except the first (the "original")
918                                filtered
919                                    .get(self.duplicates_state.selected_group)
920                                    .map(|g| g.paths.iter().skip(1).cloned().collect())
921                                    .unwrap_or_default()
922                            } else {
923                                // Mark the selected file
924                                self.duplicates_state
925                                    .selected_file_path(&filtered)
926                                    .cloned()
927                                    .into_iter()
928                                    .collect()
929                            }
930                        } else {
931                            Vec::new()
932                        };
933                        for path in paths_to_mark {
934                            self.marked.insert(path);
935                        }
936                    } else if let Some(path) = self.get_selected_path() {
937                        self.marked.insert(path);
938                    }
939                }
940                if !self.marked.is_empty() {
941                    self.mode = AppMode::ConfirmDelete;
942                }
943            }
944            KeyAction::Rename => {
945                self.start_rename();
946            }
947            KeyAction::CreateFile => {
948                self.start_create_file();
949            }
950            KeyAction::CreateDirectory => {
951                self.start_create_directory();
952            }
953            KeyAction::Take => {
954                self.start_take();
955            }
956            KeyAction::GoTo => {
957                self.start_goto();
958            }
959            KeyAction::Undo => {
960                self.execute_undo();
961            }
962
963            KeyAction::Confirm => {
964                if !self.marked.is_empty() {
965                    self.mode = AppMode::ConfirmDelete;
966                } else if self.view == View::Explorer {
967                    self.toggle_selected();
968                }
969            }
970
971            KeyAction::DrillDown | KeyAction::OpenFile => {
972                if self.view == View::Explorer {
973                    // Check if selected item is a file or directory
974                    let is_file = self.is_selected_file();
975                    if is_file {
976                        self.open_selected_file();
977                    } else {
978                        match self.layout_mode {
979                            LayoutMode::Tree => self.drill_into_selected(),
980                            LayoutMode::Miller => self.drill_into_miller_selected(),
981                        }
982                    }
983                }
984            }
985            KeyAction::NavigateBack => {
986                if self.view == View::Explorer {
987                    self.navigate_back();
988                }
989            }
990
991            KeyAction::CommandMode => {
992                self.command_input.clear();
993                self.mode = AppMode::Command;
994            }
995
996            KeyAction::ToggleDetails => {
997                self.show_details = !self.show_details;
998            }
999            KeyAction::ToggleHelp => {
1000                self.mode = AppMode::Help;
1001            }
1002            KeyAction::ToggleTheme => {
1003                self.theme = self.theme.toggle();
1004            }
1005            KeyAction::ToggleLayout => {
1006                self.sync_layout_selection();
1007            }
1008            KeyAction::CyclePreviewMode => {
1009                self.preview_state.cycle_mode();
1010            }
1011            KeyAction::OpenSettings => {
1012                self.settings_state = Some(SettingsState::new(self.user_settings.clone()));
1013                self.mode = AppMode::Settings;
1014            }
1015            KeyAction::Refresh => {
1016                self.marked.clear();
1017                self.start_scan();
1018            }
1019            KeyAction::Sort => {
1020                self.cycle_sort_mode();
1021            }
1022            KeyAction::ReverseSort => {
1023                self.reverse_sort_mode();
1024            }
1025            KeyAction::Search => {
1026                self.start_search();
1027            }
1028
1029            _ => {}
1030        }
1031    }
1032
1033    /// Toggle marking the currently selected item.
1034    fn toggle_mark(&mut self) {
1035        let path = match self.view {
1036            View::Explorer => {
1037                if let Some((node, root_path)) = self.get_view_root_node() {
1038                    match self.layout_mode {
1039                        LayoutMode::Tree => {
1040                            let items =
1041                                TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1042                            items.get(self.tree_state.selected).map(|i| i.path.clone())
1043                        }
1044                        LayoutMode::Miller => {
1045                            node.children.get(self.miller_state.selected).map(|child| {
1046                                self.view_root.join(&*child.name)
1047                            })
1048                        }
1049                    }
1050                } else {
1051                    None
1052                }
1053            }
1054            View::Duplicates => {
1055                // Collect data first to avoid borrow issues
1056                let (is_on_header, group_paths, selected_file) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1057                    let is_on_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
1058                        || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
1059
1060                    let group_paths: Vec<PathBuf> = filtered
1061                        .get(self.duplicates_state.selected_group)
1062                        .map(|g| g.paths.clone())
1063                        .unwrap_or_default();
1064
1065                    let selected_file = if !is_on_header {
1066                        self.duplicates_state.selected_file_path(&filtered).cloned()
1067                    } else {
1068                        None
1069                    };
1070
1071                    (is_on_header, group_paths, selected_file)
1072                } else {
1073                    return;
1074                };
1075
1076                if is_on_header {
1077                    // Header selected - toggle ALL files in the group
1078                    let any_marked = group_paths.iter().any(|p| self.marked.contains(p));
1079
1080                    if any_marked {
1081                        // Unmark all files in the group
1082                        for path in &group_paths {
1083                            self.marked.remove(path);
1084                        }
1085                    } else {
1086                        // Mark all files in the group
1087                        for path in group_paths {
1088                            if path != self.path {
1089                                self.marked.insert(path);
1090                            }
1091                        }
1092                    }
1093                    return; // Early return since we handled it here
1094                } else {
1095                    // Specific file selected within expanded group
1096                    selected_file
1097                }
1098            }
1099            View::Age => {
1100                if let Some(filtered) = self.get_filtered_stale_dirs() {
1101                    let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
1102                    filtered.get(selected).map(|d| d.path.clone())
1103                } else {
1104                    None
1105                }
1106            }
1107            View::Errors => None,
1108        };
1109
1110        if let Some(path) = path {
1111            if path != self.path {
1112                if self.marked.contains(&path) {
1113                    self.marked.remove(&path);
1114                } else {
1115                    self.marked.insert(path);
1116                }
1117            }
1118        }
1119    }
1120
1121    /// Execute deletion of all marked items asynchronously.
1122    fn execute_deletion(&mut self) {
1123        let paths_with_sizes: Vec<(PathBuf, u64)> = self
1124            .marked
1125            .iter()
1126            .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
1127            .collect();
1128
1129        let total = paths_with_sizes.len();
1130        if total == 0 {
1131            return;
1132        }
1133
1134        self.marked.clear();
1135        self.mode = AppMode::Deleting;
1136        self.deletion_progress = Some(DeletionProgress {
1137            total,
1138            deleted: 0,
1139            failed: 0,
1140            bytes_freed: 0,
1141            current: paths_with_sizes.first().map(|(p, _)| p.clone()),
1142        });
1143
1144        self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
1145    }
1146
1147    /// Get the size of a path from the tree.
1148    fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
1149        let tree = self.tree.as_ref()?;
1150
1151        fn find_size(
1152            node: &gravityfile_core::FileNode,
1153            target: &PathBuf,
1154            current: &PathBuf,
1155        ) -> Option<u64> {
1156            if current == target {
1157                return Some(node.size);
1158            }
1159            for child in &node.children {
1160                let child_path = current.join(&*child.name);
1161                if let Some(size) = find_size(child, target, &child_path) {
1162                    return Some(size);
1163                }
1164            }
1165            None
1166        }
1167
1168        find_size(&tree.root, target, &tree.root_path)
1169    }
1170
1171    /// Yank (copy) selected items to clipboard.
1172    fn yank_selection(&mut self) {
1173        let paths: Vec<PathBuf> = if self.marked.is_empty() {
1174            self.get_selected_path().into_iter().collect()
1175        } else {
1176            self.marked.iter().cloned().collect()
1177        };
1178
1179        if !paths.is_empty() {
1180            self.clipboard.yank(paths, self.view_root.clone());
1181            self.marked.clear();
1182        }
1183    }
1184
1185    /// Cut (move) selected items to clipboard.
1186    fn cut_selection(&mut self) {
1187        let paths: Vec<PathBuf> = if self.marked.is_empty() {
1188            self.get_selected_path().into_iter().collect()
1189        } else {
1190            self.marked.iter().cloned().collect()
1191        };
1192
1193        if !paths.is_empty() {
1194            self.clipboard.cut(paths, self.view_root.clone());
1195            self.marked.clear();
1196        }
1197    }
1198
1199    /// Paste from clipboard to appropriate destination.
1200    /// - If cursor is on a directory → paste INTO that directory
1201    /// - If cursor is on a file → paste into the same parent directory
1202    fn paste_clipboard(&mut self) {
1203        self.paste_with_resolution(None);
1204    }
1205
1206    /// Paste with a specific conflict resolution (or None to check for conflicts).
1207    fn paste_with_resolution(&mut self, resolution: Option<gravityfile_ops::ConflictResolution>) {
1208        if self.clipboard.is_empty() {
1209            return;
1210        }
1211
1212        // Determine destination based on current selection
1213        let destination = self.get_paste_destination();
1214
1215        let sources = self.clipboard.paths.clone();
1216        let mode = self.clipboard.mode;
1217
1218        // Pre-flight conflict detection (only if no resolution provided)
1219        if resolution.is_none() {
1220            if let Some(conflict) = self.check_paste_conflict(&sources, &destination) {
1221                // Store the pending operation and show conflict modal
1222                self.pending_operation = Some(PendingOperation::Paste {
1223                    sources: sources.clone(),
1224                    destination: destination.clone(),
1225                    mode,
1226                });
1227                self.pending_conflict = Some(conflict);
1228                self.mode = AppMode::ConflictResolution;
1229                return;
1230            }
1231        }
1232
1233        match mode {
1234            ClipboardMode::Copy => {
1235                self.mode = AppMode::Copying;
1236                let options = CopyOptions {
1237                    conflict_resolution: resolution,
1238                    preserve_timestamps: false,
1239                };
1240                let rx = gravityfile_ops::start_copy(sources, destination, options);
1241                self.scan_rx = Some(Self::adapt_copy_rx(rx));
1242            }
1243            ClipboardMode::Cut => {
1244                self.mode = AppMode::Moving;
1245                let options = MoveOptions {
1246                    conflict_resolution: resolution,
1247                };
1248                let rx = gravityfile_ops::start_move(sources, destination, options);
1249                self.scan_rx = Some(Self::adapt_move_rx(rx));
1250                self.clipboard.clear();
1251            }
1252            ClipboardMode::Empty => {}
1253        }
1254    }
1255
1256    /// Check if pasting would cause a conflict with an existing file.
1257    fn check_paste_conflict(&self, sources: &[PathBuf], destination: &PathBuf) -> Option<Conflict> {
1258        use gravityfile_ops::{ConflictKind};
1259
1260        for source in sources {
1261            if let Some(filename) = source.file_name() {
1262                let dest_path = destination.join(filename);
1263
1264                // Check if destination already exists
1265                if dest_path.exists() {
1266                    // Determine conflict kind
1267                    let kind = if source == &dest_path {
1268                        // Same file - source and destination are identical
1269                        ConflictKind::SameFile
1270                    } else if dest_path.is_dir() {
1271                        ConflictKind::DirectoryExists
1272                    } else {
1273                        ConflictKind::FileExists
1274                    };
1275
1276                    return Some(Conflict::new(
1277                        source.clone(),
1278                        dest_path,
1279                        kind,
1280                    ));
1281                }
1282            }
1283        }
1284        None
1285    }
1286
1287    /// Get the appropriate paste destination based on current selection.
1288    /// - If selected item is a directory → return that directory
1289    /// - If selected item is a file → return the parent directory
1290    /// - If nothing selected → return view_root
1291    fn get_paste_destination(&self) -> PathBuf {
1292        // Try to get the currently selected item
1293        if let Some(selected_path) = self.get_selected_path() {
1294            // Check if the selected item is a directory
1295            if selected_path.is_dir() {
1296                return selected_path;
1297            } else {
1298                // It's a file, use its parent directory
1299                if let Some(parent) = selected_path.parent() {
1300                    return parent.to_path_buf();
1301                }
1302            }
1303        }
1304        // Default to view_root if nothing selected
1305        self.view_root.clone()
1306    }
1307
1308    /// Adapt copy result channel to ScanResult channel.
1309    fn adapt_copy_rx(mut rx: mpsc::Receiver<CopyResult>) -> mpsc::Receiver<ScanResult> {
1310        let (tx, result_rx) = mpsc::channel(100);
1311        tokio::spawn(async move {
1312            while let Some(result) = rx.recv().await {
1313                let scan_result = match result {
1314                    CopyResult::Progress(p) => ScanResult::OperationProgress(p),
1315                    CopyResult::Conflict(c) => ScanResult::OperationConflict(c),
1316                    CopyResult::Complete(c) => ScanResult::OperationComplete {
1317                        operation_type: c.operation_type,
1318                        succeeded: c.succeeded,
1319                        failed: c.failed,
1320                        bytes_processed: c.bytes_processed,
1321                    },
1322                };
1323                if tx.send(scan_result).await.is_err() {
1324                    break;
1325                }
1326            }
1327        });
1328        result_rx
1329    }
1330
1331    /// Adapt move result channel to ScanResult channel.
1332    fn adapt_move_rx(mut rx: mpsc::Receiver<MoveResult>) -> mpsc::Receiver<ScanResult> {
1333        let (tx, result_rx) = mpsc::channel(100);
1334        tokio::spawn(async move {
1335            while let Some(result) = rx.recv().await {
1336                let scan_result = match result {
1337                    MoveResult::Progress(p) => ScanResult::OperationProgress(p),
1338                    MoveResult::Conflict(c) => ScanResult::OperationConflict(c),
1339                    MoveResult::Complete(c) => ScanResult::OperationComplete {
1340                        operation_type: c.operation_type,
1341                        succeeded: c.succeeded,
1342                        failed: c.failed,
1343                        bytes_processed: c.bytes_processed,
1344                    },
1345                };
1346                if tx.send(scan_result).await.is_err() {
1347                    break;
1348                }
1349            }
1350        });
1351        result_rx
1352    }
1353
1354    /// Handle key press during conflict resolution mode.
1355    fn handle_conflict_key(&mut self, event: crossterm::event::KeyEvent) {
1356        use crossterm::event::KeyCode;
1357        use gravityfile_ops::ConflictResolution;
1358
1359        // Check if this is a SameFile conflict - only Skip and Abort are valid
1360        let is_same_file = self.pending_conflict
1361            .as_ref()
1362            .map(|c| matches!(c.kind, gravityfile_ops::ConflictKind::SameFile))
1363            .unwrap_or(false);
1364
1365        let resolution = match event.code {
1366            // Skip
1367            KeyCode::Char('s') => Some(ConflictResolution::Skip),
1368            // Skip All (not valid for same file)
1369            KeyCode::Char('S') if !is_same_file => Some(ConflictResolution::SkipAll),
1370            // Overwrite (not valid for same file)
1371            KeyCode::Char('o') if !is_same_file => Some(ConflictResolution::Overwrite),
1372            // Overwrite All (not valid for same file)
1373            KeyCode::Char('O') if !is_same_file => Some(ConflictResolution::OverwriteAll),
1374            // Rename (auto-rename)
1375            KeyCode::Char('r') => Some(ConflictResolution::AutoRename),
1376            // Abort/Cancel
1377            KeyCode::Esc => Some(ConflictResolution::Abort),
1378            // Enter also acts as skip for same-file conflicts
1379            KeyCode::Enter if is_same_file => Some(ConflictResolution::Skip),
1380            // For SourceIsAncestor, Enter also acknowledges
1381            KeyCode::Enter => {
1382                if let Some(conflict) = &self.pending_conflict {
1383                    if matches!(conflict.kind, gravityfile_ops::ConflictKind::SourceIsAncestor) {
1384                        Some(ConflictResolution::Abort)
1385                    } else {
1386                        None
1387                    }
1388                } else {
1389                    None
1390                }
1391            }
1392            _ => None,
1393        };
1394
1395        if let Some(res) = resolution {
1396            // Clear the pending conflict
1397            self.pending_conflict = None;
1398
1399            // Take the pending operation
1400            let pending = self.pending_operation.take();
1401
1402            // If aborting or skipping, cancel the operation and return to normal mode
1403            if matches!(res, ConflictResolution::Abort | ConflictResolution::Skip | ConflictResolution::SkipAll) {
1404                self.mode = AppMode::Normal;
1405                self.scan_rx = None;
1406                self.operation_progress = None;
1407                // Keep clipboard for Abort (user might want to try elsewhere)
1408                // Clear for Skip only if it was a cut operation that we're fully canceling
1409                return;
1410            }
1411
1412            // Resume the pending operation with the chosen resolution
1413            if let Some(pending_op) = pending {
1414                match pending_op {
1415                    PendingOperation::Paste { sources, destination: _, mode } => {
1416                        // Temporarily set clipboard to resume the paste
1417                        let old_clipboard = self.clipboard.clone();
1418                        self.clipboard.paths = sources;
1419                        self.clipboard.mode = mode;
1420
1421                        // Execute paste with the chosen resolution
1422                        self.paste_with_resolution(Some(res));
1423
1424                        // For Copy mode, restore clipboard after paste starts
1425                        // For Cut mode, the paste_with_resolution already clears it
1426                        if mode == ClipboardMode::Copy {
1427                            self.clipboard = old_clipboard;
1428                        }
1429                    }
1430                }
1431            } else {
1432                // No pending operation, just return to normal
1433                self.mode = AppMode::Normal;
1434                self.scan_rx = None;
1435                self.operation_progress = None;
1436            }
1437        }
1438    }
1439
1440    /// Start rename operation for current selection.
1441    fn start_rename(&mut self) {
1442        if let Some(path) = self.get_selected_path() {
1443            let name = path
1444                .file_name()
1445                .map(|n| n.to_string_lossy().to_string())
1446                .unwrap_or_default();
1447            let mut input = InputState::with_initial(&name);
1448            input.context_path = Some(path);
1449            self.input_state = Some(input);
1450            self.mode = AppMode::Renaming;
1451        }
1452    }
1453
1454    /// Start create file operation.
1455    fn start_create_file(&mut self) {
1456        let mut input = InputState::new();
1457        input.context_path = Some(self.view_root.clone());
1458        self.input_state = Some(input);
1459        self.mode = AppMode::CreatingFile;
1460    }
1461
1462    /// Start create directory operation.
1463    fn start_create_directory(&mut self) {
1464        let mut input = InputState::new();
1465        input.context_path = Some(self.view_root.clone());
1466        self.input_state = Some(input);
1467        self.mode = AppMode::CreatingDirectory;
1468    }
1469
1470    /// Start take operation (create directory and cd into it).
1471    fn start_take(&mut self) {
1472        let mut input = InputState::new();
1473        input.context_path = Some(self.view_root.clone());
1474        self.input_state = Some(input);
1475        self.mode = AppMode::Taking;
1476    }
1477
1478    /// Start go to directory operation.
1479    fn start_goto(&mut self) {
1480        // Pre-fill with current view root for convenience
1481        let current_path = self.view_root.to_string_lossy().to_string();
1482        let mut input = InputState::with_initial(&current_path);
1483        input.context_path = Some(self.view_root.clone());
1484        self.input_state = Some(input);
1485        self.mode = AppMode::GoingTo;
1486    }
1487
1488    /// Execute go to directory.
1489    fn execute_goto(&mut self, path_str: &str) {
1490        let path = if path_str.starts_with('~') {
1491            // Expand tilde
1492            if let Some(home) = dirs::home_dir() {
1493                home.join(&path_str[1..].trim_start_matches('/'))
1494            } else {
1495                std::path::PathBuf::from(path_str)
1496            }
1497        } else if path_str.starts_with('/') {
1498            std::path::PathBuf::from(path_str)
1499        } else {
1500            // Relative path from current view root
1501            self.view_root.join(path_str)
1502        };
1503
1504        // Canonicalize to resolve .. and .
1505        let path = path.canonicalize().unwrap_or(path);
1506
1507        if path.is_dir() {
1508            // Check if target path is within our already-scanned tree
1509            let within_scanned_tree = if let Some(tree) = &self.tree {
1510                path.starts_with(&tree.root_path) || tree.root_path.starts_with(&path)
1511            } else {
1512                false
1513            };
1514
1515            if within_scanned_tree {
1516                // Navigate within the existing tree - no rescan needed
1517                self.view_root = path.clone();
1518                self.view_history.clear();
1519                self.forward_history.clear();
1520                self.tree_state.selected = 0;
1521                self.tree_state.offset = 0;
1522                self.tree_state.expand(&self.view_root);
1523                self.miller_state.reset();
1524                self.update_cached_tree_len();
1525                self.update_cached_miller_len();
1526                self.preview_state.content = crate::preview::PreviewContent::Empty;
1527
1528                // Update the active tab view_root but keep the tree
1529                if let Some(tab) = self.tab_manager.active_tab_mut() {
1530                    tab.view_root = self.view_root.clone();
1531                }
1532            } else {
1533                // Going to a new location - use quick_list for immediate display
1534                // Don't auto-scan; user can press R to scan if needed
1535                self.path = path.clone();
1536                self.view_root = path.clone();
1537                self.view_history.clear();
1538                self.forward_history.clear();
1539                self.tree_state = TreeState::new(self.path.clone());
1540                self.miller_state.reset();
1541                self.marked.clear();
1542                self.clipboard.clear();
1543                self.duplicates = None;
1544                self.age_report = None;
1545                self.warnings.clear();
1546                self.preview_state.content = crate::preview::PreviewContent::Empty;
1547                self.scan_progress = None;
1548
1549                // Load quick tree for immediate display
1550                self.tree = gravityfile_scan::quick_list(&path).ok();
1551                self.update_cached_tree_len();
1552                self.update_cached_miller_len();
1553
1554                // Update the active tab
1555                if let Some(tab) = self.tab_manager.active_tab_mut() {
1556                    tab.path = self.path.clone();
1557                    tab.view_root = self.view_root.clone();
1558                    tab.label = self.path
1559                        .file_name()
1560                        .map(|n| n.to_string_lossy().to_string())
1561                        .unwrap_or_else(|| self.path.to_string_lossy().to_string());
1562                    tab.history.clear();
1563                }
1564
1565                // Don't auto-start scan - user can press R when ready
1566            }
1567        } else {
1568            self.error = Some(format!("Not a directory: {}", path.display()));
1569        }
1570    }
1571
1572    /// Execute undo of the last operation.
1573    fn execute_undo(&mut self) {
1574        // TODO: Implement undo
1575        // For now, just show a message that undo is not yet implemented
1576        self.operation_message = Some((false, "Undo not yet implemented".to_string()));
1577    }
1578
1579    /// Handle key events in input modes (Renaming, CreatingFile, CreatingDirectory).
1580    fn handle_input_event(&mut self, key: crossterm::event::KeyEvent) {
1581        let current_mode = self.mode;
1582
1583        if let Some(ref mut input) = self.input_state {
1584            match input.handle_key(key) {
1585                InputResult::Continue => {}
1586                InputResult::Cancel => {
1587                    self.input_state = None;
1588                    self.mode = AppMode::Normal;
1589                }
1590                InputResult::Submit(value) => {
1591                    let context_path = input.context_path.clone();
1592                    self.input_state = None;
1593
1594                    match current_mode {
1595                        AppMode::Renaming => {
1596                            if let Some(source) = context_path {
1597                                self.execute_rename(source, value);
1598                            }
1599                        }
1600                        AppMode::CreatingFile => {
1601                            if let Some(parent) = context_path {
1602                                self.execute_create_file(parent, value);
1603                            }
1604                        }
1605                        AppMode::CreatingDirectory => {
1606                            if let Some(parent) = context_path {
1607                                self.execute_create_directory(parent, value);
1608                            }
1609                        }
1610                        AppMode::Taking => {
1611                            if let Some(parent) = context_path {
1612                                self.execute_take(parent, value);
1613                            }
1614                        }
1615                        AppMode::GoingTo => {
1616                            self.execute_goto(&value);
1617                        }
1618                        _ => {}
1619                    }
1620
1621                    self.mode = AppMode::Normal;
1622                }
1623            }
1624        } else {
1625            // No input state, just cancel
1626            self.mode = AppMode::Normal;
1627        }
1628    }
1629
1630    /// Execute rename operation.
1631    fn execute_rename(&mut self, source: std::path::PathBuf, new_name: String) {
1632        use gravityfile_ops::start_rename;
1633
1634        // Validate name
1635        if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
1636            self.operation_message = Some((false, "Invalid file name".to_string()));
1637            return;
1638        }
1639
1640        let rx = start_rename(source.clone(), new_name.clone());
1641
1642        // Adapt the rename result receiver
1643        let adapted_rx = Self::adapt_rename_rx(rx);
1644        self.scan_rx = Some(adapted_rx);
1645        self.mode = AppMode::Normal;
1646
1647        // Show pending message
1648        self.operation_message = Some((true, format!("Renaming to {}...", new_name)));
1649    }
1650
1651    /// Adapt rename receiver to ScanResult.
1652    fn adapt_rename_rx(
1653        mut rx: mpsc::Receiver<gravityfile_ops::RenameResult>,
1654    ) -> mpsc::Receiver<ScanResult> {
1655        let (tx, adapted_rx) = mpsc::channel(100);
1656
1657        tokio::spawn(async move {
1658            while let Some(result) = rx.recv().await {
1659                let scan_result = match result {
1660                    gravityfile_ops::RenameResult::Progress(progress) => {
1661                        ScanResult::OperationProgress(progress)
1662                    }
1663                    gravityfile_ops::RenameResult::Complete(complete) => {
1664                        ScanResult::OperationComplete {
1665                            operation_type: complete.operation_type,
1666                            succeeded: complete.succeeded,
1667                            failed: complete.failed,
1668                            bytes_processed: complete.bytes_processed,
1669                        }
1670                    }
1671                };
1672                if tx.send(scan_result).await.is_err() {
1673                    break;
1674                }
1675            }
1676        });
1677
1678        adapted_rx
1679    }
1680
1681    /// Execute create file operation.
1682    fn execute_create_file(&mut self, parent: std::path::PathBuf, name: String) {
1683        use gravityfile_ops::start_create_file;
1684
1685        // Validate name
1686        if name.is_empty() || name.contains('/') || name.contains('\\') {
1687            self.operation_message = Some((false, "Invalid file name".to_string()));
1688            return;
1689        }
1690
1691        let path = parent.join(&name);
1692        let rx = start_create_file(path.clone());
1693
1694        let adapted_rx = Self::adapt_create_rx(rx);
1695        self.scan_rx = Some(adapted_rx);
1696
1697        self.operation_message = Some((true, format!("Creating file {}...", name)));
1698    }
1699
1700    /// Execute create directory operation.
1701    fn execute_create_directory(&mut self, parent: std::path::PathBuf, name: String) {
1702        use gravityfile_ops::start_create_directory;
1703
1704        // Validate name
1705        if name.is_empty() || name.contains('/') || name.contains('\\') {
1706            self.operation_message = Some((false, "Invalid directory name".to_string()));
1707            return;
1708        }
1709
1710        let path = parent.join(&name);
1711        let rx = start_create_directory(path.clone());
1712
1713        let adapted_rx = Self::adapt_create_rx(rx);
1714        self.scan_rx = Some(adapted_rx);
1715
1716        self.operation_message = Some((true, format!("Creating directory {}...", name)));
1717    }
1718
1719    /// Execute take operation (create directory and navigate into it).
1720    fn execute_take(&mut self, parent: std::path::PathBuf, name: String) {
1721        // Validate name
1722        if name.is_empty() || name.contains('/') || name.contains('\\') {
1723            self.operation_message = Some((false, "Invalid directory name".to_string()));
1724            return;
1725        }
1726
1727        let path = parent.join(&name);
1728
1729        // Check if already exists
1730        if path.exists() {
1731            if path.is_dir() {
1732                // Directory exists - just navigate into it
1733                self.operation_message = Some((true, format!("Directory {} already exists, navigating...", name)));
1734            } else {
1735                // File exists with same name
1736                self.operation_message = Some((false, format!("A file named {} already exists", name)));
1737                return;
1738            }
1739        } else {
1740            // Create the directory synchronously (it's a simple operation)
1741            if let Err(e) = std::fs::create_dir(&path) {
1742                self.operation_message = Some((false, format!("Failed to create directory: {}", e)));
1743                return;
1744            }
1745            self.operation_message = Some((true, format!("Created and entered {}", name)));
1746        }
1747
1748        // Navigate into the new directory
1749        // Save current state to history
1750        let saved_expanded = self.tree_state.expanded.clone();
1751        let saved_selected = match self.layout_mode {
1752            LayoutMode::Tree => self.tree_state.selected,
1753            LayoutMode::Miller => self.miller_state.selected,
1754        };
1755        self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1756
1757        // Update view root to the new directory
1758        self.view_root = path;
1759
1760        // Reset state for new directory
1761        match self.layout_mode {
1762            LayoutMode::Tree => {
1763                self.tree_state.selected = 0;
1764                self.tree_state.offset = 0;
1765                self.tree_state.expand(&self.view_root);
1766            }
1767            LayoutMode::Miller => {
1768                self.miller_state.reset();
1769            }
1770        }
1771
1772        self.sync_to_active_tab();
1773
1774        // Refresh to show new directory in tree
1775        self.start_scan();
1776    }
1777
1778    /// Adapt create result receiver to ScanResult.
1779    fn adapt_create_rx(
1780        mut rx: mpsc::Receiver<gravityfile_ops::CreateResult>,
1781    ) -> mpsc::Receiver<ScanResult> {
1782        let (tx, adapted_rx) = mpsc::channel(100);
1783
1784        tokio::spawn(async move {
1785            while let Some(result) = rx.recv().await {
1786                let scan_result = match result {
1787                    gravityfile_ops::CreateResult::Progress(progress) => {
1788                        ScanResult::OperationProgress(progress)
1789                    }
1790                    gravityfile_ops::CreateResult::Complete(complete) => {
1791                        ScanResult::OperationComplete {
1792                            operation_type: complete.operation_type,
1793                            succeeded: complete.succeeded,
1794                            failed: complete.failed,
1795                            bytes_processed: complete.bytes_processed,
1796                        }
1797                    }
1798                };
1799                if tx.send(scan_result).await.is_err() {
1800                    break;
1801                }
1802            }
1803        });
1804
1805        adapted_rx
1806    }
1807
1808    // Navigation methods
1809    fn move_up(&mut self) {
1810        match self.view {
1811            View::Explorer => match self.layout_mode {
1812                LayoutMode::Tree => self.tree_state.move_up(1),
1813                LayoutMode::Miller => {
1814                    self.miller_state.move_up(1);
1815                    self.update_preview();
1816                }
1817            },
1818            View::Duplicates => {
1819                // Get counts first to avoid borrow issues
1820                let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1821                    let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1822                    (filtered.len(), counts)
1823                } else {
1824                    (0, Vec::new())
1825                };
1826                if group_count > 0 {
1827                    self.duplicates_state.move_up(group_count, |idx| {
1828                        file_counts.get(idx).copied().unwrap_or(0)
1829                    });
1830                }
1831            }
1832            View::Age => {
1833                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
1834            }
1835            View::Errors => {
1836                self.selected_warning = self.selected_warning.saturating_sub(1);
1837            }
1838        }
1839    }
1840
1841    fn move_down(&mut self) {
1842        match self.view {
1843            View::Explorer => match self.layout_mode {
1844                LayoutMode::Tree => {
1845                    self.tree_state.move_down(1, self.cached_tree_len);
1846                }
1847                LayoutMode::Miller => {
1848                    self.miller_state.move_down(1, self.cached_miller_len);
1849                    self.update_preview();
1850                }
1851            },
1852            View::Duplicates => {
1853                // Get counts first to avoid borrow issues
1854                let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1855                    let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1856                    (filtered.len(), counts)
1857                } else {
1858                    (0, Vec::new())
1859                };
1860                if group_count > 0 {
1861                    self.duplicates_state.move_down(group_count, |idx| {
1862                        file_counts.get(idx).copied().unwrap_or(0)
1863                    });
1864                }
1865            }
1866            View::Age => {
1867                if let Some(filtered) = self.get_filtered_stale_dirs() {
1868                    let max = filtered.len().saturating_sub(1);
1869                    self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
1870                }
1871            }
1872            View::Errors => {
1873                let max = self.warnings.len().saturating_sub(1);
1874                self.selected_warning = (self.selected_warning + 1).min(max);
1875            }
1876        }
1877    }
1878
1879    fn page_up(&mut self) {
1880        match self.view {
1881            View::Explorer => match self.layout_mode {
1882                LayoutMode::Tree => self.tree_state.move_up(PAGE_SIZE),
1883                LayoutMode::Miller => {
1884                    self.miller_state.move_up(PAGE_SIZE);
1885                    self.update_preview();
1886                }
1887            },
1888            View::Duplicates => {
1889                // Page up - move multiple groups up
1890                self.duplicates_state.selected_group =
1891                    self.duplicates_state.selected_group.saturating_sub(PAGE_SIZE);
1892            }
1893            View::Age => {
1894                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
1895            }
1896            View::Errors => {
1897                self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
1898            }
1899        }
1900    }
1901
1902    fn page_down(&mut self) {
1903        match self.view {
1904            View::Explorer => match self.layout_mode {
1905                LayoutMode::Tree => {
1906                    self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
1907                }
1908                LayoutMode::Miller => {
1909                    self.miller_state.move_down(PAGE_SIZE, self.cached_miller_len);
1910                    self.update_preview();
1911                }
1912            },
1913            View::Duplicates => {
1914                // Page down - move multiple groups down
1915                if let Some((filtered, _)) = self.get_filtered_duplicates() {
1916                    let max = filtered.len().saturating_sub(1);
1917                    self.duplicates_state.selected_group =
1918                        (self.duplicates_state.selected_group + PAGE_SIZE).min(max);
1919                }
1920            }
1921            View::Age => {
1922                if let Some(filtered) = self.get_filtered_stale_dirs() {
1923                    let max = filtered.len().saturating_sub(1);
1924                    self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
1925                }
1926            }
1927            View::Errors => {
1928                let max = self.warnings.len().saturating_sub(1);
1929                self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
1930            }
1931        }
1932    }
1933
1934    fn jump_to_top(&mut self) {
1935        match self.view {
1936            View::Explorer => match self.layout_mode {
1937                LayoutMode::Tree => self.tree_state.jump_to_top(),
1938                LayoutMode::Miller => {
1939                    self.miller_state.jump_to_top();
1940                    self.update_preview();
1941                }
1942            },
1943            View::Duplicates => self.duplicates_state.reset(),
1944            View::Age => self.selected_stale_dir = 0,
1945            View::Errors => self.selected_warning = 0,
1946        }
1947    }
1948
1949    fn jump_to_bottom(&mut self) {
1950        match self.view {
1951            View::Explorer => match self.layout_mode {
1952                LayoutMode::Tree => {
1953                    self.tree_state.jump_to_bottom(self.cached_tree_len);
1954                }
1955                LayoutMode::Miller => {
1956                    self.miller_state.jump_to_bottom(self.cached_miller_len);
1957                    self.update_preview();
1958                }
1959            },
1960            View::Duplicates => {
1961                if let Some((filtered, _)) = self.get_filtered_duplicates() {
1962                    self.duplicates_state.selected_group = filtered.len().saturating_sub(1);
1963                }
1964            }
1965            View::Age => {
1966                if let Some(filtered) = self.get_filtered_stale_dirs() {
1967                    self.selected_stale_dir = filtered.len().saturating_sub(1);
1968                }
1969            }
1970            View::Errors => {
1971                self.selected_warning = self.warnings.len().saturating_sub(1);
1972            }
1973        }
1974    }
1975
1976    fn expand_selected(&mut self) {
1977        if let Some((node, root_path)) = self.get_view_root_node() {
1978            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1979            if let Some(item) = items.get(self.tree_state.selected) {
1980                // Lazy load directory contents if directory has no children
1981                if matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
1982                    let needs_lazy_load = if let Some(tree) = &self.tree {
1983                        if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
1984                            node.children.is_empty()
1985                        } else {
1986                            false
1987                        }
1988                    } else {
1989                        false
1990                    };
1991                    if needs_lazy_load {
1992                        self.lazy_load_directory(&item.path);
1993                    }
1994                }
1995                self.tree_state.expand(&item.path);
1996                self.update_cached_tree_len();
1997            }
1998        }
1999    }
2000
2001    /// Collapse the selected item, or navigate back if already collapsed/not a directory.
2002    /// This provides vim-style navigation where 'h' goes up when there's nothing to collapse.
2003    fn collapse_selected(&mut self) {
2004        if let Some((node, root_path)) = self.get_view_root_node() {
2005            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2006            if let Some(item) = items.get(self.tree_state.selected) {
2007                // Check if this is an expanded directory
2008                let is_expanded_dir = matches!(
2009                    item.node.kind,
2010                    crate::ui::VisibleNodeKind::Directory { expanded: true }
2011                );
2012
2013                if is_expanded_dir {
2014                    // Collapse the directory
2015                    self.tree_state.collapse(&item.path);
2016                    self.update_cached_tree_len();
2017                } else {
2018                    // Not expanded or not a directory - navigate back to parent
2019                    self.navigate_back();
2020                }
2021            }
2022        }
2023    }
2024
2025    fn toggle_selected(&mut self) {
2026        if let Some((node, root_path)) = self.get_view_root_node() {
2027            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2028            if let Some(item) = items.get(self.tree_state.selected) {
2029                self.tree_state.toggle_expand(&item.path);
2030                self.update_cached_tree_len();
2031            }
2032        }
2033    }
2034
2035    fn get_selected_info(&self) -> Option<SelectedInfo> {
2036        let tree = self.tree.as_ref()?;
2037        let (view_node, view_path) = self.get_view_root_node()?;
2038
2039        match self.layout_mode {
2040            LayoutMode::Tree => {
2041                let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2042                let item = items.get(self.tree_state.selected)?;
2043                let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
2044
2045                Some(SelectedInfo {
2046                    name: item.node.name.clone(),
2047                    path: item.path.clone(),
2048                    size: node.size,
2049                    file_count: node.file_count(),
2050                    dir_count: node.dir_count(),
2051                    modified: node.timestamps.modified,
2052                    is_dir: node.is_dir(),
2053                })
2054            }
2055            LayoutMode::Miller => {
2056                let child = view_node.children.get(self.miller_state.selected)?;
2057                let path = self.view_root.join(&*child.name);
2058
2059                Some(SelectedInfo {
2060                    name: child.name.to_string().into(),
2061                    path,
2062                    size: child.size,
2063                    file_count: child.file_count(),
2064                    dir_count: child.dir_count(),
2065                    modified: child.timestamps.modified,
2066                    is_dir: child.is_dir(),
2067                })
2068            }
2069        }
2070    }
2071
2072    /// Check if the currently selected item is a file (not a directory).
2073    fn is_selected_file(&self) -> bool {
2074        match self.layout_mode {
2075            LayoutMode::Tree => {
2076                if let Some((view_node, view_path)) = self.get_view_root_node() {
2077                    let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2078                        .flatten(&self.tree_state);
2079                    if let Some(item) = items.get(self.tree_state.selected) {
2080                        return !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. });
2081                    }
2082                }
2083                false
2084            }
2085            LayoutMode::Miller => {
2086                if let Some((view_node, _)) = self.get_view_root_node() {
2087                    if let Some(child) = view_node.children.get(self.miller_state.selected) {
2088                        return !child.is_dir();
2089                    }
2090                }
2091                false
2092            }
2093        }
2094    }
2095
2096    /// Get the path of the currently selected item.
2097    fn get_selected_path(&self) -> Option<PathBuf> {
2098        match self.layout_mode {
2099            LayoutMode::Tree => {
2100                if let Some((view_node, view_path)) = self.get_view_root_node() {
2101                    let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2102                        .flatten(&self.tree_state);
2103                    return items.get(self.tree_state.selected).map(|item| item.path.clone());
2104                }
2105                None
2106            }
2107            LayoutMode::Miller => {
2108                if let Some((view_node, _)) = self.get_view_root_node() {
2109                    if let Some(child) = view_node.children.get(self.miller_state.selected) {
2110                        return Some(self.view_root.join(&*child.name));
2111                    }
2112                }
2113                None
2114            }
2115        }
2116    }
2117
2118    /// Open the currently selected file with the configured opener.
2119    fn open_selected_file(&mut self) {
2120        let Some(path) = self.get_selected_path() else {
2121            return;
2122        };
2123
2124        // Get opener configuration
2125        let openers = &self.user_settings.openers;
2126        let editor_config = &self.user_settings.editor;
2127
2128        match crate::opener::open_file(&path, openers, editor_config) {
2129            crate::opener::OpenResult::Opened => {
2130                // File opened with system command (non-blocking)
2131            }
2132            crate::opener::OpenResult::NeedsSuspend(cmd) => {
2133                // Need to suspend terminal and run command
2134                self.suspend_and_run(cmd);
2135            }
2136            crate::opener::OpenResult::NotSupported(msg) => {
2137                self.error = Some(msg);
2138            }
2139            crate::opener::OpenResult::Error(msg) => {
2140                self.error = Some(msg);
2141            }
2142        }
2143    }
2144
2145    /// Suspend the terminal, run a command, and restore.
2146    fn suspend_and_run(&mut self, cmd: std::process::Command) {
2147        // We need to restore terminal before running the command
2148        // and reinitialize after. This is handled by setting a flag
2149        // that the main loop will process.
2150        self.pending_suspend_command = Some(cmd);
2151    }
2152
2153    /// Drill into the currently selected directory.
2154    fn drill_into_selected(&mut self) {
2155        let Some((view_node, view_path)) = self.get_view_root_node() else {
2156            return;
2157        };
2158
2159        let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2160        let Some(item) = items.get(self.tree_state.selected) else {
2161            return;
2162        };
2163
2164        if !matches!(
2165            item.node.kind,
2166            crate::ui::VisibleNodeKind::Directory { .. }
2167        ) {
2168            return;
2169        }
2170
2171        // Check if directory needs lazy loading (has no children loaded yet)
2172        let needs_lazy_load = if let Some(tree) = &self.tree {
2173            if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2174                node.children.is_empty()
2175            } else {
2176                true
2177            }
2178        } else {
2179            true
2180        };
2181
2182        // Lazy load directory contents if needed
2183        if needs_lazy_load {
2184            self.lazy_load_directory(&item.path);
2185        }
2186
2187        if item.path == self.view_root {
2188            self.tree_state.expand(&item.path);
2189            self.update_cached_tree_len();
2190            return;
2191        }
2192
2193        let saved_expanded = self.tree_state.expanded.clone();
2194        let saved_selected = self.tree_state.selected;
2195        self.view_history
2196            .push((self.view_root.clone(), saved_selected, saved_expanded));
2197
2198        let target_path = item.path.clone();
2199
2200        // Check if forward history has this path - if so, restore that state
2201        if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2202            let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2203            // Clear any forward history entries after this one
2204            self.forward_history.truncate(forward_idx);
2205
2206            self.view_root = target_path;
2207            self.tree_state.selected = fwd_selected;
2208            self.tree_state.offset = 0;
2209            self.tree_state.expanded = fwd_expanded;
2210            self.tree_state.expand(&self.view_root);
2211            self.update_cached_tree_len();
2212            self.sync_to_active_tab();
2213            return;
2214        }
2215
2216        // Clear forward history - user is navigating to a new location
2217        self.forward_history.clear();
2218
2219        self.view_root = target_path;
2220
2221        self.tree_state.selected = 0;
2222        self.tree_state.offset = 0;
2223        self.tree_state.expand(&self.view_root);
2224        self.update_cached_tree_len();
2225        self.sync_to_active_tab();
2226    }
2227
2228    /// Drill into the selected directory in Miller columns mode.
2229    fn drill_into_miller_selected(&mut self) {
2230        let Some((view_node, _)) = self.get_view_root_node() else {
2231            return;
2232        };
2233
2234        let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
2235            return;
2236        };
2237
2238        // Only drill into directories
2239        if !selected_child.is_dir() {
2240            return;
2241        }
2242
2243        let target_path = self.view_root.join(&*selected_child.name);
2244
2245        // Lazy loading: if directory has no children (from quick_list), load them now
2246        if selected_child.children.is_empty() {
2247            self.lazy_load_directory(&target_path);
2248        }
2249
2250        // Save current state to history
2251        let saved_expanded = self.tree_state.expanded.clone();
2252        let saved_selected = self.miller_state.selected;
2253        self.view_history
2254            .push((self.view_root.clone(), saved_selected, saved_expanded));
2255
2256        // Check if forward history has this path - if so, restore that state
2257        if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2258            let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2259            // Clear any forward history entries after this one
2260            self.forward_history.truncate(forward_idx);
2261
2262            self.view_root = target_path;
2263            self.miller_state.selected = fwd_selected;
2264            self.miller_state.offset = 0;
2265            self.tree_state.expanded = fwd_expanded;
2266            self.update_cached_miller_len();
2267            self.miller_state.ensure_visible(20);
2268            self.update_preview();
2269            self.sync_to_active_tab();
2270            return;
2271        }
2272
2273        // Clear forward history - user is navigating to a new location
2274        self.forward_history.clear();
2275
2276        // Update view root
2277        self.view_root = target_path;
2278        self.miller_state.reset();
2279        self.update_cached_miller_len();
2280        self.update_preview();
2281        self.sync_to_active_tab();
2282    }
2283
2284    /// Lazy load a directory's children using quick_list.
2285    /// This updates the tree in-place with the new children.
2286    fn lazy_load_directory(&mut self, path: &PathBuf) {
2287        // First check if we have cached scan data for this directory
2288        if let Some(cached_tree) = self.scanned_cache.get(path).cloned() {
2289            // Use cached scan data - it has full size/structure information
2290            if let Some(tree) = &mut self.tree {
2291                if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2292                    // Replace with the cached node (preserves all scan data)
2293                    *node = cached_tree.root;
2294                }
2295            }
2296            return;
2297        }
2298
2299        // Quick load directory contents for immediate display
2300        // When the full scan completes, it will replace this with accurate data
2301        if let Ok(quick_tree) = gravityfile_scan::quick_list(path) {
2302            // Find the node in our tree and update its children
2303            if let Some(tree) = &mut self.tree {
2304                if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2305                    node.children = quick_tree.root.children;
2306                }
2307            }
2308        }
2309    }
2310
2311    /// Find a mutable reference to a node by path.
2312    fn find_node_mut<'a>(
2313        node: &'a mut gravityfile_core::FileNode,
2314        target: &PathBuf,
2315        current_path: &PathBuf,
2316    ) -> Option<&'a mut gravityfile_core::FileNode> {
2317        if current_path == target {
2318            return Some(node);
2319        }
2320
2321        for child in &mut node.children {
2322            let child_path = current_path.join(&*child.name);
2323            if target.starts_with(&child_path) {
2324                if let Some(found) = Self::find_node_mut(child, target, &child_path) {
2325                    return Some(found);
2326                }
2327            }
2328        }
2329
2330        None
2331    }
2332
2333    /// Navigate back up to the previous view root.
2334    /// Supports navigation beyond the scan root, all the way to filesystem root.
2335    fn navigate_back(&mut self) {
2336        // Prepare current state for forward history (will be pushed if navigation succeeds)
2337        let current_selected = match self.layout_mode {
2338            LayoutMode::Tree => self.tree_state.selected,
2339            LayoutMode::Miller => self.miller_state.selected,
2340        };
2341        let current_state = (
2342            self.view_root.clone(),
2343            current_selected,
2344            self.tree_state.expanded.clone(),
2345        );
2346
2347        // First, try to use view history if available
2348        if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
2349            // Navigation will succeed - save current state to forward history
2350            self.forward_history.push(current_state);
2351            self.view_root = prev_root;
2352            self.tree_state.expanded = saved_expanded;
2353
2354            // Restore selection based on layout mode
2355            match self.layout_mode {
2356                LayoutMode::Tree => {
2357                    self.tree_state.selected = saved_selected;
2358                    self.tree_state.offset = 0;
2359                    self.update_cached_tree_len();
2360                }
2361                LayoutMode::Miller => {
2362                    self.miller_state.selected = saved_selected;
2363                    self.miller_state.offset = 0;
2364                    self.update_cached_miller_len();
2365                }
2366            }
2367            self.sync_to_active_tab();
2368            return;
2369        }
2370
2371        // No history - try to navigate to parent
2372        let Some(parent) = self.view_root.parent() else {
2373            return; // Already at root
2374        };
2375
2376        let parent = parent.to_path_buf();
2377        let dir_we_came_from = self.view_root.clone();
2378
2379        // Navigation will succeed - save current state to forward history
2380        self.forward_history.push(current_state);
2381
2382        // Check if parent is within our current tree
2383        let parent_in_tree = parent.starts_with(&self.path) || parent == self.path;
2384
2385        if parent_in_tree {
2386            // Navigate within existing tree
2387            self.tree_state.collapse(&dir_we_came_from);
2388            self.view_root = parent;
2389            self.tree_state.expand(&self.view_root);
2390        } else {
2391            // Navigate beyond scan root - need to extend our tree
2392            self.navigate_to_parent_beyond_scan_root(&parent, &dir_we_came_from);
2393            return;
2394        }
2395
2396        // Select the directory we just came from
2397        self.select_child_by_name(&dir_we_came_from);
2398        self.sync_to_active_tab();
2399    }
2400
2401    /// Navigate to a parent directory that's outside the current scan root.
2402    /// This extends the tree by lazy-loading the parent.
2403    fn navigate_to_parent_beyond_scan_root(&mut self, parent: &PathBuf, dir_we_came_from: &PathBuf) {
2404        // Cache current tree before navigating away (if we have scan data)
2405        if let Some(tree) = &self.tree {
2406            if self.has_full_scan {
2407                self.scanned_cache.insert(tree.root_path.clone(), tree.clone());
2408            }
2409        }
2410
2411        // Load the parent directory contents
2412        let Ok(mut parent_tree) = gravityfile_scan::quick_list(parent) else {
2413            return; // Can't access parent directory
2414        };
2415
2416        // Merge any cached scans into the parent tree's children
2417        self.merge_cached_scans_into_tree(&mut parent_tree.root, parent);
2418
2419        // Create a new tree rooted at the parent
2420        self.path = parent.clone();
2421        self.tree = Some(parent_tree);
2422        self.view_root = parent.clone();
2423        self.view_history.clear();
2424        // Note: don't clear forward_history here - we want to allow navigating back
2425
2426        // The parent tree is quick-listed, not fully scanned
2427        self.has_full_scan = false;
2428
2429        // Reset tree state for the new root
2430        self.tree_state = TreeState::new(parent.clone());
2431
2432        // Update cached parent for Miller columns display
2433        self.update_cached_parent();
2434
2435        // Select the directory we came from
2436        self.select_child_by_name(dir_we_came_from);
2437        self.sync_to_active_tab();
2438    }
2439
2440    /// Merge cached scan data into a tree node's children.
2441    /// For each child directory in the node, if we have cached scan data,
2442    /// replace the placeholder child with the fully scanned version.
2443    fn merge_cached_scans_into_tree(&self, node: &mut gravityfile_core::FileNode, node_path: &PathBuf) {
2444        for child in &mut node.children {
2445            if child.is_dir() {
2446                let child_path = node_path.join(&*child.name);
2447                if let Some(cached_tree) = self.scanned_cache.get(&child_path) {
2448                    // Replace this child with the cached scan data
2449                    *child = cached_tree.root.clone();
2450                }
2451            }
2452        }
2453    }
2454
2455    /// Select a child entry by matching its name.
2456    fn select_child_by_name(&mut self, target_path: &PathBuf) {
2457        let dir_name = target_path
2458            .file_name()
2459            .and_then(|n| n.to_str())
2460            .unwrap_or("");
2461
2462        match self.layout_mode {
2463            LayoutMode::Tree => {
2464                // In tree view, find the index of the target in flattened list
2465                if let Some((node, root_path)) = self.get_view_root_node() {
2466                    let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard)
2467                        .flatten(&self.tree_state);
2468                    if let Some(idx) = items.iter().position(|item| {
2469                        item.path.file_name().and_then(|n| n.to_str()) == Some(dir_name)
2470                    }) {
2471                        self.tree_state.selected = idx;
2472                    } else {
2473                        self.tree_state.selected = 0;
2474                    }
2475                } else {
2476                    self.tree_state.selected = 0;
2477                }
2478                self.tree_state.offset = 0;
2479                self.update_cached_tree_len();
2480            }
2481            LayoutMode::Miller => {
2482                // In miller view, find the index in children
2483                if let Some((node, _)) = self.get_view_root_node() {
2484                    let idx = node
2485                        .children
2486                        .iter()
2487                        .position(|c| c.name.as_str() == dir_name)
2488                        .unwrap_or(0);
2489                    self.miller_state.selected = idx;
2490                } else {
2491                    self.miller_state.selected = 0;
2492                }
2493                self.miller_state.offset = 0;
2494                self.update_cached_miller_len();
2495            }
2496        }
2497    }
2498
2499    /// Find a node by path in the tree.
2500    fn find_node_at_path<'a>(
2501        node: &'a gravityfile_core::FileNode,
2502        target_path: &PathBuf,
2503        current_path: &PathBuf,
2504    ) -> Option<&'a gravityfile_core::FileNode> {
2505        if current_path == target_path {
2506            return Some(node);
2507        }
2508        for child in &node.children {
2509            let child_path = current_path.join(&*child.name);
2510            if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
2511                return Some(found);
2512            }
2513        }
2514        None
2515    }
2516
2517    /// Get the current view root node.
2518    fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
2519        let tree = self.tree.as_ref()?;
2520        let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
2521        Some((node, self.view_root.clone()))
2522    }
2523
2524    /// Get the parent node of the current view root (for Miller columns).
2525    fn get_parent_node(&self) -> Option<&gravityfile_core::FileNode> {
2526        let tree = self.tree.as_ref()?;
2527        let parent_path = self.view_root.parent()?.to_path_buf();
2528
2529        // First, check if parent is within the main tree
2530        if parent_path.starts_with(&tree.root_path) || parent_path == tree.root_path {
2531            return Self::find_node_at_path(&tree.root, &parent_path, &tree.root_path);
2532        }
2533
2534        // If we're at the tree root and have a cached parent, use that
2535        if self.view_root == tree.root_path {
2536            if let Some(cached) = &self.cached_parent_tree {
2537                return Some(&cached.root);
2538            }
2539        }
2540
2541        None
2542    }
2543
2544    /// Update the cached parent tree for Miller columns display.
2545    /// Called when navigating to ensure the parent column can be shown.
2546    fn update_cached_parent(&mut self) {
2547        let Some(tree) = &self.tree else { return };
2548
2549        // Only need to cache parent if we're at or near the tree root
2550        if self.view_root == tree.root_path {
2551            if let Some(parent_path) = self.view_root.parent() {
2552                let parent_path_buf = parent_path.to_path_buf();
2553                // Load the parent directory
2554                if let Ok(mut parent_tree) = gravityfile_scan::quick_list(&parent_path_buf) {
2555                    // Merge any cached scans into the parent tree's children
2556                    // This ensures previously scanned directories show their data
2557                    self.merge_cached_scans_into_tree(&mut parent_tree.root, &parent_path_buf);
2558                    self.cached_parent_tree = Some(parent_tree);
2559                }
2560            }
2561        } else {
2562            // Don't need cached parent when navigating within the tree
2563            self.cached_parent_tree = None;
2564        }
2565    }
2566
2567    /// Get the current directory name for highlighting in parent column.
2568    fn get_current_dir_name(&self) -> Option<String> {
2569        self.view_root
2570            .file_name()
2571            .and_then(|n| n.to_str())
2572            .map(|s| s.to_string())
2573    }
2574
2575    /// Handle raw key input for command mode.
2576    fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
2577        match self.command_input.handle_key(key) {
2578            CommandKeyResult::Continue => {}
2579            CommandKeyResult::Cancel => {
2580                self.mode = AppMode::Normal;
2581            }
2582            CommandKeyResult::Execute(cmd) => {
2583                self.mode = AppMode::Normal;
2584                self.execute_command(&cmd);
2585            }
2586        }
2587    }
2588
2589    /// Execute a command from the command palette.
2590    fn execute_command(&mut self, cmd: &str) {
2591        match parse_command(cmd) {
2592            CommandAction::None => {}
2593            CommandAction::Quit => {
2594                self.mode = AppMode::Quit;
2595            }
2596            CommandAction::Refresh => {
2597                self.marked.clear();
2598                self.start_scan();
2599            }
2600            CommandAction::NavigateTo(target) => {
2601                self.cd_to_path(&target);
2602            }
2603            CommandAction::GoToRoot => {
2604                self.go_to_root();
2605            }
2606            CommandAction::NavigateBack => {
2607                self.navigate_back();
2608            }
2609            CommandAction::ShowHelp => {
2610                self.mode = AppMode::Help;
2611            }
2612            CommandAction::SwitchView(view) => {
2613                self.view = view;
2614            }
2615            CommandAction::ClearMarks => {
2616                self.marked.clear();
2617            }
2618            CommandAction::ToggleDetails => {
2619                self.show_details = !self.show_details;
2620            }
2621            CommandAction::SetTheme(theme_cmd) => match theme_cmd {
2622                ThemeCommand::Dark => self.theme = Theme::dark(),
2623                ThemeCommand::Light => self.theme = Theme::light(),
2624                ThemeCommand::Toggle => self.theme = self.theme.toggle(),
2625            },
2626            CommandAction::SetLayout(layout_cmd) => {
2627                match layout_cmd {
2628                    LayoutCommand::Tree => {
2629                        if self.layout_mode != LayoutMode::Tree {
2630                            // Use sync to preserve selection when switching to tree
2631                            self.sync_layout_selection();
2632                        }
2633                    }
2634                    LayoutCommand::Miller => {
2635                        if self.layout_mode != LayoutMode::Miller {
2636                            // Use sync to preserve selection when switching to miller
2637                            self.sync_layout_selection();
2638                        }
2639                    }
2640                    LayoutCommand::Toggle => {
2641                        self.sync_layout_selection();
2642                    }
2643                }
2644            }
2645            CommandAction::SetSort(sort_cmd) => {
2646                match sort_cmd {
2647                    SortCommand::SizeDesc => self.sort_mode = SortMode::SizeDescending,
2648                    SortCommand::SizeAsc => self.sort_mode = SortMode::SizeAscending,
2649                    SortCommand::NameAsc => self.sort_mode = SortMode::NameAscending,
2650                    SortCommand::NameDesc => self.sort_mode = SortMode::NameDescending,
2651                    SortCommand::DateDesc => self.sort_mode = SortMode::ModifiedDescending,
2652                    SortCommand::DateAsc => self.sort_mode = SortMode::ModifiedAscending,
2653                    SortCommand::CountDesc => self.sort_mode = SortMode::CountDescending,
2654                    SortCommand::CountAsc => self.sort_mode = SortMode::CountAscending,
2655                    SortCommand::Cycle => self.sort_mode = self.sort_mode.next(),
2656                    SortCommand::Reverse => self.sort_mode = self.sort_mode.reverse(),
2657                }
2658                self.apply_sort();
2659            }
2660            CommandAction::Yank => {
2661                self.yank_selection();
2662            }
2663            CommandAction::Cut => {
2664                self.cut_selection();
2665            }
2666            CommandAction::Paste => {
2667                self.paste_clipboard();
2668            }
2669            CommandAction::Delete => {
2670                if !self.marked.is_empty() {
2671                    self.mode = AppMode::ConfirmDelete;
2672                }
2673            }
2674            CommandAction::Rename(name) => {
2675                if let Some(name) = name {
2676                    // Direct rename with provided name
2677                    if let Some(path) = self.get_selected_path() {
2678                        self.execute_rename(path, name);
2679                    }
2680                } else {
2681                    // Enter rename mode
2682                    self.start_rename();
2683                }
2684            }
2685            CommandAction::CreateFile(name) => {
2686                if let Some(name) = name {
2687                    // Direct create with provided name
2688                    self.execute_create_file(self.view_root.clone(), name);
2689                } else {
2690                    // Enter create file mode
2691                    self.start_create_file();
2692                }
2693            }
2694            CommandAction::CreateDirectory(name) => {
2695                if let Some(name) = name {
2696                    // Direct create with provided name
2697                    self.execute_create_directory(self.view_root.clone(), name);
2698                } else {
2699                    // Enter create directory mode
2700                    self.start_create_directory();
2701                }
2702            }
2703            CommandAction::Take(name) => {
2704                if let Some(name) = name {
2705                    // Direct take with provided name
2706                    self.execute_take(self.view_root.clone(), name);
2707                } else {
2708                    // Enter take mode
2709                    self.start_take();
2710                }
2711            }
2712            CommandAction::Undo => {
2713                self.execute_undo();
2714            }
2715        }
2716    }
2717
2718    /// Go to the scan root.
2719    fn go_to_root(&mut self) {
2720        self.view_root = self.path.clone();
2721        self.view_history.clear();
2722        self.forward_history.clear();
2723        self.tree_state.selected = 0;
2724        self.tree_state.offset = 0;
2725        self.tree_state.expand(&self.view_root);
2726        self.update_cached_tree_len();
2727        self.sync_to_active_tab();
2728    }
2729
2730    /// Change directory to a path.
2731    fn cd_to_path(&mut self, target: &str) {
2732        if target == "/" || target == "~" {
2733            self.go_to_root();
2734            return;
2735        }
2736        if target == ".." {
2737            self.navigate_back();
2738            return;
2739        }
2740
2741        let Some(tree) = &self.tree else {
2742            return;
2743        };
2744
2745        let target_path = if target.starts_with('/') {
2746            let relative = target.trim_start_matches('/');
2747            self.path.join(relative)
2748        } else {
2749            self.view_root.join(target)
2750        };
2751
2752        if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
2753            if node.is_dir() {
2754                let saved_expanded = self.tree_state.expanded.clone();
2755                let saved_selected = self.tree_state.selected;
2756                self.view_history
2757                    .push((self.view_root.clone(), saved_selected, saved_expanded));
2758
2759                self.view_root = target_path;
2760                self.tree_state.selected = 0;
2761                self.tree_state.offset = 0;
2762                self.tree_state.expand(&self.view_root);
2763                self.update_cached_tree_len();
2764                self.sync_to_active_tab();
2765            }
2766        }
2767    }
2768
2769    /// Check if a path is marked for deletion.
2770    pub fn is_marked(&self, path: &PathBuf) -> bool {
2771        self.marked.contains(path)
2772    }
2773
2774    /// Cycle to the next sort mode and apply sorting.
2775    fn cycle_sort_mode(&mut self) {
2776        self.sort_mode = self.sort_mode.next();
2777        self.apply_sort();
2778    }
2779
2780    /// Reverse the current sort direction.
2781    fn reverse_sort_mode(&mut self) {
2782        self.sort_mode = self.sort_mode.reverse();
2783        self.apply_sort();
2784    }
2785
2786    /// Sync current tab state to the active tab.
2787    fn sync_to_active_tab(&mut self) {
2788        if let Some(tab) = self.tab_manager.active_tab_mut() {
2789            tab.view_root = self.view_root.clone();
2790            tab.label = self.view_root
2791                .file_name()
2792                .map(|n| n.to_string_lossy().to_string())
2793                .unwrap_or_else(|| self.view_root.to_string_lossy().to_string());
2794        }
2795    }
2796
2797    /// Sync app state from the active tab.
2798    fn sync_from_active_tab(&mut self) {
2799        if let Some(tab) = self.tab_manager.active_tab() {
2800            // Only sync if view_root is different
2801            if tab.view_root != self.view_root {
2802                self.view_root = tab.view_root.clone();
2803                // Reset tree state for the new view
2804                self.tree_state.selected = 0;
2805                self.tree_state.offset = 0;
2806                self.tree_state.expand(&self.view_root);
2807                self.miller_state.reset();
2808                match self.layout_mode {
2809                    LayoutMode::Tree => self.update_cached_tree_len(),
2810                    LayoutMode::Miller => self.update_cached_miller_len(),
2811                }
2812            }
2813        }
2814    }
2815
2816    /// Start fuzzy search mode.
2817    fn start_search(&mut self) {
2818        // Populate search paths from the current tree
2819        self.update_search_paths();
2820        self.search_state.activate();
2821        self.mode = AppMode::Search;
2822    }
2823
2824    /// Update search paths from the file tree.
2825    fn update_search_paths(&mut self) {
2826        let mut paths = Vec::new();
2827
2828        if let Some(tree) = &self.tree {
2829            Self::collect_paths_recursive(
2830                &tree.root,
2831                &tree.root_path,
2832                &self.view_root,
2833                &mut paths,
2834            );
2835        }
2836
2837        self.search_state.update_paths(paths);
2838    }
2839
2840    /// Recursively collect paths from the file tree.
2841    fn collect_paths_recursive(
2842        node: &gravityfile_core::FileNode,
2843        current_path: &PathBuf,
2844        view_root: &PathBuf,
2845        paths: &mut Vec<(PathBuf, String, bool)>,
2846    ) {
2847        // Only include paths under view_root
2848        if !current_path.starts_with(view_root) && current_path != view_root {
2849            return;
2850        }
2851
2852        // Compute relative display path from view_root
2853        let display = if current_path == view_root {
2854            ".".to_string()
2855        } else {
2856            current_path
2857                .strip_prefix(view_root)
2858                .map(|p| p.to_string_lossy().to_string())
2859                .unwrap_or_else(|_| node.name.to_string())
2860        };
2861
2862        paths.push((current_path.clone(), display, node.is_dir()));
2863
2864        // Recurse into children
2865        for child in &node.children {
2866            let child_path = current_path.join(&*child.name);
2867            Self::collect_paths_recursive(child, &child_path, view_root, paths);
2868        }
2869    }
2870
2871    /// Handle key input during search mode.
2872    fn handle_search_input(&mut self, key: crossterm::event::KeyEvent) {
2873        use crossterm::event::{KeyCode, KeyModifiers};
2874
2875        match (key.code, key.modifiers) {
2876            // Cancel search
2877            (KeyCode::Esc, _) => {
2878                self.search_state.deactivate();
2879                self.mode = AppMode::Normal;
2880            }
2881            // Execute search (navigate to selected)
2882            (KeyCode::Enter, _) => {
2883                if let Some(result) = self.search_state.selected_result() {
2884                    let target_path = result.path.clone();
2885                    self.navigate_to_search_result(&target_path);
2886                }
2887                self.search_state.deactivate();
2888                self.mode = AppMode::Normal;
2889            }
2890            // Navigation in results
2891            (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
2892                self.search_state.move_up();
2893            }
2894            (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
2895                self.search_state.move_down();
2896            }
2897            // Cycle search mode
2898            (KeyCode::Tab, _) => {
2899                self.search_state.cycle_mode();
2900            }
2901            // Cursor movement
2902            (KeyCode::Left, _) => {
2903                self.search_state.move_cursor_left();
2904            }
2905            (KeyCode::Right, _) => {
2906                self.search_state.move_cursor_right();
2907            }
2908            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
2909                self.search_state.move_cursor_start();
2910            }
2911            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
2912                self.search_state.move_cursor_end();
2913            }
2914            // Delete
2915            (KeyCode::Backspace, _) => {
2916                self.search_state.delete_char_before();
2917            }
2918            (KeyCode::Delete, _) => {
2919                self.search_state.delete_char_at();
2920            }
2921            // Clear line
2922            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
2923                self.search_state.clear_query();
2924            }
2925            // Type character
2926            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2927                self.search_state.insert_char(c);
2928            }
2929            _ => {}
2930        }
2931    }
2932
2933    /// Navigate to a search result path.
2934    fn navigate_to_search_result(&mut self, target: &PathBuf) {
2935        // If it's a directory, drill into it
2936        // If it's a file, navigate to its parent and select it
2937        if target.is_dir() {
2938            // Save current state
2939            let saved_expanded = self.tree_state.expanded.clone();
2940            let saved_selected = match self.layout_mode {
2941                LayoutMode::Tree => self.tree_state.selected,
2942                LayoutMode::Miller => self.miller_state.selected,
2943            };
2944            self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2945
2946            // Navigate to directory
2947            self.view_root = target.clone();
2948            self.tree_state.selected = 0;
2949            self.tree_state.offset = 0;
2950            self.tree_state.expand(&self.view_root);
2951            self.miller_state.reset();
2952
2953            match self.layout_mode {
2954                LayoutMode::Tree => self.update_cached_tree_len(),
2955                LayoutMode::Miller => self.update_cached_miller_len(),
2956            }
2957            self.sync_to_active_tab();
2958        } else {
2959            // It's a file - navigate to parent and select it
2960            if let Some(parent) = target.parent() {
2961                // Save current state
2962                let saved_expanded = self.tree_state.expanded.clone();
2963                let saved_selected = match self.layout_mode {
2964                    LayoutMode::Tree => self.tree_state.selected,
2965                    LayoutMode::Miller => self.miller_state.selected,
2966                };
2967                self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2968
2969                // Navigate to parent
2970                self.view_root = parent.to_path_buf();
2971                self.tree_state.expand(&self.view_root);
2972
2973                // Get file name before borrowing for position search
2974                let file_name = target.file_name().and_then(|n| n.to_str()).map(|s| s.to_string());
2975                let target_clone = target.clone();
2976
2977                match self.layout_mode {
2978                    LayoutMode::Tree => {
2979                        // For tree view, we need to find the index in flattened list
2980                        self.tree_state.selected = 0;
2981                        self.update_cached_tree_len();
2982
2983                        // Find position in flattened tree
2984                        if let Some((node, _)) = self.get_view_root_node() {
2985                            let items = TreeView::new(node, &self.view_root, &self.theme, &self.marked, &self.clipboard)
2986                                .flatten(&self.tree_state);
2987                            if let Some(pos) = items.iter().position(|i| i.path == target_clone) {
2988                                self.tree_state.selected = pos;
2989                            }
2990                        }
2991                    }
2992                    LayoutMode::Miller => {
2993                        // Find the index of the file in the parent's children
2994                        if let (Some(file_name), Some((node, _))) = (&file_name, self.get_view_root_node()) {
2995                            let idx = node
2996                                .children
2997                                .iter()
2998                                .position(|c| c.name.as_str() == file_name)
2999                                .unwrap_or(0);
3000                            self.miller_state.selected = idx;
3001                        }
3002                        self.update_cached_miller_len();
3003                    }
3004                }
3005                self.sync_to_active_tab();
3006            }
3007        }
3008    }
3009
3010    /// Handle key input during settings mode.
3011    fn handle_settings_input(&mut self, action: KeyAction) {
3012        match action {
3013            KeyAction::Cancel | KeyAction::Quit => {
3014                // Close settings without saving
3015                self.settings_state = None;
3016                self.mode = AppMode::Normal;
3017            }
3018            KeyAction::MoveUp => {
3019                if let Some(ref mut state) = self.settings_state {
3020                    state.move_up();
3021                }
3022            }
3023            KeyAction::MoveDown => {
3024                if let Some(ref mut state) = self.settings_state {
3025                    state.move_down();
3026                }
3027            }
3028            KeyAction::ToggleMark | KeyAction::DrillDown => {
3029                // Space or Enter toggles the current setting
3030                if let Some(ref mut state) = self.settings_state {
3031                    state.toggle_selected();
3032                }
3033            }
3034            KeyAction::Sort => {
3035                // 's' saves settings
3036                if let Some(ref state) = self.settings_state {
3037                    if state.dirty {
3038                        // Save settings to disk
3039                        self.user_settings = state.settings.clone();
3040                        if let Err(e) = self.user_settings.save() {
3041                            self.error = Some(format!("Failed to save settings: {}", e));
3042                        } else {
3043                            // Apply settings immediately where applicable
3044                            self.scan_on_startup = self.user_settings.scan_on_startup;
3045                        }
3046                    }
3047                }
3048                self.settings_state = None;
3049                self.mode = AppMode::Normal;
3050            }
3051            _ => {}
3052        }
3053    }
3054
3055    /// Apply the current sort mode to the file tree.
3056    fn apply_sort(&mut self) {
3057        if let Some(ref mut tree) = self.tree {
3058            Self::sort_node_recursive(&mut tree.root, self.sort_mode);
3059            // Update cached lengths after sorting
3060            match self.layout_mode {
3061                LayoutMode::Tree => self.update_cached_tree_len(),
3062                LayoutMode::Miller => self.update_cached_miller_len(),
3063            }
3064        }
3065    }
3066
3067    /// Recursively sort a node and all its children.
3068    fn sort_node_recursive(node: &mut gravityfile_core::FileNode, mode: SortMode) {
3069        // Sort children based on mode
3070        node.children.sort_by(|a, b| {
3071            match mode {
3072                SortMode::SizeDescending => b.size.cmp(&a.size),
3073                SortMode::SizeAscending => a.size.cmp(&b.size),
3074                SortMode::NameAscending => a.name.cmp(&b.name),
3075                SortMode::NameDescending => b.name.cmp(&a.name),
3076                SortMode::ModifiedDescending => b.timestamps.modified.cmp(&a.timestamps.modified),
3077                SortMode::ModifiedAscending => a.timestamps.modified.cmp(&b.timestamps.modified),
3078                SortMode::CountDescending => {
3079                    let a_count = a.children.len();
3080                    let b_count = b.children.len();
3081                    b_count.cmp(&a_count)
3082                }
3083                SortMode::CountAscending => {
3084                    let a_count = a.children.len();
3085                    let b_count = b.children.len();
3086                    a_count.cmp(&b_count)
3087                }
3088            }
3089        });
3090
3091        // Recursively sort children
3092        for child in &mut node.children {
3093            Self::sort_node_recursive(child, mode);
3094        }
3095    }
3096
3097    /// Get duplicate groups filtered to current view_root.
3098    fn get_filtered_duplicates(
3099        &self,
3100    ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
3101        let dups = self.duplicates.as_ref()?;
3102
3103        if self.view_root == self.path {
3104            let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
3105            return Some((dups.groups.iter().collect(), total));
3106        }
3107
3108        let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
3109            .groups
3110            .iter()
3111            .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
3112            .collect();
3113
3114        let total_wasted: u64 = filtered
3115            .iter()
3116            .map(|g| {
3117                let paths_in_view = g
3118                    .paths
3119                    .iter()
3120                    .filter(|p| p.starts_with(&self.view_root))
3121                    .count();
3122                if paths_in_view > 1 {
3123                    g.size * (paths_in_view as u64 - 1)
3124                } else {
3125                    0
3126                }
3127            })
3128            .sum();
3129
3130        Some((filtered, total_wasted))
3131    }
3132
3133    /// Get stale directories filtered to current view_root.
3134    fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
3135        let age = self.age_report.as_ref()?;
3136
3137        if self.view_root == self.path {
3138            return Some(age.stale_directories.iter().collect());
3139        }
3140
3141        let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
3142            .stale_directories
3143            .iter()
3144            .filter(|d| d.path.starts_with(&self.view_root))
3145            .collect();
3146
3147        Some(filtered)
3148    }
3149}
3150
3151impl Widget for &App {
3152    fn render(self, area: Rect, buf: &mut Buffer) {
3153        let ctx = RenderContext {
3154            mode: self.mode,
3155            view: self.view,
3156            theme: &self.theme,
3157            path: &self.path,
3158            view_root: &self.view_root,
3159            show_details: self.show_details,
3160            tree: self.tree.as_ref(),
3161            tree_state: &self.tree_state,
3162            layout_mode: self.layout_mode,
3163            miller_state: &self.miller_state,
3164            scan_progress: self.scan_progress.as_ref(),
3165            deletion_progress: self.deletion_progress.as_ref(),
3166            duplicates: self.duplicates.as_ref(),
3167            age_report: self.age_report.as_ref(),
3168            warnings: &self.warnings,
3169            duplicates_state: &self.duplicates_state,
3170            selected_stale_dir: self.selected_stale_dir,
3171            selected_warning: self.selected_warning,
3172            marked: &self.marked,
3173            deletion_message: self.deletion_message.as_ref(),
3174            operation_message: self.operation_message.as_ref(),
3175            error: self.error.as_deref(),
3176            command_input: self.command_input.buffer(),
3177            command_cursor: self.command_input.cursor(),
3178            input_state: self.input_state.as_ref(),
3179            operation_progress: self.operation_progress.as_ref(),
3180            pending_conflict: self.pending_conflict.as_ref(),
3181            clipboard: &self.clipboard,
3182            get_path_size: Box::new(|p| self.get_path_size(p)),
3183            get_selected_info: self.get_selected_info(),
3184            get_view_root_node: self.get_view_root_node(),
3185            get_parent_node: self.get_parent_node(),
3186            current_dir_name: self.get_current_dir_name(),
3187            get_filtered_duplicates: self.get_filtered_duplicates(),
3188            get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
3189            sort_mode: self.sort_mode,
3190            search_state: &self.search_state,
3191            tab_manager: &self.tab_manager,
3192            preview_content: &self.preview_state.content,
3193            preview_mode: self.preview_state.mode,
3194            has_full_scan: self.has_full_scan,
3195            settings_state: self.settings_state.as_ref(),
3196        };
3197
3198        render_app(&ctx, area, buf);
3199    }
3200}