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 based on current view
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 self.view == View::Errors {
937                        // In Errors view, get the selected warning's path
938                        if let Some(warning) = self.warnings.get(self.selected_warning) {
939                            // Only allow deleting broken symlinks, not permission errors etc.
940                            if warning.kind == gravityfile_core::WarningKind::BrokenSymlink {
941                                self.marked.insert(warning.path.clone());
942                            }
943                        }
944                    } else if let Some(path) = self.get_selected_path() {
945                        self.marked.insert(path);
946                    }
947                }
948                if !self.marked.is_empty() {
949                    self.mode = AppMode::ConfirmDelete;
950                }
951            }
952            KeyAction::Rename => {
953                self.start_rename();
954            }
955            KeyAction::CreateFile => {
956                self.start_create_file();
957            }
958            KeyAction::CreateDirectory => {
959                self.start_create_directory();
960            }
961            KeyAction::Take => {
962                self.start_take();
963            }
964            KeyAction::GoTo => {
965                self.start_goto();
966            }
967            KeyAction::Undo => {
968                self.execute_undo();
969            }
970
971            KeyAction::Confirm => {
972                if !self.marked.is_empty() {
973                    self.mode = AppMode::ConfirmDelete;
974                } else if self.view == View::Explorer {
975                    self.toggle_selected();
976                }
977            }
978
979            KeyAction::DrillDown | KeyAction::OpenFile => {
980                if self.view == View::Explorer {
981                    // Check if selected item is a file or directory
982                    let is_file = self.is_selected_file();
983                    if is_file {
984                        self.open_selected_file();
985                    } else {
986                        match self.layout_mode {
987                            LayoutMode::Tree => self.drill_into_selected(),
988                            LayoutMode::Miller => self.drill_into_miller_selected(),
989                        }
990                    }
991                }
992            }
993            KeyAction::NavigateBack => {
994                if self.view == View::Explorer {
995                    self.navigate_back();
996                }
997            }
998
999            KeyAction::CommandMode => {
1000                self.command_input.clear();
1001                self.mode = AppMode::Command;
1002            }
1003
1004            KeyAction::ToggleDetails => {
1005                self.show_details = !self.show_details;
1006            }
1007            KeyAction::ToggleHelp => {
1008                self.mode = AppMode::Help;
1009            }
1010            KeyAction::ToggleTheme => {
1011                self.theme = self.theme.toggle();
1012            }
1013            KeyAction::ToggleLayout => {
1014                self.sync_layout_selection();
1015            }
1016            KeyAction::CyclePreviewMode => {
1017                self.preview_state.cycle_mode();
1018            }
1019            KeyAction::OpenSettings => {
1020                self.settings_state = Some(SettingsState::new(self.user_settings.clone()));
1021                self.mode = AppMode::Settings;
1022            }
1023            KeyAction::Refresh => {
1024                self.marked.clear();
1025                self.start_scan();
1026            }
1027            KeyAction::Sort => {
1028                self.cycle_sort_mode();
1029            }
1030            KeyAction::ReverseSort => {
1031                self.reverse_sort_mode();
1032            }
1033            KeyAction::Search => {
1034                self.start_search();
1035            }
1036
1037            _ => {}
1038        }
1039    }
1040
1041    /// Toggle marking the currently selected item.
1042    fn toggle_mark(&mut self) {
1043        let path = match self.view {
1044            View::Explorer => {
1045                if let Some((node, root_path)) = self.get_view_root_node() {
1046                    match self.layout_mode {
1047                        LayoutMode::Tree => {
1048                            let items =
1049                                TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1050                            items.get(self.tree_state.selected).map(|i| i.path.clone())
1051                        }
1052                        LayoutMode::Miller => {
1053                            node.children.get(self.miller_state.selected).map(|child| {
1054                                self.view_root.join(&*child.name)
1055                            })
1056                        }
1057                    }
1058                } else {
1059                    None
1060                }
1061            }
1062            View::Duplicates => {
1063                // Collect data first to avoid borrow issues
1064                let (is_on_header, group_paths, selected_file) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1065                    let is_on_header = !self.duplicates_state.is_expanded(self.duplicates_state.selected_group)
1066                        || self.duplicates_state.selected_item(self.duplicates_state.selected_group) == 0;
1067
1068                    let group_paths: Vec<PathBuf> = filtered
1069                        .get(self.duplicates_state.selected_group)
1070                        .map(|g| g.paths.clone())
1071                        .unwrap_or_default();
1072
1073                    let selected_file = if !is_on_header {
1074                        self.duplicates_state.selected_file_path(&filtered).cloned()
1075                    } else {
1076                        None
1077                    };
1078
1079                    (is_on_header, group_paths, selected_file)
1080                } else {
1081                    return;
1082                };
1083
1084                if is_on_header {
1085                    // Header selected - toggle ALL files in the group
1086                    let any_marked = group_paths.iter().any(|p| self.marked.contains(p));
1087
1088                    if any_marked {
1089                        // Unmark all files in the group
1090                        for path in &group_paths {
1091                            self.marked.remove(path);
1092                        }
1093                    } else {
1094                        // Mark all files in the group
1095                        for path in group_paths {
1096                            if path != self.path {
1097                                self.marked.insert(path);
1098                            }
1099                        }
1100                    }
1101                    return; // Early return since we handled it here
1102                } else {
1103                    // Specific file selected within expanded group
1104                    selected_file
1105                }
1106            }
1107            View::Age => {
1108                if let Some(filtered) = self.get_filtered_stale_dirs() {
1109                    let selected = self.selected_stale_dir.min(filtered.len().saturating_sub(1));
1110                    filtered.get(selected).map(|d| d.path.clone())
1111                } else {
1112                    None
1113                }
1114            }
1115            View::Errors => {
1116                // Only allow marking broken symlinks for deletion
1117                if let Some(warning) = self.warnings.get(self.selected_warning) {
1118                    if warning.kind == gravityfile_core::WarningKind::BrokenSymlink {
1119                        Some(warning.path.clone())
1120                    } else {
1121                        None
1122                    }
1123                } else {
1124                    None
1125                }
1126            }
1127        };
1128
1129        if let Some(path) = path {
1130            if path != self.path {
1131                if self.marked.contains(&path) {
1132                    self.marked.remove(&path);
1133                } else {
1134                    self.marked.insert(path);
1135                }
1136            }
1137        }
1138    }
1139
1140    /// Execute deletion of all marked items asynchronously.
1141    fn execute_deletion(&mut self) {
1142        let paths_with_sizes: Vec<(PathBuf, u64)> = self
1143            .marked
1144            .iter()
1145            .map(|p| (p.clone(), self.get_path_size(p).unwrap_or(0)))
1146            .collect();
1147
1148        let total = paths_with_sizes.len();
1149        if total == 0 {
1150            return;
1151        }
1152
1153        self.marked.clear();
1154        self.mode = AppMode::Deleting;
1155        self.deletion_progress = Some(DeletionProgress {
1156            total,
1157            deleted: 0,
1158            failed: 0,
1159            bytes_freed: 0,
1160            current: paths_with_sizes.first().map(|(p, _)| p.clone()),
1161        });
1162
1163        self.scan_rx = Some(deletion::start_deletion(paths_with_sizes));
1164    }
1165
1166    /// Get the size of a path from the tree.
1167    fn get_path_size(&self, target: &PathBuf) -> Option<u64> {
1168        let tree = self.tree.as_ref()?;
1169
1170        fn find_size(
1171            node: &gravityfile_core::FileNode,
1172            target: &PathBuf,
1173            current: &PathBuf,
1174        ) -> Option<u64> {
1175            if current == target {
1176                return Some(node.size);
1177            }
1178            for child in &node.children {
1179                let child_path = current.join(&*child.name);
1180                if let Some(size) = find_size(child, target, &child_path) {
1181                    return Some(size);
1182                }
1183            }
1184            None
1185        }
1186
1187        find_size(&tree.root, target, &tree.root_path)
1188    }
1189
1190    /// Yank (copy) selected items to clipboard.
1191    fn yank_selection(&mut self) {
1192        let paths: Vec<PathBuf> = if self.marked.is_empty() {
1193            self.get_selected_path().into_iter().collect()
1194        } else {
1195            self.marked.iter().cloned().collect()
1196        };
1197
1198        if !paths.is_empty() {
1199            self.clipboard.yank(paths, self.view_root.clone());
1200            self.marked.clear();
1201        }
1202    }
1203
1204    /// Cut (move) selected items to clipboard.
1205    fn cut_selection(&mut self) {
1206        let paths: Vec<PathBuf> = if self.marked.is_empty() {
1207            self.get_selected_path().into_iter().collect()
1208        } else {
1209            self.marked.iter().cloned().collect()
1210        };
1211
1212        if !paths.is_empty() {
1213            self.clipboard.cut(paths, self.view_root.clone());
1214            self.marked.clear();
1215        }
1216    }
1217
1218    /// Paste from clipboard to appropriate destination.
1219    /// - If cursor is on a directory → paste INTO that directory
1220    /// - If cursor is on a file → paste into the same parent directory
1221    fn paste_clipboard(&mut self) {
1222        self.paste_with_resolution(None);
1223    }
1224
1225    /// Paste with a specific conflict resolution (or None to check for conflicts).
1226    fn paste_with_resolution(&mut self, resolution: Option<gravityfile_ops::ConflictResolution>) {
1227        if self.clipboard.is_empty() {
1228            return;
1229        }
1230
1231        // Determine destination based on current selection
1232        let destination = self.get_paste_destination();
1233
1234        let sources = self.clipboard.paths.clone();
1235        let mode = self.clipboard.mode;
1236
1237        // Pre-flight conflict detection (only if no resolution provided)
1238        if resolution.is_none() {
1239            if let Some(conflict) = self.check_paste_conflict(&sources, &destination) {
1240                // Store the pending operation and show conflict modal
1241                self.pending_operation = Some(PendingOperation::Paste {
1242                    sources: sources.clone(),
1243                    destination: destination.clone(),
1244                    mode,
1245                });
1246                self.pending_conflict = Some(conflict);
1247                self.mode = AppMode::ConflictResolution;
1248                return;
1249            }
1250        }
1251
1252        match mode {
1253            ClipboardMode::Copy => {
1254                self.mode = AppMode::Copying;
1255                let options = CopyOptions {
1256                    conflict_resolution: resolution,
1257                    preserve_timestamps: false,
1258                };
1259                let rx = gravityfile_ops::start_copy(sources, destination, options);
1260                self.scan_rx = Some(Self::adapt_copy_rx(rx));
1261            }
1262            ClipboardMode::Cut => {
1263                self.mode = AppMode::Moving;
1264                let options = MoveOptions {
1265                    conflict_resolution: resolution,
1266                };
1267                let rx = gravityfile_ops::start_move(sources, destination, options);
1268                self.scan_rx = Some(Self::adapt_move_rx(rx));
1269                self.clipboard.clear();
1270            }
1271            ClipboardMode::Empty => {}
1272        }
1273    }
1274
1275    /// Check if pasting would cause a conflict with an existing file.
1276    fn check_paste_conflict(&self, sources: &[PathBuf], destination: &PathBuf) -> Option<Conflict> {
1277        use gravityfile_ops::{ConflictKind};
1278
1279        for source in sources {
1280            if let Some(filename) = source.file_name() {
1281                let dest_path = destination.join(filename);
1282
1283                // Check if destination already exists
1284                if dest_path.exists() {
1285                    // Determine conflict kind
1286                    let kind = if source == &dest_path {
1287                        // Same file - source and destination are identical
1288                        ConflictKind::SameFile
1289                    } else if dest_path.is_dir() {
1290                        ConflictKind::DirectoryExists
1291                    } else {
1292                        ConflictKind::FileExists
1293                    };
1294
1295                    return Some(Conflict::new(
1296                        source.clone(),
1297                        dest_path,
1298                        kind,
1299                    ));
1300                }
1301            }
1302        }
1303        None
1304    }
1305
1306    /// Get the appropriate paste destination based on current selection.
1307    /// - If selected item is a directory → return that directory
1308    /// - If selected item is a file → return the parent directory
1309    /// - If nothing selected → return view_root
1310    fn get_paste_destination(&self) -> PathBuf {
1311        // Try to get the currently selected item
1312        if let Some(selected_path) = self.get_selected_path() {
1313            // Check if the selected item is a directory
1314            if selected_path.is_dir() {
1315                return selected_path;
1316            } else {
1317                // It's a file, use its parent directory
1318                if let Some(parent) = selected_path.parent() {
1319                    return parent.to_path_buf();
1320                }
1321            }
1322        }
1323        // Default to view_root if nothing selected
1324        self.view_root.clone()
1325    }
1326
1327    /// Adapt copy result channel to ScanResult channel.
1328    fn adapt_copy_rx(mut rx: mpsc::Receiver<CopyResult>) -> mpsc::Receiver<ScanResult> {
1329        let (tx, result_rx) = mpsc::channel(100);
1330        tokio::spawn(async move {
1331            while let Some(result) = rx.recv().await {
1332                let scan_result = match result {
1333                    CopyResult::Progress(p) => ScanResult::OperationProgress(p),
1334                    CopyResult::Conflict(c) => ScanResult::OperationConflict(c),
1335                    CopyResult::Complete(c) => ScanResult::OperationComplete {
1336                        operation_type: c.operation_type,
1337                        succeeded: c.succeeded,
1338                        failed: c.failed,
1339                        bytes_processed: c.bytes_processed,
1340                    },
1341                };
1342                if tx.send(scan_result).await.is_err() {
1343                    break;
1344                }
1345            }
1346        });
1347        result_rx
1348    }
1349
1350    /// Adapt move result channel to ScanResult channel.
1351    fn adapt_move_rx(mut rx: mpsc::Receiver<MoveResult>) -> mpsc::Receiver<ScanResult> {
1352        let (tx, result_rx) = mpsc::channel(100);
1353        tokio::spawn(async move {
1354            while let Some(result) = rx.recv().await {
1355                let scan_result = match result {
1356                    MoveResult::Progress(p) => ScanResult::OperationProgress(p),
1357                    MoveResult::Conflict(c) => ScanResult::OperationConflict(c),
1358                    MoveResult::Complete(c) => ScanResult::OperationComplete {
1359                        operation_type: c.operation_type,
1360                        succeeded: c.succeeded,
1361                        failed: c.failed,
1362                        bytes_processed: c.bytes_processed,
1363                    },
1364                };
1365                if tx.send(scan_result).await.is_err() {
1366                    break;
1367                }
1368            }
1369        });
1370        result_rx
1371    }
1372
1373    /// Handle key press during conflict resolution mode.
1374    fn handle_conflict_key(&mut self, event: crossterm::event::KeyEvent) {
1375        use crossterm::event::KeyCode;
1376        use gravityfile_ops::ConflictResolution;
1377
1378        // Check if this is a SameFile conflict - only Skip and Abort are valid
1379        let is_same_file = self.pending_conflict
1380            .as_ref()
1381            .map(|c| matches!(c.kind, gravityfile_ops::ConflictKind::SameFile))
1382            .unwrap_or(false);
1383
1384        let resolution = match event.code {
1385            // Skip
1386            KeyCode::Char('s') => Some(ConflictResolution::Skip),
1387            // Skip All (not valid for same file)
1388            KeyCode::Char('S') if !is_same_file => Some(ConflictResolution::SkipAll),
1389            // Overwrite (not valid for same file)
1390            KeyCode::Char('o') if !is_same_file => Some(ConflictResolution::Overwrite),
1391            // Overwrite All (not valid for same file)
1392            KeyCode::Char('O') if !is_same_file => Some(ConflictResolution::OverwriteAll),
1393            // Rename (auto-rename)
1394            KeyCode::Char('r') => Some(ConflictResolution::AutoRename),
1395            // Abort/Cancel
1396            KeyCode::Esc => Some(ConflictResolution::Abort),
1397            // Enter also acts as skip for same-file conflicts
1398            KeyCode::Enter if is_same_file => Some(ConflictResolution::Skip),
1399            // For SourceIsAncestor, Enter also acknowledges
1400            KeyCode::Enter => {
1401                if let Some(conflict) = &self.pending_conflict {
1402                    if matches!(conflict.kind, gravityfile_ops::ConflictKind::SourceIsAncestor) {
1403                        Some(ConflictResolution::Abort)
1404                    } else {
1405                        None
1406                    }
1407                } else {
1408                    None
1409                }
1410            }
1411            _ => None,
1412        };
1413
1414        if let Some(res) = resolution {
1415            // Clear the pending conflict
1416            self.pending_conflict = None;
1417
1418            // Take the pending operation
1419            let pending = self.pending_operation.take();
1420
1421            // If aborting or skipping, cancel the operation and return to normal mode
1422            if matches!(res, ConflictResolution::Abort | ConflictResolution::Skip | ConflictResolution::SkipAll) {
1423                self.mode = AppMode::Normal;
1424                self.scan_rx = None;
1425                self.operation_progress = None;
1426                // Keep clipboard for Abort (user might want to try elsewhere)
1427                // Clear for Skip only if it was a cut operation that we're fully canceling
1428                return;
1429            }
1430
1431            // Resume the pending operation with the chosen resolution
1432            if let Some(pending_op) = pending {
1433                match pending_op {
1434                    PendingOperation::Paste { sources, destination: _, mode } => {
1435                        // Temporarily set clipboard to resume the paste
1436                        let old_clipboard = self.clipboard.clone();
1437                        self.clipboard.paths = sources;
1438                        self.clipboard.mode = mode;
1439
1440                        // Execute paste with the chosen resolution
1441                        self.paste_with_resolution(Some(res));
1442
1443                        // For Copy mode, restore clipboard after paste starts
1444                        // For Cut mode, the paste_with_resolution already clears it
1445                        if mode == ClipboardMode::Copy {
1446                            self.clipboard = old_clipboard;
1447                        }
1448                    }
1449                }
1450            } else {
1451                // No pending operation, just return to normal
1452                self.mode = AppMode::Normal;
1453                self.scan_rx = None;
1454                self.operation_progress = None;
1455            }
1456        }
1457    }
1458
1459    /// Start rename operation for current selection.
1460    fn start_rename(&mut self) {
1461        if let Some(path) = self.get_selected_path() {
1462            let name = path
1463                .file_name()
1464                .map(|n| n.to_string_lossy().to_string())
1465                .unwrap_or_default();
1466            let mut input = InputState::with_initial(&name);
1467            input.context_path = Some(path);
1468            self.input_state = Some(input);
1469            self.mode = AppMode::Renaming;
1470        }
1471    }
1472
1473    /// Start create file operation.
1474    fn start_create_file(&mut self) {
1475        let mut input = InputState::new();
1476        input.context_path = Some(self.view_root.clone());
1477        self.input_state = Some(input);
1478        self.mode = AppMode::CreatingFile;
1479    }
1480
1481    /// Start create directory operation.
1482    fn start_create_directory(&mut self) {
1483        let mut input = InputState::new();
1484        input.context_path = Some(self.view_root.clone());
1485        self.input_state = Some(input);
1486        self.mode = AppMode::CreatingDirectory;
1487    }
1488
1489    /// Start take operation (create directory and cd into it).
1490    fn start_take(&mut self) {
1491        let mut input = InputState::new();
1492        input.context_path = Some(self.view_root.clone());
1493        self.input_state = Some(input);
1494        self.mode = AppMode::Taking;
1495    }
1496
1497    /// Start go to directory operation.
1498    fn start_goto(&mut self) {
1499        // Pre-fill with current view root for convenience
1500        let current_path = self.view_root.to_string_lossy().to_string();
1501        let mut input = InputState::with_initial(&current_path);
1502        input.context_path = Some(self.view_root.clone());
1503        self.input_state = Some(input);
1504        self.mode = AppMode::GoingTo;
1505    }
1506
1507    /// Execute go to directory.
1508    fn execute_goto(&mut self, path_str: &str) {
1509        let path = if path_str.starts_with('~') {
1510            // Expand tilde
1511            if let Some(home) = dirs::home_dir() {
1512                home.join(&path_str[1..].trim_start_matches('/'))
1513            } else {
1514                std::path::PathBuf::from(path_str)
1515            }
1516        } else if path_str.starts_with('/') {
1517            std::path::PathBuf::from(path_str)
1518        } else {
1519            // Relative path from current view root
1520            self.view_root.join(path_str)
1521        };
1522
1523        // Canonicalize to resolve .. and .
1524        let path = path.canonicalize().unwrap_or(path);
1525
1526        if path.is_dir() {
1527            // Check if target path is within our already-scanned tree
1528            let within_scanned_tree = if let Some(tree) = &self.tree {
1529                path.starts_with(&tree.root_path) || tree.root_path.starts_with(&path)
1530            } else {
1531                false
1532            };
1533
1534            if within_scanned_tree {
1535                // Navigate within the existing tree - no rescan needed
1536                self.view_root = path.clone();
1537                self.view_history.clear();
1538                self.forward_history.clear();
1539                self.tree_state.selected = 0;
1540                self.tree_state.offset = 0;
1541                self.tree_state.expand(&self.view_root);
1542                self.miller_state.reset();
1543                self.update_cached_tree_len();
1544                self.update_cached_miller_len();
1545                self.preview_state.content = crate::preview::PreviewContent::Empty;
1546
1547                // Update the active tab view_root but keep the tree
1548                if let Some(tab) = self.tab_manager.active_tab_mut() {
1549                    tab.view_root = self.view_root.clone();
1550                }
1551            } else {
1552                // Going to a new location - use quick_list for immediate display
1553                // Don't auto-scan; user can press R to scan if needed
1554                self.path = path.clone();
1555                self.view_root = path.clone();
1556                self.view_history.clear();
1557                self.forward_history.clear();
1558                self.tree_state = TreeState::new(self.path.clone());
1559                self.miller_state.reset();
1560                self.marked.clear();
1561                self.clipboard.clear();
1562                self.duplicates = None;
1563                self.age_report = None;
1564                self.warnings.clear();
1565                self.preview_state.content = crate::preview::PreviewContent::Empty;
1566                self.scan_progress = None;
1567
1568                // Load quick tree for immediate display
1569                self.tree = gravityfile_scan::quick_list(&path).ok();
1570                self.update_cached_tree_len();
1571                self.update_cached_miller_len();
1572
1573                // Update the active tab
1574                if let Some(tab) = self.tab_manager.active_tab_mut() {
1575                    tab.path = self.path.clone();
1576                    tab.view_root = self.view_root.clone();
1577                    tab.label = self.path
1578                        .file_name()
1579                        .map(|n| n.to_string_lossy().to_string())
1580                        .unwrap_or_else(|| self.path.to_string_lossy().to_string());
1581                    tab.history.clear();
1582                }
1583
1584                // Don't auto-start scan - user can press R when ready
1585            }
1586        } else {
1587            self.error = Some(format!("Not a directory: {}", path.display()));
1588        }
1589    }
1590
1591    /// Execute undo of the last operation.
1592    fn execute_undo(&mut self) {
1593        // TODO: Implement undo
1594        // For now, just show a message that undo is not yet implemented
1595        self.operation_message = Some((false, "Undo not yet implemented".to_string()));
1596    }
1597
1598    /// Handle key events in input modes (Renaming, CreatingFile, CreatingDirectory).
1599    fn handle_input_event(&mut self, key: crossterm::event::KeyEvent) {
1600        let current_mode = self.mode;
1601
1602        if let Some(ref mut input) = self.input_state {
1603            match input.handle_key(key) {
1604                InputResult::Continue => {}
1605                InputResult::Cancel => {
1606                    self.input_state = None;
1607                    self.mode = AppMode::Normal;
1608                }
1609                InputResult::Submit(value) => {
1610                    let context_path = input.context_path.clone();
1611                    self.input_state = None;
1612
1613                    match current_mode {
1614                        AppMode::Renaming => {
1615                            if let Some(source) = context_path {
1616                                self.execute_rename(source, value);
1617                            }
1618                        }
1619                        AppMode::CreatingFile => {
1620                            if let Some(parent) = context_path {
1621                                self.execute_create_file(parent, value);
1622                            }
1623                        }
1624                        AppMode::CreatingDirectory => {
1625                            if let Some(parent) = context_path {
1626                                self.execute_create_directory(parent, value);
1627                            }
1628                        }
1629                        AppMode::Taking => {
1630                            if let Some(parent) = context_path {
1631                                self.execute_take(parent, value);
1632                            }
1633                        }
1634                        AppMode::GoingTo => {
1635                            self.execute_goto(&value);
1636                        }
1637                        _ => {}
1638                    }
1639
1640                    self.mode = AppMode::Normal;
1641                }
1642            }
1643        } else {
1644            // No input state, just cancel
1645            self.mode = AppMode::Normal;
1646        }
1647    }
1648
1649    /// Execute rename operation.
1650    fn execute_rename(&mut self, source: std::path::PathBuf, new_name: String) {
1651        use gravityfile_ops::start_rename;
1652
1653        // Validate name
1654        if new_name.is_empty() || new_name.contains('/') || new_name.contains('\\') {
1655            self.operation_message = Some((false, "Invalid file name".to_string()));
1656            return;
1657        }
1658
1659        let rx = start_rename(source.clone(), new_name.clone());
1660
1661        // Adapt the rename result receiver
1662        let adapted_rx = Self::adapt_rename_rx(rx);
1663        self.scan_rx = Some(adapted_rx);
1664        self.mode = AppMode::Normal;
1665
1666        // Show pending message
1667        self.operation_message = Some((true, format!("Renaming to {}...", new_name)));
1668    }
1669
1670    /// Adapt rename receiver to ScanResult.
1671    fn adapt_rename_rx(
1672        mut rx: mpsc::Receiver<gravityfile_ops::RenameResult>,
1673    ) -> mpsc::Receiver<ScanResult> {
1674        let (tx, adapted_rx) = mpsc::channel(100);
1675
1676        tokio::spawn(async move {
1677            while let Some(result) = rx.recv().await {
1678                let scan_result = match result {
1679                    gravityfile_ops::RenameResult::Progress(progress) => {
1680                        ScanResult::OperationProgress(progress)
1681                    }
1682                    gravityfile_ops::RenameResult::Complete(complete) => {
1683                        ScanResult::OperationComplete {
1684                            operation_type: complete.operation_type,
1685                            succeeded: complete.succeeded,
1686                            failed: complete.failed,
1687                            bytes_processed: complete.bytes_processed,
1688                        }
1689                    }
1690                };
1691                if tx.send(scan_result).await.is_err() {
1692                    break;
1693                }
1694            }
1695        });
1696
1697        adapted_rx
1698    }
1699
1700    /// Execute create file operation.
1701    fn execute_create_file(&mut self, parent: std::path::PathBuf, name: String) {
1702        use gravityfile_ops::start_create_file;
1703
1704        // Validate name
1705        if name.is_empty() || name.contains('/') || name.contains('\\') {
1706            self.operation_message = Some((false, "Invalid file name".to_string()));
1707            return;
1708        }
1709
1710        let path = parent.join(&name);
1711        let rx = start_create_file(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 file {}...", name)));
1717    }
1718
1719    /// Execute create directory operation.
1720    fn execute_create_directory(&mut self, parent: std::path::PathBuf, name: String) {
1721        use gravityfile_ops::start_create_directory;
1722
1723        // Validate name
1724        if name.is_empty() || name.contains('/') || name.contains('\\') {
1725            self.operation_message = Some((false, "Invalid directory name".to_string()));
1726            return;
1727        }
1728
1729        let path = parent.join(&name);
1730        let rx = start_create_directory(path.clone());
1731
1732        let adapted_rx = Self::adapt_create_rx(rx);
1733        self.scan_rx = Some(adapted_rx);
1734
1735        self.operation_message = Some((true, format!("Creating directory {}...", name)));
1736    }
1737
1738    /// Execute take operation (create directory and navigate into it).
1739    fn execute_take(&mut self, parent: std::path::PathBuf, name: String) {
1740        // Validate name
1741        if name.is_empty() || name.contains('/') || name.contains('\\') {
1742            self.operation_message = Some((false, "Invalid directory name".to_string()));
1743            return;
1744        }
1745
1746        let path = parent.join(&name);
1747
1748        // Check if already exists
1749        if path.exists() {
1750            if path.is_dir() {
1751                // Directory exists - just navigate into it
1752                self.operation_message = Some((true, format!("Directory {} already exists, navigating...", name)));
1753            } else {
1754                // File exists with same name
1755                self.operation_message = Some((false, format!("A file named {} already exists", name)));
1756                return;
1757            }
1758        } else {
1759            // Create the directory synchronously (it's a simple operation)
1760            if let Err(e) = std::fs::create_dir(&path) {
1761                self.operation_message = Some((false, format!("Failed to create directory: {}", e)));
1762                return;
1763            }
1764            self.operation_message = Some((true, format!("Created and entered {}", name)));
1765        }
1766
1767        // Navigate into the new directory
1768        // Save current state to history
1769        let saved_expanded = self.tree_state.expanded.clone();
1770        let saved_selected = match self.layout_mode {
1771            LayoutMode::Tree => self.tree_state.selected,
1772            LayoutMode::Miller => self.miller_state.selected,
1773        };
1774        self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
1775
1776        // Update view root to the new directory
1777        self.view_root = path;
1778
1779        // Reset state for new directory
1780        match self.layout_mode {
1781            LayoutMode::Tree => {
1782                self.tree_state.selected = 0;
1783                self.tree_state.offset = 0;
1784                self.tree_state.expand(&self.view_root);
1785            }
1786            LayoutMode::Miller => {
1787                self.miller_state.reset();
1788            }
1789        }
1790
1791        self.sync_to_active_tab();
1792
1793        // Refresh to show new directory in tree
1794        self.start_scan();
1795    }
1796
1797    /// Adapt create result receiver to ScanResult.
1798    fn adapt_create_rx(
1799        mut rx: mpsc::Receiver<gravityfile_ops::CreateResult>,
1800    ) -> mpsc::Receiver<ScanResult> {
1801        let (tx, adapted_rx) = mpsc::channel(100);
1802
1803        tokio::spawn(async move {
1804            while let Some(result) = rx.recv().await {
1805                let scan_result = match result {
1806                    gravityfile_ops::CreateResult::Progress(progress) => {
1807                        ScanResult::OperationProgress(progress)
1808                    }
1809                    gravityfile_ops::CreateResult::Complete(complete) => {
1810                        ScanResult::OperationComplete {
1811                            operation_type: complete.operation_type,
1812                            succeeded: complete.succeeded,
1813                            failed: complete.failed,
1814                            bytes_processed: complete.bytes_processed,
1815                        }
1816                    }
1817                };
1818                if tx.send(scan_result).await.is_err() {
1819                    break;
1820                }
1821            }
1822        });
1823
1824        adapted_rx
1825    }
1826
1827    // Navigation methods
1828    fn move_up(&mut self) {
1829        match self.view {
1830            View::Explorer => match self.layout_mode {
1831                LayoutMode::Tree => self.tree_state.move_up(1),
1832                LayoutMode::Miller => {
1833                    self.miller_state.move_up(1);
1834                    self.update_preview();
1835                }
1836            },
1837            View::Duplicates => {
1838                // Get counts first to avoid borrow issues
1839                let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1840                    let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1841                    (filtered.len(), counts)
1842                } else {
1843                    (0, Vec::new())
1844                };
1845                if group_count > 0 {
1846                    self.duplicates_state.move_up(group_count, |idx| {
1847                        file_counts.get(idx).copied().unwrap_or(0)
1848                    });
1849                }
1850            }
1851            View::Age => {
1852                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(1);
1853            }
1854            View::Errors => {
1855                self.selected_warning = self.selected_warning.saturating_sub(1);
1856            }
1857        }
1858    }
1859
1860    fn move_down(&mut self) {
1861        match self.view {
1862            View::Explorer => match self.layout_mode {
1863                LayoutMode::Tree => {
1864                    self.tree_state.move_down(1, self.cached_tree_len);
1865                }
1866                LayoutMode::Miller => {
1867                    self.miller_state.move_down(1, self.cached_miller_len);
1868                    self.update_preview();
1869                }
1870            },
1871            View::Duplicates => {
1872                // Get counts first to avoid borrow issues
1873                let (group_count, file_counts) = if let Some((filtered, _)) = self.get_filtered_duplicates() {
1874                    let counts: Vec<usize> = filtered.iter().map(|g| g.paths.len()).collect();
1875                    (filtered.len(), counts)
1876                } else {
1877                    (0, Vec::new())
1878                };
1879                if group_count > 0 {
1880                    self.duplicates_state.move_down(group_count, |idx| {
1881                        file_counts.get(idx).copied().unwrap_or(0)
1882                    });
1883                }
1884            }
1885            View::Age => {
1886                if let Some(filtered) = self.get_filtered_stale_dirs() {
1887                    let max = filtered.len().saturating_sub(1);
1888                    self.selected_stale_dir = (self.selected_stale_dir + 1).min(max);
1889                }
1890            }
1891            View::Errors => {
1892                let max = self.warnings.len().saturating_sub(1);
1893                self.selected_warning = (self.selected_warning + 1).min(max);
1894            }
1895        }
1896    }
1897
1898    fn page_up(&mut self) {
1899        match self.view {
1900            View::Explorer => match self.layout_mode {
1901                LayoutMode::Tree => self.tree_state.move_up(PAGE_SIZE),
1902                LayoutMode::Miller => {
1903                    self.miller_state.move_up(PAGE_SIZE);
1904                    self.update_preview();
1905                }
1906            },
1907            View::Duplicates => {
1908                // Page up - move multiple groups up
1909                self.duplicates_state.selected_group =
1910                    self.duplicates_state.selected_group.saturating_sub(PAGE_SIZE);
1911            }
1912            View::Age => {
1913                self.selected_stale_dir = self.selected_stale_dir.saturating_sub(PAGE_SIZE);
1914            }
1915            View::Errors => {
1916                self.selected_warning = self.selected_warning.saturating_sub(PAGE_SIZE);
1917            }
1918        }
1919    }
1920
1921    fn page_down(&mut self) {
1922        match self.view {
1923            View::Explorer => match self.layout_mode {
1924                LayoutMode::Tree => {
1925                    self.tree_state.move_down(PAGE_SIZE, self.cached_tree_len);
1926                }
1927                LayoutMode::Miller => {
1928                    self.miller_state.move_down(PAGE_SIZE, self.cached_miller_len);
1929                    self.update_preview();
1930                }
1931            },
1932            View::Duplicates => {
1933                // Page down - move multiple groups down
1934                if let Some((filtered, _)) = self.get_filtered_duplicates() {
1935                    let max = filtered.len().saturating_sub(1);
1936                    self.duplicates_state.selected_group =
1937                        (self.duplicates_state.selected_group + PAGE_SIZE).min(max);
1938                }
1939            }
1940            View::Age => {
1941                if let Some(filtered) = self.get_filtered_stale_dirs() {
1942                    let max = filtered.len().saturating_sub(1);
1943                    self.selected_stale_dir = (self.selected_stale_dir + PAGE_SIZE).min(max);
1944                }
1945            }
1946            View::Errors => {
1947                let max = self.warnings.len().saturating_sub(1);
1948                self.selected_warning = (self.selected_warning + PAGE_SIZE).min(max);
1949            }
1950        }
1951    }
1952
1953    fn jump_to_top(&mut self) {
1954        match self.view {
1955            View::Explorer => match self.layout_mode {
1956                LayoutMode::Tree => self.tree_state.jump_to_top(),
1957                LayoutMode::Miller => {
1958                    self.miller_state.jump_to_top();
1959                    self.update_preview();
1960                }
1961            },
1962            View::Duplicates => self.duplicates_state.reset(),
1963            View::Age => self.selected_stale_dir = 0,
1964            View::Errors => self.selected_warning = 0,
1965        }
1966    }
1967
1968    fn jump_to_bottom(&mut self) {
1969        match self.view {
1970            View::Explorer => match self.layout_mode {
1971                LayoutMode::Tree => {
1972                    self.tree_state.jump_to_bottom(self.cached_tree_len);
1973                }
1974                LayoutMode::Miller => {
1975                    self.miller_state.jump_to_bottom(self.cached_miller_len);
1976                    self.update_preview();
1977                }
1978            },
1979            View::Duplicates => {
1980                if let Some((filtered, _)) = self.get_filtered_duplicates() {
1981                    self.duplicates_state.selected_group = filtered.len().saturating_sub(1);
1982                }
1983            }
1984            View::Age => {
1985                if let Some(filtered) = self.get_filtered_stale_dirs() {
1986                    self.selected_stale_dir = filtered.len().saturating_sub(1);
1987                }
1988            }
1989            View::Errors => {
1990                self.selected_warning = self.warnings.len().saturating_sub(1);
1991            }
1992        }
1993    }
1994
1995    fn expand_selected(&mut self) {
1996        if let Some((node, root_path)) = self.get_view_root_node() {
1997            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
1998            if let Some(item) = items.get(self.tree_state.selected) {
1999                // Lazy load directory contents if directory has no children
2000                if matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. }) {
2001                    let needs_lazy_load = if let Some(tree) = &self.tree {
2002                        if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2003                            node.children.is_empty()
2004                        } else {
2005                            false
2006                        }
2007                    } else {
2008                        false
2009                    };
2010                    if needs_lazy_load {
2011                        self.lazy_load_directory(&item.path);
2012                    }
2013                }
2014                self.tree_state.expand(&item.path);
2015                self.update_cached_tree_len();
2016            }
2017        }
2018    }
2019
2020    /// Collapse the selected item, or navigate back if already collapsed/not a directory.
2021    /// This provides vim-style navigation where 'h' goes up when there's nothing to collapse.
2022    fn collapse_selected(&mut self) {
2023        if let Some((node, root_path)) = self.get_view_root_node() {
2024            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2025            if let Some(item) = items.get(self.tree_state.selected) {
2026                // Check if this is an expanded directory
2027                let is_expanded_dir = matches!(
2028                    item.node.kind,
2029                    crate::ui::VisibleNodeKind::Directory { expanded: true }
2030                );
2031
2032                if is_expanded_dir {
2033                    // Collapse the directory
2034                    self.tree_state.collapse(&item.path);
2035                    self.update_cached_tree_len();
2036                } else {
2037                    // Not expanded or not a directory - navigate back to parent
2038                    self.navigate_back();
2039                }
2040            }
2041        }
2042    }
2043
2044    fn toggle_selected(&mut self) {
2045        if let Some((node, root_path)) = self.get_view_root_node() {
2046            let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2047            if let Some(item) = items.get(self.tree_state.selected) {
2048                self.tree_state.toggle_expand(&item.path);
2049                self.update_cached_tree_len();
2050            }
2051        }
2052    }
2053
2054    fn get_selected_info(&self) -> Option<SelectedInfo> {
2055        let tree = self.tree.as_ref()?;
2056        let (view_node, view_path) = self.get_view_root_node()?;
2057
2058        match self.layout_mode {
2059            LayoutMode::Tree => {
2060                let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2061                let item = items.get(self.tree_state.selected)?;
2062                let node = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path)?;
2063
2064                Some(SelectedInfo {
2065                    name: item.node.name.clone(),
2066                    path: item.path.clone(),
2067                    size: node.size,
2068                    file_count: node.file_count(),
2069                    dir_count: node.dir_count(),
2070                    modified: node.timestamps.modified,
2071                    is_dir: node.is_dir(),
2072                })
2073            }
2074            LayoutMode::Miller => {
2075                let child = view_node.children.get(self.miller_state.selected)?;
2076                let path = self.view_root.join(&*child.name);
2077
2078                Some(SelectedInfo {
2079                    name: child.name.to_string().into(),
2080                    path,
2081                    size: child.size,
2082                    file_count: child.file_count(),
2083                    dir_count: child.dir_count(),
2084                    modified: child.timestamps.modified,
2085                    is_dir: child.is_dir(),
2086                })
2087            }
2088        }
2089    }
2090
2091    /// Check if the currently selected item is a file (not a directory).
2092    fn is_selected_file(&self) -> bool {
2093        match self.layout_mode {
2094            LayoutMode::Tree => {
2095                if let Some((view_node, view_path)) = self.get_view_root_node() {
2096                    let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2097                        .flatten(&self.tree_state);
2098                    if let Some(item) = items.get(self.tree_state.selected) {
2099                        return !matches!(item.node.kind, crate::ui::VisibleNodeKind::Directory { .. });
2100                    }
2101                }
2102                false
2103            }
2104            LayoutMode::Miller => {
2105                if let Some((view_node, _)) = self.get_view_root_node() {
2106                    if let Some(child) = view_node.children.get(self.miller_state.selected) {
2107                        return !child.is_dir();
2108                    }
2109                }
2110                false
2111            }
2112        }
2113    }
2114
2115    /// Get the path of the currently selected item.
2116    fn get_selected_path(&self) -> Option<PathBuf> {
2117        match self.layout_mode {
2118            LayoutMode::Tree => {
2119                if let Some((view_node, view_path)) = self.get_view_root_node() {
2120                    let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard)
2121                        .flatten(&self.tree_state);
2122                    return items.get(self.tree_state.selected).map(|item| item.path.clone());
2123                }
2124                None
2125            }
2126            LayoutMode::Miller => {
2127                if let Some((view_node, _)) = self.get_view_root_node() {
2128                    if let Some(child) = view_node.children.get(self.miller_state.selected) {
2129                        return Some(self.view_root.join(&*child.name));
2130                    }
2131                }
2132                None
2133            }
2134        }
2135    }
2136
2137    /// Open the currently selected file with the configured opener.
2138    fn open_selected_file(&mut self) {
2139        let Some(path) = self.get_selected_path() else {
2140            return;
2141        };
2142
2143        // Get opener configuration
2144        let openers = &self.user_settings.openers;
2145        let editor_config = &self.user_settings.editor;
2146
2147        match crate::opener::open_file(&path, openers, editor_config) {
2148            crate::opener::OpenResult::Opened => {
2149                // File opened with system command (non-blocking)
2150            }
2151            crate::opener::OpenResult::NeedsSuspend(cmd) => {
2152                // Need to suspend terminal and run command
2153                self.suspend_and_run(cmd);
2154            }
2155            crate::opener::OpenResult::NotSupported(msg) => {
2156                self.error = Some(msg);
2157            }
2158            crate::opener::OpenResult::Error(msg) => {
2159                self.error = Some(msg);
2160            }
2161        }
2162    }
2163
2164    /// Suspend the terminal, run a command, and restore.
2165    fn suspend_and_run(&mut self, cmd: std::process::Command) {
2166        // We need to restore terminal before running the command
2167        // and reinitialize after. This is handled by setting a flag
2168        // that the main loop will process.
2169        self.pending_suspend_command = Some(cmd);
2170    }
2171
2172    /// Drill into the currently selected directory.
2173    fn drill_into_selected(&mut self) {
2174        let Some((view_node, view_path)) = self.get_view_root_node() else {
2175            return;
2176        };
2177
2178        let items = TreeView::new(view_node, &view_path, &self.theme, &self.marked, &self.clipboard).flatten(&self.tree_state);
2179        let Some(item) = items.get(self.tree_state.selected) else {
2180            return;
2181        };
2182
2183        if !matches!(
2184            item.node.kind,
2185            crate::ui::VisibleNodeKind::Directory { .. }
2186        ) {
2187            return;
2188        }
2189
2190        // Check if directory needs lazy loading (has no children loaded yet)
2191        let needs_lazy_load = if let Some(tree) = &self.tree {
2192            if let Some(node) = Self::find_node_at_path(&tree.root, &item.path, &tree.root_path) {
2193                node.children.is_empty()
2194            } else {
2195                true
2196            }
2197        } else {
2198            true
2199        };
2200
2201        // Lazy load directory contents if needed
2202        if needs_lazy_load {
2203            self.lazy_load_directory(&item.path);
2204        }
2205
2206        if item.path == self.view_root {
2207            self.tree_state.expand(&item.path);
2208            self.update_cached_tree_len();
2209            return;
2210        }
2211
2212        let saved_expanded = self.tree_state.expanded.clone();
2213        let saved_selected = self.tree_state.selected;
2214        self.view_history
2215            .push((self.view_root.clone(), saved_selected, saved_expanded));
2216
2217        let target_path = item.path.clone();
2218
2219        // Check if forward history has this path - if so, restore that state
2220        if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2221            let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2222            // Clear any forward history entries after this one
2223            self.forward_history.truncate(forward_idx);
2224
2225            self.view_root = target_path;
2226            self.tree_state.selected = fwd_selected;
2227            self.tree_state.offset = 0;
2228            self.tree_state.expanded = fwd_expanded;
2229            self.tree_state.expand(&self.view_root);
2230            self.update_cached_tree_len();
2231            self.sync_to_active_tab();
2232            return;
2233        }
2234
2235        // Clear forward history - user is navigating to a new location
2236        self.forward_history.clear();
2237
2238        self.view_root = target_path;
2239
2240        self.tree_state.selected = 0;
2241        self.tree_state.offset = 0;
2242        self.tree_state.expand(&self.view_root);
2243        self.update_cached_tree_len();
2244        self.sync_to_active_tab();
2245    }
2246
2247    /// Drill into the selected directory in Miller columns mode.
2248    fn drill_into_miller_selected(&mut self) {
2249        let Some((view_node, _)) = self.get_view_root_node() else {
2250            return;
2251        };
2252
2253        let Some(selected_child) = view_node.children.get(self.miller_state.selected) else {
2254            return;
2255        };
2256
2257        // Only drill into directories
2258        if !selected_child.is_dir() {
2259            return;
2260        }
2261
2262        let target_path = self.view_root.join(&*selected_child.name);
2263
2264        // Lazy loading: if directory has no children (from quick_list), load them now
2265        if selected_child.children.is_empty() {
2266            self.lazy_load_directory(&target_path);
2267        }
2268
2269        // Save current state to history
2270        let saved_expanded = self.tree_state.expanded.clone();
2271        let saved_selected = self.miller_state.selected;
2272        self.view_history
2273            .push((self.view_root.clone(), saved_selected, saved_expanded));
2274
2275        // Check if forward history has this path - if so, restore that state
2276        if let Some(forward_idx) = self.forward_history.iter().rposition(|(p, _, _)| p == &target_path) {
2277            let (_, fwd_selected, fwd_expanded) = self.forward_history.remove(forward_idx);
2278            // Clear any forward history entries after this one
2279            self.forward_history.truncate(forward_idx);
2280
2281            self.view_root = target_path;
2282            self.miller_state.selected = fwd_selected;
2283            self.miller_state.offset = 0;
2284            self.tree_state.expanded = fwd_expanded;
2285            self.update_cached_miller_len();
2286            self.miller_state.ensure_visible(20);
2287            self.update_preview();
2288            self.sync_to_active_tab();
2289            return;
2290        }
2291
2292        // Clear forward history - user is navigating to a new location
2293        self.forward_history.clear();
2294
2295        // Update view root
2296        self.view_root = target_path;
2297        self.miller_state.reset();
2298        self.update_cached_miller_len();
2299        self.update_preview();
2300        self.sync_to_active_tab();
2301    }
2302
2303    /// Lazy load a directory's children using quick_list.
2304    /// This updates the tree in-place with the new children.
2305    fn lazy_load_directory(&mut self, path: &PathBuf) {
2306        // First check if we have cached scan data for this directory
2307        if let Some(cached_tree) = self.scanned_cache.get(path).cloned() {
2308            // Use cached scan data - it has full size/structure information
2309            if let Some(tree) = &mut self.tree {
2310                if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2311                    // Replace with the cached node (preserves all scan data)
2312                    *node = cached_tree.root;
2313                }
2314            }
2315            return;
2316        }
2317
2318        // Quick load directory contents for immediate display
2319        // When the full scan completes, it will replace this with accurate data
2320        if let Ok(quick_tree) = gravityfile_scan::quick_list(path) {
2321            // Find the node in our tree and update its children
2322            if let Some(tree) = &mut self.tree {
2323                if let Some(node) = Self::find_node_mut(&mut tree.root, path, &tree.root_path) {
2324                    node.children = quick_tree.root.children;
2325                }
2326            }
2327        }
2328    }
2329
2330    /// Find a mutable reference to a node by path.
2331    fn find_node_mut<'a>(
2332        node: &'a mut gravityfile_core::FileNode,
2333        target: &PathBuf,
2334        current_path: &PathBuf,
2335    ) -> Option<&'a mut gravityfile_core::FileNode> {
2336        if current_path == target {
2337            return Some(node);
2338        }
2339
2340        for child in &mut node.children {
2341            let child_path = current_path.join(&*child.name);
2342            if target.starts_with(&child_path) {
2343                if let Some(found) = Self::find_node_mut(child, target, &child_path) {
2344                    return Some(found);
2345                }
2346            }
2347        }
2348
2349        None
2350    }
2351
2352    /// Navigate back up to the previous view root.
2353    /// Supports navigation beyond the scan root, all the way to filesystem root.
2354    fn navigate_back(&mut self) {
2355        // Prepare current state for forward history (will be pushed if navigation succeeds)
2356        let current_selected = match self.layout_mode {
2357            LayoutMode::Tree => self.tree_state.selected,
2358            LayoutMode::Miller => self.miller_state.selected,
2359        };
2360        let current_state = (
2361            self.view_root.clone(),
2362            current_selected,
2363            self.tree_state.expanded.clone(),
2364        );
2365
2366        // First, try to use view history if available
2367        if let Some((prev_root, saved_selected, saved_expanded)) = self.view_history.pop() {
2368            // Navigation will succeed - save current state to forward history
2369            self.forward_history.push(current_state);
2370            self.view_root = prev_root;
2371            self.tree_state.expanded = saved_expanded;
2372
2373            // Restore selection based on layout mode
2374            match self.layout_mode {
2375                LayoutMode::Tree => {
2376                    self.tree_state.selected = saved_selected;
2377                    self.tree_state.offset = 0;
2378                    self.update_cached_tree_len();
2379                }
2380                LayoutMode::Miller => {
2381                    self.miller_state.selected = saved_selected;
2382                    self.miller_state.offset = 0;
2383                    self.update_cached_miller_len();
2384                }
2385            }
2386            self.sync_to_active_tab();
2387            return;
2388        }
2389
2390        // No history - try to navigate to parent
2391        let Some(parent) = self.view_root.parent() else {
2392            return; // Already at root
2393        };
2394
2395        let parent = parent.to_path_buf();
2396        let dir_we_came_from = self.view_root.clone();
2397
2398        // Navigation will succeed - save current state to forward history
2399        self.forward_history.push(current_state);
2400
2401        // Check if parent is within our current tree
2402        let parent_in_tree = parent.starts_with(&self.path) || parent == self.path;
2403
2404        if parent_in_tree {
2405            // Navigate within existing tree
2406            self.tree_state.collapse(&dir_we_came_from);
2407            self.view_root = parent;
2408            self.tree_state.expand(&self.view_root);
2409        } else {
2410            // Navigate beyond scan root - need to extend our tree
2411            self.navigate_to_parent_beyond_scan_root(&parent, &dir_we_came_from);
2412            return;
2413        }
2414
2415        // Select the directory we just came from
2416        self.select_child_by_name(&dir_we_came_from);
2417        self.sync_to_active_tab();
2418    }
2419
2420    /// Navigate to a parent directory that's outside the current scan root.
2421    /// This extends the tree by lazy-loading the parent.
2422    fn navigate_to_parent_beyond_scan_root(&mut self, parent: &PathBuf, dir_we_came_from: &PathBuf) {
2423        // Cache current tree before navigating away (if we have scan data)
2424        if let Some(tree) = &self.tree {
2425            if self.has_full_scan {
2426                self.scanned_cache.insert(tree.root_path.clone(), tree.clone());
2427            }
2428        }
2429
2430        // Load the parent directory contents
2431        let Ok(mut parent_tree) = gravityfile_scan::quick_list(parent) else {
2432            return; // Can't access parent directory
2433        };
2434
2435        // Merge any cached scans into the parent tree's children
2436        self.merge_cached_scans_into_tree(&mut parent_tree.root, parent);
2437
2438        // Create a new tree rooted at the parent
2439        self.path = parent.clone();
2440        self.tree = Some(parent_tree);
2441        self.view_root = parent.clone();
2442        self.view_history.clear();
2443        // Note: don't clear forward_history here - we want to allow navigating back
2444
2445        // The parent tree is quick-listed, not fully scanned
2446        self.has_full_scan = false;
2447
2448        // Reset tree state for the new root
2449        self.tree_state = TreeState::new(parent.clone());
2450
2451        // Update cached parent for Miller columns display
2452        self.update_cached_parent();
2453
2454        // Select the directory we came from
2455        self.select_child_by_name(dir_we_came_from);
2456        self.sync_to_active_tab();
2457    }
2458
2459    /// Merge cached scan data into a tree node's children.
2460    /// For each child directory in the node, if we have cached scan data,
2461    /// replace the placeholder child with the fully scanned version.
2462    fn merge_cached_scans_into_tree(&self, node: &mut gravityfile_core::FileNode, node_path: &PathBuf) {
2463        for child in &mut node.children {
2464            if child.is_dir() {
2465                let child_path = node_path.join(&*child.name);
2466                if let Some(cached_tree) = self.scanned_cache.get(&child_path) {
2467                    // Replace this child with the cached scan data
2468                    *child = cached_tree.root.clone();
2469                }
2470            }
2471        }
2472    }
2473
2474    /// Select a child entry by matching its name.
2475    fn select_child_by_name(&mut self, target_path: &PathBuf) {
2476        let dir_name = target_path
2477            .file_name()
2478            .and_then(|n| n.to_str())
2479            .unwrap_or("");
2480
2481        match self.layout_mode {
2482            LayoutMode::Tree => {
2483                // In tree view, find the index of the target in flattened list
2484                if let Some((node, root_path)) = self.get_view_root_node() {
2485                    let items = TreeView::new(node, &root_path, &self.theme, &self.marked, &self.clipboard)
2486                        .flatten(&self.tree_state);
2487                    if let Some(idx) = items.iter().position(|item| {
2488                        item.path.file_name().and_then(|n| n.to_str()) == Some(dir_name)
2489                    }) {
2490                        self.tree_state.selected = idx;
2491                    } else {
2492                        self.tree_state.selected = 0;
2493                    }
2494                } else {
2495                    self.tree_state.selected = 0;
2496                }
2497                self.tree_state.offset = 0;
2498                self.update_cached_tree_len();
2499            }
2500            LayoutMode::Miller => {
2501                // In miller view, find the index in children
2502                if let Some((node, _)) = self.get_view_root_node() {
2503                    let idx = node
2504                        .children
2505                        .iter()
2506                        .position(|c| c.name.as_str() == dir_name)
2507                        .unwrap_or(0);
2508                    self.miller_state.selected = idx;
2509                } else {
2510                    self.miller_state.selected = 0;
2511                }
2512                self.miller_state.offset = 0;
2513                self.update_cached_miller_len();
2514            }
2515        }
2516    }
2517
2518    /// Find a node by path in the tree.
2519    fn find_node_at_path<'a>(
2520        node: &'a gravityfile_core::FileNode,
2521        target_path: &PathBuf,
2522        current_path: &PathBuf,
2523    ) -> Option<&'a gravityfile_core::FileNode> {
2524        if current_path == target_path {
2525            return Some(node);
2526        }
2527        for child in &node.children {
2528            let child_path = current_path.join(&*child.name);
2529            if let Some(found) = Self::find_node_at_path(child, target_path, &child_path) {
2530                return Some(found);
2531            }
2532        }
2533        None
2534    }
2535
2536    /// Get the current view root node.
2537    fn get_view_root_node(&self) -> Option<(&gravityfile_core::FileNode, PathBuf)> {
2538        let tree = self.tree.as_ref()?;
2539        let node = Self::find_node_at_path(&tree.root, &self.view_root, &tree.root_path)?;
2540        Some((node, self.view_root.clone()))
2541    }
2542
2543    /// Get the parent node of the current view root (for Miller columns).
2544    fn get_parent_node(&self) -> Option<&gravityfile_core::FileNode> {
2545        let tree = self.tree.as_ref()?;
2546        let parent_path = self.view_root.parent()?.to_path_buf();
2547
2548        // First, check if parent is within the main tree
2549        if parent_path.starts_with(&tree.root_path) || parent_path == tree.root_path {
2550            return Self::find_node_at_path(&tree.root, &parent_path, &tree.root_path);
2551        }
2552
2553        // If we're at the tree root and have a cached parent, use that
2554        if self.view_root == tree.root_path {
2555            if let Some(cached) = &self.cached_parent_tree {
2556                return Some(&cached.root);
2557            }
2558        }
2559
2560        None
2561    }
2562
2563    /// Update the cached parent tree for Miller columns display.
2564    /// Called when navigating to ensure the parent column can be shown.
2565    fn update_cached_parent(&mut self) {
2566        let Some(tree) = &self.tree else { return };
2567
2568        // Only need to cache parent if we're at or near the tree root
2569        if self.view_root == tree.root_path {
2570            if let Some(parent_path) = self.view_root.parent() {
2571                let parent_path_buf = parent_path.to_path_buf();
2572                // Load the parent directory
2573                if let Ok(mut parent_tree) = gravityfile_scan::quick_list(&parent_path_buf) {
2574                    // Merge any cached scans into the parent tree's children
2575                    // This ensures previously scanned directories show their data
2576                    self.merge_cached_scans_into_tree(&mut parent_tree.root, &parent_path_buf);
2577                    self.cached_parent_tree = Some(parent_tree);
2578                }
2579            }
2580        } else {
2581            // Don't need cached parent when navigating within the tree
2582            self.cached_parent_tree = None;
2583        }
2584    }
2585
2586    /// Get the current directory name for highlighting in parent column.
2587    fn get_current_dir_name(&self) -> Option<String> {
2588        self.view_root
2589            .file_name()
2590            .and_then(|n| n.to_str())
2591            .map(|s| s.to_string())
2592    }
2593
2594    /// Handle raw key input for command mode.
2595    fn handle_command_input(&mut self, key: crossterm::event::KeyEvent) {
2596        match self.command_input.handle_key(key) {
2597            CommandKeyResult::Continue => {}
2598            CommandKeyResult::Cancel => {
2599                self.mode = AppMode::Normal;
2600            }
2601            CommandKeyResult::Execute(cmd) => {
2602                self.mode = AppMode::Normal;
2603                self.execute_command(&cmd);
2604            }
2605        }
2606    }
2607
2608    /// Execute a command from the command palette.
2609    fn execute_command(&mut self, cmd: &str) {
2610        match parse_command(cmd) {
2611            CommandAction::None => {}
2612            CommandAction::Quit => {
2613                self.mode = AppMode::Quit;
2614            }
2615            CommandAction::Refresh => {
2616                self.marked.clear();
2617                self.start_scan();
2618            }
2619            CommandAction::NavigateTo(target) => {
2620                self.cd_to_path(&target);
2621            }
2622            CommandAction::GoToRoot => {
2623                self.go_to_root();
2624            }
2625            CommandAction::NavigateBack => {
2626                self.navigate_back();
2627            }
2628            CommandAction::ShowHelp => {
2629                self.mode = AppMode::Help;
2630            }
2631            CommandAction::SwitchView(view) => {
2632                self.view = view;
2633            }
2634            CommandAction::ClearMarks => {
2635                self.marked.clear();
2636            }
2637            CommandAction::ToggleDetails => {
2638                self.show_details = !self.show_details;
2639            }
2640            CommandAction::SetTheme(theme_cmd) => match theme_cmd {
2641                ThemeCommand::Dark => self.theme = Theme::dark(),
2642                ThemeCommand::Light => self.theme = Theme::light(),
2643                ThemeCommand::Toggle => self.theme = self.theme.toggle(),
2644            },
2645            CommandAction::SetLayout(layout_cmd) => {
2646                match layout_cmd {
2647                    LayoutCommand::Tree => {
2648                        if self.layout_mode != LayoutMode::Tree {
2649                            // Use sync to preserve selection when switching to tree
2650                            self.sync_layout_selection();
2651                        }
2652                    }
2653                    LayoutCommand::Miller => {
2654                        if self.layout_mode != LayoutMode::Miller {
2655                            // Use sync to preserve selection when switching to miller
2656                            self.sync_layout_selection();
2657                        }
2658                    }
2659                    LayoutCommand::Toggle => {
2660                        self.sync_layout_selection();
2661                    }
2662                }
2663            }
2664            CommandAction::SetSort(sort_cmd) => {
2665                match sort_cmd {
2666                    SortCommand::SizeDesc => self.sort_mode = SortMode::SizeDescending,
2667                    SortCommand::SizeAsc => self.sort_mode = SortMode::SizeAscending,
2668                    SortCommand::NameAsc => self.sort_mode = SortMode::NameAscending,
2669                    SortCommand::NameDesc => self.sort_mode = SortMode::NameDescending,
2670                    SortCommand::DateDesc => self.sort_mode = SortMode::ModifiedDescending,
2671                    SortCommand::DateAsc => self.sort_mode = SortMode::ModifiedAscending,
2672                    SortCommand::CountDesc => self.sort_mode = SortMode::CountDescending,
2673                    SortCommand::CountAsc => self.sort_mode = SortMode::CountAscending,
2674                    SortCommand::Cycle => self.sort_mode = self.sort_mode.next(),
2675                    SortCommand::Reverse => self.sort_mode = self.sort_mode.reverse(),
2676                }
2677                self.apply_sort();
2678            }
2679            CommandAction::Yank => {
2680                self.yank_selection();
2681            }
2682            CommandAction::Cut => {
2683                self.cut_selection();
2684            }
2685            CommandAction::Paste => {
2686                self.paste_clipboard();
2687            }
2688            CommandAction::Delete => {
2689                if !self.marked.is_empty() {
2690                    self.mode = AppMode::ConfirmDelete;
2691                }
2692            }
2693            CommandAction::Rename(name) => {
2694                if let Some(name) = name {
2695                    // Direct rename with provided name
2696                    if let Some(path) = self.get_selected_path() {
2697                        self.execute_rename(path, name);
2698                    }
2699                } else {
2700                    // Enter rename mode
2701                    self.start_rename();
2702                }
2703            }
2704            CommandAction::CreateFile(name) => {
2705                if let Some(name) = name {
2706                    // Direct create with provided name
2707                    self.execute_create_file(self.view_root.clone(), name);
2708                } else {
2709                    // Enter create file mode
2710                    self.start_create_file();
2711                }
2712            }
2713            CommandAction::CreateDirectory(name) => {
2714                if let Some(name) = name {
2715                    // Direct create with provided name
2716                    self.execute_create_directory(self.view_root.clone(), name);
2717                } else {
2718                    // Enter create directory mode
2719                    self.start_create_directory();
2720                }
2721            }
2722            CommandAction::Take(name) => {
2723                if let Some(name) = name {
2724                    // Direct take with provided name
2725                    self.execute_take(self.view_root.clone(), name);
2726                } else {
2727                    // Enter take mode
2728                    self.start_take();
2729                }
2730            }
2731            CommandAction::Undo => {
2732                self.execute_undo();
2733            }
2734        }
2735    }
2736
2737    /// Go to the scan root.
2738    fn go_to_root(&mut self) {
2739        self.view_root = self.path.clone();
2740        self.view_history.clear();
2741        self.forward_history.clear();
2742        self.tree_state.selected = 0;
2743        self.tree_state.offset = 0;
2744        self.tree_state.expand(&self.view_root);
2745        self.update_cached_tree_len();
2746        self.sync_to_active_tab();
2747    }
2748
2749    /// Change directory to a path.
2750    fn cd_to_path(&mut self, target: &str) {
2751        if target == "/" || target == "~" {
2752            self.go_to_root();
2753            return;
2754        }
2755        if target == ".." {
2756            self.navigate_back();
2757            return;
2758        }
2759
2760        let Some(tree) = &self.tree else {
2761            return;
2762        };
2763
2764        let target_path = if target.starts_with('/') {
2765            let relative = target.trim_start_matches('/');
2766            self.path.join(relative)
2767        } else {
2768            self.view_root.join(target)
2769        };
2770
2771        if let Some(node) = Self::find_node_at_path(&tree.root, &target_path, &tree.root_path) {
2772            if node.is_dir() {
2773                let saved_expanded = self.tree_state.expanded.clone();
2774                let saved_selected = self.tree_state.selected;
2775                self.view_history
2776                    .push((self.view_root.clone(), saved_selected, saved_expanded));
2777
2778                self.view_root = target_path;
2779                self.tree_state.selected = 0;
2780                self.tree_state.offset = 0;
2781                self.tree_state.expand(&self.view_root);
2782                self.update_cached_tree_len();
2783                self.sync_to_active_tab();
2784            }
2785        }
2786    }
2787
2788    /// Check if a path is marked for deletion.
2789    pub fn is_marked(&self, path: &PathBuf) -> bool {
2790        self.marked.contains(path)
2791    }
2792
2793    /// Cycle to the next sort mode and apply sorting.
2794    fn cycle_sort_mode(&mut self) {
2795        self.sort_mode = self.sort_mode.next();
2796        self.apply_sort();
2797    }
2798
2799    /// Reverse the current sort direction.
2800    fn reverse_sort_mode(&mut self) {
2801        self.sort_mode = self.sort_mode.reverse();
2802        self.apply_sort();
2803    }
2804
2805    /// Sync current tab state to the active tab.
2806    fn sync_to_active_tab(&mut self) {
2807        if let Some(tab) = self.tab_manager.active_tab_mut() {
2808            tab.view_root = self.view_root.clone();
2809            tab.label = self.view_root
2810                .file_name()
2811                .map(|n| n.to_string_lossy().to_string())
2812                .unwrap_or_else(|| self.view_root.to_string_lossy().to_string());
2813        }
2814    }
2815
2816    /// Sync app state from the active tab.
2817    fn sync_from_active_tab(&mut self) {
2818        if let Some(tab) = self.tab_manager.active_tab() {
2819            // Only sync if view_root is different
2820            if tab.view_root != self.view_root {
2821                self.view_root = tab.view_root.clone();
2822                // Reset tree state for the new view
2823                self.tree_state.selected = 0;
2824                self.tree_state.offset = 0;
2825                self.tree_state.expand(&self.view_root);
2826                self.miller_state.reset();
2827                match self.layout_mode {
2828                    LayoutMode::Tree => self.update_cached_tree_len(),
2829                    LayoutMode::Miller => self.update_cached_miller_len(),
2830                }
2831            }
2832        }
2833    }
2834
2835    /// Start fuzzy search mode.
2836    fn start_search(&mut self) {
2837        // Populate search paths from the current tree
2838        self.update_search_paths();
2839        self.search_state.activate();
2840        self.mode = AppMode::Search;
2841    }
2842
2843    /// Update search paths from the file tree.
2844    fn update_search_paths(&mut self) {
2845        let mut paths = Vec::new();
2846
2847        if let Some(tree) = &self.tree {
2848            Self::collect_paths_recursive(
2849                &tree.root,
2850                &tree.root_path,
2851                &self.view_root,
2852                &mut paths,
2853            );
2854        }
2855
2856        self.search_state.update_paths(paths);
2857    }
2858
2859    /// Recursively collect paths from the file tree.
2860    fn collect_paths_recursive(
2861        node: &gravityfile_core::FileNode,
2862        current_path: &PathBuf,
2863        view_root: &PathBuf,
2864        paths: &mut Vec<(PathBuf, String, bool)>,
2865    ) {
2866        // Only include paths under view_root
2867        if !current_path.starts_with(view_root) && current_path != view_root {
2868            return;
2869        }
2870
2871        // Compute relative display path from view_root
2872        let display = if current_path == view_root {
2873            ".".to_string()
2874        } else {
2875            current_path
2876                .strip_prefix(view_root)
2877                .map(|p| p.to_string_lossy().to_string())
2878                .unwrap_or_else(|_| node.name.to_string())
2879        };
2880
2881        paths.push((current_path.clone(), display, node.is_dir()));
2882
2883        // Recurse into children
2884        for child in &node.children {
2885            let child_path = current_path.join(&*child.name);
2886            Self::collect_paths_recursive(child, &child_path, view_root, paths);
2887        }
2888    }
2889
2890    /// Handle key input during search mode.
2891    fn handle_search_input(&mut self, key: crossterm::event::KeyEvent) {
2892        use crossterm::event::{KeyCode, KeyModifiers};
2893
2894        match (key.code, key.modifiers) {
2895            // Cancel search
2896            (KeyCode::Esc, _) => {
2897                self.search_state.deactivate();
2898                self.mode = AppMode::Normal;
2899            }
2900            // Execute search (navigate to selected)
2901            (KeyCode::Enter, _) => {
2902                if let Some(result) = self.search_state.selected_result() {
2903                    let target_path = result.path.clone();
2904                    self.navigate_to_search_result(&target_path);
2905                }
2906                self.search_state.deactivate();
2907                self.mode = AppMode::Normal;
2908            }
2909            // Navigation in results
2910            (KeyCode::Up, _) | (KeyCode::Char('k'), KeyModifiers::CONTROL) => {
2911                self.search_state.move_up();
2912            }
2913            (KeyCode::Down, _) | (KeyCode::Char('j'), KeyModifiers::CONTROL) => {
2914                self.search_state.move_down();
2915            }
2916            // Cycle search mode
2917            (KeyCode::Tab, _) => {
2918                self.search_state.cycle_mode();
2919            }
2920            // Cursor movement
2921            (KeyCode::Left, _) => {
2922                self.search_state.move_cursor_left();
2923            }
2924            (KeyCode::Right, _) => {
2925                self.search_state.move_cursor_right();
2926            }
2927            (KeyCode::Home, _) | (KeyCode::Char('a'), KeyModifiers::CONTROL) => {
2928                self.search_state.move_cursor_start();
2929            }
2930            (KeyCode::End, _) | (KeyCode::Char('e'), KeyModifiers::CONTROL) => {
2931                self.search_state.move_cursor_end();
2932            }
2933            // Delete
2934            (KeyCode::Backspace, _) => {
2935                self.search_state.delete_char_before();
2936            }
2937            (KeyCode::Delete, _) => {
2938                self.search_state.delete_char_at();
2939            }
2940            // Clear line
2941            (KeyCode::Char('u'), KeyModifiers::CONTROL) => {
2942                self.search_state.clear_query();
2943            }
2944            // Type character
2945            (KeyCode::Char(c), KeyModifiers::NONE | KeyModifiers::SHIFT) => {
2946                self.search_state.insert_char(c);
2947            }
2948            _ => {}
2949        }
2950    }
2951
2952    /// Navigate to a search result path.
2953    fn navigate_to_search_result(&mut self, target: &PathBuf) {
2954        // If it's a directory, drill into it
2955        // If it's a file, navigate to its parent and select it
2956        if target.is_dir() {
2957            // Save current state
2958            let saved_expanded = self.tree_state.expanded.clone();
2959            let saved_selected = match self.layout_mode {
2960                LayoutMode::Tree => self.tree_state.selected,
2961                LayoutMode::Miller => self.miller_state.selected,
2962            };
2963            self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2964
2965            // Navigate to directory
2966            self.view_root = target.clone();
2967            self.tree_state.selected = 0;
2968            self.tree_state.offset = 0;
2969            self.tree_state.expand(&self.view_root);
2970            self.miller_state.reset();
2971
2972            match self.layout_mode {
2973                LayoutMode::Tree => self.update_cached_tree_len(),
2974                LayoutMode::Miller => self.update_cached_miller_len(),
2975            }
2976            self.sync_to_active_tab();
2977        } else {
2978            // It's a file - navigate to parent and select it
2979            if let Some(parent) = target.parent() {
2980                // Save current state
2981                let saved_expanded = self.tree_state.expanded.clone();
2982                let saved_selected = match self.layout_mode {
2983                    LayoutMode::Tree => self.tree_state.selected,
2984                    LayoutMode::Miller => self.miller_state.selected,
2985                };
2986                self.view_history.push((self.view_root.clone(), saved_selected, saved_expanded));
2987
2988                // Navigate to parent
2989                self.view_root = parent.to_path_buf();
2990                self.tree_state.expand(&self.view_root);
2991
2992                // Get file name before borrowing for position search
2993                let file_name = target.file_name().and_then(|n| n.to_str()).map(|s| s.to_string());
2994                let target_clone = target.clone();
2995
2996                match self.layout_mode {
2997                    LayoutMode::Tree => {
2998                        // For tree view, we need to find the index in flattened list
2999                        self.tree_state.selected = 0;
3000                        self.update_cached_tree_len();
3001
3002                        // Find position in flattened tree
3003                        if let Some((node, _)) = self.get_view_root_node() {
3004                            let items = TreeView::new(node, &self.view_root, &self.theme, &self.marked, &self.clipboard)
3005                                .flatten(&self.tree_state);
3006                            if let Some(pos) = items.iter().position(|i| i.path == target_clone) {
3007                                self.tree_state.selected = pos;
3008                            }
3009                        }
3010                    }
3011                    LayoutMode::Miller => {
3012                        // Find the index of the file in the parent's children
3013                        if let (Some(file_name), Some((node, _))) = (&file_name, self.get_view_root_node()) {
3014                            let idx = node
3015                                .children
3016                                .iter()
3017                                .position(|c| c.name.as_str() == file_name)
3018                                .unwrap_or(0);
3019                            self.miller_state.selected = idx;
3020                        }
3021                        self.update_cached_miller_len();
3022                    }
3023                }
3024                self.sync_to_active_tab();
3025            }
3026        }
3027    }
3028
3029    /// Handle key input during settings mode.
3030    fn handle_settings_input(&mut self, action: KeyAction) {
3031        match action {
3032            KeyAction::Cancel | KeyAction::Quit => {
3033                // Close settings without saving
3034                self.settings_state = None;
3035                self.mode = AppMode::Normal;
3036            }
3037            KeyAction::MoveUp => {
3038                if let Some(ref mut state) = self.settings_state {
3039                    state.move_up();
3040                }
3041            }
3042            KeyAction::MoveDown => {
3043                if let Some(ref mut state) = self.settings_state {
3044                    state.move_down();
3045                }
3046            }
3047            KeyAction::ToggleMark | KeyAction::DrillDown => {
3048                // Space or Enter toggles the current setting
3049                if let Some(ref mut state) = self.settings_state {
3050                    state.toggle_selected();
3051                }
3052            }
3053            KeyAction::Sort => {
3054                // 's' saves settings
3055                if let Some(ref state) = self.settings_state {
3056                    if state.dirty {
3057                        // Save settings to disk
3058                        self.user_settings = state.settings.clone();
3059                        if let Err(e) = self.user_settings.save() {
3060                            self.error = Some(format!("Failed to save settings: {}", e));
3061                        } else {
3062                            // Apply settings immediately where applicable
3063                            self.scan_on_startup = self.user_settings.scan_on_startup;
3064                        }
3065                    }
3066                }
3067                self.settings_state = None;
3068                self.mode = AppMode::Normal;
3069            }
3070            _ => {}
3071        }
3072    }
3073
3074    /// Apply the current sort mode to the file tree.
3075    fn apply_sort(&mut self) {
3076        if let Some(ref mut tree) = self.tree {
3077            Self::sort_node_recursive(&mut tree.root, self.sort_mode);
3078            // Update cached lengths after sorting
3079            match self.layout_mode {
3080                LayoutMode::Tree => self.update_cached_tree_len(),
3081                LayoutMode::Miller => self.update_cached_miller_len(),
3082            }
3083        }
3084    }
3085
3086    /// Recursively sort a node and all its children.
3087    fn sort_node_recursive(node: &mut gravityfile_core::FileNode, mode: SortMode) {
3088        // Sort children based on mode
3089        node.children.sort_by(|a, b| {
3090            match mode {
3091                SortMode::SizeDescending => b.size.cmp(&a.size),
3092                SortMode::SizeAscending => a.size.cmp(&b.size),
3093                SortMode::NameAscending => a.name.cmp(&b.name),
3094                SortMode::NameDescending => b.name.cmp(&a.name),
3095                SortMode::ModifiedDescending => b.timestamps.modified.cmp(&a.timestamps.modified),
3096                SortMode::ModifiedAscending => a.timestamps.modified.cmp(&b.timestamps.modified),
3097                SortMode::CountDescending => {
3098                    let a_count = a.children.len();
3099                    let b_count = b.children.len();
3100                    b_count.cmp(&a_count)
3101                }
3102                SortMode::CountAscending => {
3103                    let a_count = a.children.len();
3104                    let b_count = b.children.len();
3105                    a_count.cmp(&b_count)
3106                }
3107            }
3108        });
3109
3110        // Recursively sort children
3111        for child in &mut node.children {
3112            Self::sort_node_recursive(child, mode);
3113        }
3114    }
3115
3116    /// Get duplicate groups filtered to current view_root.
3117    fn get_filtered_duplicates(
3118        &self,
3119    ) -> Option<(Vec<&gravityfile_analyze::DuplicateGroup>, u64)> {
3120        let dups = self.duplicates.as_ref()?;
3121
3122        if self.view_root == self.path {
3123            let total: u64 = dups.groups.iter().map(|g| g.wasted_bytes).sum();
3124            return Some((dups.groups.iter().collect(), total));
3125        }
3126
3127        let filtered: Vec<&gravityfile_analyze::DuplicateGroup> = dups
3128            .groups
3129            .iter()
3130            .filter(|g| g.paths.iter().any(|p| p.starts_with(&self.view_root)))
3131            .collect();
3132
3133        let total_wasted: u64 = filtered
3134            .iter()
3135            .map(|g| {
3136                let paths_in_view = g
3137                    .paths
3138                    .iter()
3139                    .filter(|p| p.starts_with(&self.view_root))
3140                    .count();
3141                if paths_in_view > 1 {
3142                    g.size * (paths_in_view as u64 - 1)
3143                } else {
3144                    0
3145                }
3146            })
3147            .sum();
3148
3149        Some((filtered, total_wasted))
3150    }
3151
3152    /// Get stale directories filtered to current view_root.
3153    fn get_filtered_stale_dirs(&self) -> Option<Vec<&gravityfile_analyze::StaleDirectory>> {
3154        let age = self.age_report.as_ref()?;
3155
3156        if self.view_root == self.path {
3157            return Some(age.stale_directories.iter().collect());
3158        }
3159
3160        let filtered: Vec<&gravityfile_analyze::StaleDirectory> = age
3161            .stale_directories
3162            .iter()
3163            .filter(|d| d.path.starts_with(&self.view_root))
3164            .collect();
3165
3166        Some(filtered)
3167    }
3168}
3169
3170impl Widget for &App {
3171    fn render(self, area: Rect, buf: &mut Buffer) {
3172        let ctx = RenderContext {
3173            mode: self.mode,
3174            view: self.view,
3175            theme: &self.theme,
3176            path: &self.path,
3177            view_root: &self.view_root,
3178            show_details: self.show_details,
3179            tree: self.tree.as_ref(),
3180            tree_state: &self.tree_state,
3181            layout_mode: self.layout_mode,
3182            miller_state: &self.miller_state,
3183            scan_progress: self.scan_progress.as_ref(),
3184            deletion_progress: self.deletion_progress.as_ref(),
3185            duplicates: self.duplicates.as_ref(),
3186            age_report: self.age_report.as_ref(),
3187            warnings: &self.warnings,
3188            duplicates_state: &self.duplicates_state,
3189            selected_stale_dir: self.selected_stale_dir,
3190            selected_warning: self.selected_warning,
3191            marked: &self.marked,
3192            deletion_message: self.deletion_message.as_ref(),
3193            operation_message: self.operation_message.as_ref(),
3194            error: self.error.as_deref(),
3195            command_input: self.command_input.buffer(),
3196            command_cursor: self.command_input.cursor(),
3197            input_state: self.input_state.as_ref(),
3198            operation_progress: self.operation_progress.as_ref(),
3199            pending_conflict: self.pending_conflict.as_ref(),
3200            clipboard: &self.clipboard,
3201            get_path_size: Box::new(|p| self.get_path_size(p)),
3202            get_selected_info: self.get_selected_info(),
3203            get_view_root_node: self.get_view_root_node(),
3204            get_parent_node: self.get_parent_node(),
3205            current_dir_name: self.get_current_dir_name(),
3206            get_filtered_duplicates: self.get_filtered_duplicates(),
3207            get_filtered_stale_dirs: self.get_filtered_stale_dirs(),
3208            sort_mode: self.sort_mode,
3209            search_state: &self.search_state,
3210            tab_manager: &self.tab_manager,
3211            preview_content: &self.preview_state.content,
3212            preview_mode: self.preview_state.mode,
3213            has_full_scan: self.has_full_scan,
3214            settings_state: self.settings_state.as_ref(),
3215        };
3216
3217        render_app(&ctx, area, buf);
3218    }
3219}