Skip to main content

fresh/app/
file_open_input.rs

1//! Input handling for the file open dialog
2//!
3//! This module handles keyboard and mouse input specifically for the file
4//! browser popup when the Open File or Switch Project prompt is active.
5
6use super::file_open::{FileOpenSection, SortMode};
7use super::prompt_actions::parse_path_line_col;
8use super::Editor;
9use crate::input::keybindings::Action;
10use crate::primitives::path_utils::expand_tilde;
11use crate::view::prompt::PromptType;
12use rust_i18n::t;
13
14impl Editor {
15    /// Check if the file open dialog is active (for OpenFile, SwitchProject, or SaveFileAs)
16    pub fn is_file_open_active(&self) -> bool {
17        self.active_window()
18            .prompt
19            .as_ref()
20            .map(|p| {
21                matches!(
22                    p.prompt_type,
23                    PromptType::OpenFile | PromptType::SwitchProject | PromptType::SaveFileAs
24                )
25            })
26            .unwrap_or(false)
27            && self.active_window().file_open_state.is_some()
28    }
29
30    /// Check if we're in folder-only selection mode (Switch Project)
31    fn is_folder_open_mode(&self) -> bool {
32        self.active_window()
33            .prompt
34            .as_ref()
35            .map(|p| p.prompt_type == PromptType::SwitchProject)
36            .unwrap_or(false)
37    }
38
39    /// Check if we're in save mode (Save As)
40    fn is_save_mode(&self) -> bool {
41        self.active_window()
42            .prompt
43            .as_ref()
44            .map(|p| p.prompt_type == PromptType::SaveFileAs)
45            .unwrap_or(false)
46    }
47
48    /// Handle action for file open dialog
49    /// Returns true if the action was handled, false if it should be passed to normal prompt handling
50    pub fn handle_file_open_action(&mut self, action: &Action) -> bool {
51        if !self.is_file_open_active() {
52            return false;
53        }
54
55        match action {
56            // Navigation actions - Up/Down in file list
57            Action::PromptSelectPrev => {
58                if let Some(state) = &mut self.active_window_mut().file_open_state {
59                    state.select_prev();
60                }
61                true
62            }
63            Action::PromptSelectNext => {
64                if let Some(state) = &mut self.active_window_mut().file_open_state {
65                    state.select_next();
66                }
67                true
68            }
69            Action::PromptPageUp => {
70                if let Some(state) = &mut self.active_window_mut().file_open_state {
71                    state.page_up(10);
72                }
73                true
74            }
75            Action::PromptPageDown => {
76                if let Some(state) = &mut self.active_window_mut().file_open_state {
77                    state.page_down(10);
78                }
79                true
80            }
81            // Let Home/End pass through to normal prompt cursor handling
82            // PromptMoveStart and PromptMoveEnd are NOT intercepted here
83
84            // Enter to confirm selection
85            Action::PromptConfirm => {
86                self.file_open_confirm();
87                true
88            }
89
90            // Tab to autocomplete to selected item (and navigate into dir if it's a directory)
91            Action::PromptAcceptSuggestion => {
92                // Get the selected entry info
93                let selected_info =
94                    self.active_window_mut()
95                        .file_open_state
96                        .as_ref()
97                        .and_then(|s| {
98                            s.selected_index
99                                .and_then(|idx| s.entries.get(idx))
100                                .map(|e| {
101                                    (
102                                        e.fs_entry.name.clone(),
103                                        e.fs_entry.is_dir(),
104                                        e.fs_entry.path.clone(),
105                                    )
106                                })
107                        });
108
109                if let Some((name, is_dir, path)) = selected_info {
110                    if is_dir {
111                        // Navigate into the directory
112                        self.file_open_navigate_to(path);
113                    } else {
114                        // Just autocomplete the filename
115                        if let Some(prompt) = &mut self.active_window_mut().prompt {
116                            prompt.input = name;
117                            prompt.cursor_pos = prompt.input.len();
118                        }
119                        // Update the filter to match
120                        self.update_file_open_filter();
121                    }
122                }
123                true
124            }
125
126            // Backspace when filter is empty goes to parent
127            Action::PromptBackspace => {
128                let filter_empty = self
129                    .active_window_mut()
130                    .file_open_state
131                    .as_ref()
132                    .map(|s| s.filter.is_empty())
133                    .unwrap_or(true);
134                let prompt_empty = self
135                    .active_window()
136                    .prompt
137                    .as_ref()
138                    .map(|p| p.input.is_empty())
139                    .unwrap_or(true);
140
141                if filter_empty && prompt_empty {
142                    self.file_open_go_parent();
143                    true
144                } else {
145                    // Let normal prompt handling delete the character
146                    false
147                }
148            }
149
150            // Escape cancels
151            Action::PromptCancel => {
152                self.cancel_prompt();
153                self.active_window_mut().file_open_state = None;
154                true
155            }
156
157            // Toggle hidden files visibility
158            Action::FileBrowserToggleHidden => {
159                self.file_open_toggle_hidden();
160                true
161            }
162
163            // Toggle encoding detection
164            Action::FileBrowserToggleDetectEncoding => {
165                self.file_open_toggle_detect_encoding();
166                true
167            }
168
169            // Text input is handled by normal prompt, but we need to update filter
170            _ => false,
171        }
172    }
173
174    /// Confirm selection in file open dialog
175    fn file_open_confirm(&mut self) {
176        let is_folder_mode = self.is_folder_open_mode();
177        let is_save_mode = self.is_save_mode();
178        let prompt_input = self
179            .active_window()
180            .prompt
181            .as_ref()
182            .map(|p| p.input.clone())
183            .unwrap_or_default();
184        let (path_input, line, column) = parse_path_line_col(&prompt_input);
185
186        // Get the current directory from file open state
187        let current_dir = self
188            .active_window_mut()
189            .file_open_state
190            .as_ref()
191            .map(|s| s.current_dir.clone())
192            .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
193
194        // If there's any prompt input, try to resolve it as a path
195        if !path_input.is_empty() {
196            // Expand tilde and resolve path
197            let tilde_expanded = expand_tilde(&path_input);
198            let expanded_path = if tilde_expanded.is_absolute() {
199                tilde_expanded
200            } else {
201                // Relative path (including plain filename) - resolve against current directory
202                current_dir.join(&path_input)
203            };
204
205            if expanded_path.is_dir() {
206                if is_folder_mode {
207                    // In folder mode, selecting a directory switches to it as the project root
208                    self.file_open_select_folder(expanded_path);
209                } else {
210                    self.file_open_navigate_to(expanded_path);
211                }
212                return;
213            } else if is_save_mode {
214                // In save mode, save to the specified path
215                self.file_open_save_file(expanded_path);
216                return;
217            } else if expanded_path.is_file() && !is_folder_mode {
218                // File exists - open it directly (handles pasted paths before async load completes)
219                // Only allowed in file mode, not folder mode
220                self.file_open_open_file_at_location(expanded_path, line, column);
221                return;
222            } else if !is_folder_mode && Self::should_create_new_file(&path_input) {
223                // File doesn't exist but input looks like a filename - create new file
224                // This handles cases like "newfile.txt" or "/path/to/newfile.txt"
225                self.file_open_create_new_file(expanded_path);
226                return;
227            }
228            // File doesn't exist and doesn't look like a new filename -
229            // fall through to use selected entry from file list
230            // This allows partial filters like "bar" to match "bar.txt"
231        }
232
233        // Use the selected entry from the file list
234        let (path, is_dir) = {
235            let state = match &self.active_window_mut().file_open_state {
236                Some(s) => s,
237                None => {
238                    // If no file is selected but we're in folder mode, use the current directory
239                    if is_folder_mode {
240                        self.file_open_select_folder(current_dir);
241                    }
242                    return;
243                }
244            };
245
246            let path = match state.get_selected_path() {
247                Some(p) => p,
248                None => {
249                    // In save mode with no input, we can't save
250                    if is_save_mode {
251                        self.set_status_message(t!("file.save_as_no_filename").to_string());
252                        return;
253                    }
254                    // If no file is selected but we're in folder mode, use the current directory
255                    if is_folder_mode {
256                        self.file_open_select_folder(current_dir);
257                    }
258                    return;
259                }
260            };
261
262            (path, state.selected_is_dir())
263        };
264
265        if is_dir {
266            if is_folder_mode {
267                // In folder mode, selecting a directory switches to it as the project root
268                self.file_open_select_folder(path);
269            } else {
270                // Navigate into directory
271                self.file_open_navigate_to(path);
272            }
273        } else if is_save_mode {
274            // In save mode, save to the selected file
275            self.file_open_save_file(path);
276        } else if !is_folder_mode {
277            // Open the file (only in file mode)
278            self.file_open_open_file(path);
279        }
280        // In folder mode, selecting a file does nothing
281    }
282
283    /// Select a folder as the new project root (for SwitchProject mode)
284    fn file_open_select_folder(&mut self, path: std::path::PathBuf) {
285        // Close the file browser
286        self.active_window_mut().file_open_state = None;
287        self.active_window_mut().prompt = None;
288
289        // Change the working directory
290        self.change_working_dir(path);
291    }
292
293    /// Navigate to a directory in the file browser
294    fn file_open_navigate_to(&mut self, path: std::path::PathBuf) {
295        // Clear prompt input
296        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
297            prompt.input.clear();
298            prompt.cursor_pos = 0;
299        }
300
301        // Load the new directory
302        self.load_file_open_directory(path);
303    }
304
305    /// Open a file from the file browser
306    fn file_open_open_file(&mut self, path: std::path::PathBuf) {
307        self.file_open_open_file_at_location(path, None, None);
308    }
309
310    /// Open a file from the file browser and optionally jump to line/column
311    fn file_open_open_file_at_location(
312        &mut self,
313        path: std::path::PathBuf,
314        line: Option<usize>,
315        column: Option<usize>,
316    ) {
317        // Check if encoding detection is disabled - if so, prompt for encoding first
318        let detect_encoding = self
319            .active_window_mut()
320            .file_open_state
321            .as_ref()
322            .map(|s| s.detect_encoding)
323            .unwrap_or(true);
324
325        // Close the file browser
326        self.active_window_mut().file_open_state = None;
327        self.active_window_mut().prompt = None;
328
329        if !detect_encoding {
330            // Start encoding selection prompt, then open with selected encoding
331            self.start_open_file_with_encoding_prompt(path);
332            return;
333        }
334
335        // Reset key context to Normal so editor gets focus
336        // This is important when the file explorer was focused before opening the file browser
337        self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
338
339        // Open the file with auto-detected encoding
340        tracing::info!("[SYNTAX DEBUG] file_open_dialog opening file: {:?}", path);
341        if let Err(e) = self.open_file(&path) {
342            // Check if this is a large file encoding confirmation error
343            if let Some(confirmation) =
344                e.downcast_ref::<crate::model::buffer::LargeFileEncodingConfirmation>()
345            {
346                // Show confirmation prompt for large file with non-resynchronizable encoding
347                let size_mb = confirmation.file_size as f64 / (1024.0 * 1024.0);
348                let load_key = t!("file.large_encoding.key.load").to_string();
349                let encoding_key = t!("file.large_encoding.key.encoding").to_string();
350                let cancel_key = t!("file.large_encoding.key.cancel").to_string();
351                let prompt_msg = t!(
352                    "file.large_encoding_prompt",
353                    encoding = confirmation.encoding.display_name(),
354                    size = format!("{:.0}", size_mb),
355                    load_key = load_key,
356                    encoding_key = encoding_key,
357                    cancel_key = cancel_key
358                )
359                .to_string();
360                self.start_prompt(
361                    prompt_msg,
362                    PromptType::ConfirmLargeFileEncoding {
363                        path: confirmation.path.clone(),
364                    },
365                );
366            } else {
367                self.set_status_message(
368                    t!("file.error_opening", error = e.to_string()).to_string(),
369                );
370            }
371        } else {
372            if let Some(line) = line {
373                self.goto_line_col(line, column);
374            }
375            self.set_status_message(
376                t!("file.opened", path = path.display().to_string()).to_string(),
377            );
378        }
379    }
380
381    /// Start the large file encoding confirmation prompt.
382    ///
383    /// This is called when opening a file that has a non-resynchronizable encoding
384    /// (like GBK, GB18030, Shift-JIS) and the file is large enough to require
385    /// user confirmation before loading the entire file into memory.
386    pub fn start_large_file_encoding_confirmation(
387        &mut self,
388        confirmation: &crate::model::buffer::LargeFileEncodingConfirmation,
389    ) {
390        let size_mb = confirmation.file_size as f64 / (1024.0 * 1024.0);
391        let load_key = t!("file.large_encoding.key.load").to_string();
392        let encoding_key = t!("file.large_encoding.key.encoding").to_string();
393        let cancel_key = t!("file.large_encoding.key.cancel").to_string();
394        let prompt_msg = t!(
395            "file.large_encoding_prompt",
396            encoding = confirmation.encoding.display_name(),
397            size = format!("{:.0}", size_mb),
398            load_key = load_key,
399            encoding_key = encoding_key,
400            cancel_key = cancel_key
401        )
402        .to_string();
403        self.start_prompt(
404            prompt_msg,
405            PromptType::ConfirmLargeFileEncoding {
406                path: confirmation.path.clone(),
407            },
408        );
409    }
410
411    /// Start the encoding selection prompt for opening a file
412    pub fn start_open_file_with_encoding_prompt(&mut self, path: std::path::PathBuf) {
413        use crate::model::buffer::Encoding;
414        use crate::view::prompt::PromptType;
415
416        // Default to UTF-8 as the suggested encoding
417        let suggestions: Vec<crate::input::commands::Suggestion> = Encoding::all()
418            .iter()
419            .map(|enc| {
420                let is_default = *enc == Encoding::Utf8;
421                crate::input::commands::Suggestion {
422                    text: format!("{} ({})", enc.display_name(), enc.description()),
423                    description: if is_default {
424                        Some("default".to_string())
425                    } else {
426                        None
427                    },
428                    value: Some(enc.display_name().to_string()),
429                    disabled: false,
430                    keybinding: None,
431                    source: None,
432                }
433            })
434            .collect();
435
436        self.active_window_mut().prompt = Some(crate::view::prompt::Prompt::with_suggestions(
437            "Select encoding: ".to_string(),
438            PromptType::OpenFileWithEncoding { path },
439            suggestions,
440        ));
441
442        // Pre-select UTF-8
443        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
444            if !prompt.suggestions.is_empty() {
445                prompt.selected_suggestion = Some(0); // UTF-8 is first
446                let enc = Encoding::Utf8;
447                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
448                prompt.cursor_pos = prompt.input.len();
449            }
450        }
451    }
452
453    /// Create a new file (opens an unsaved buffer that will create the file on save)
454    fn file_open_create_new_file(&mut self, path: std::path::PathBuf) {
455        // Close the file browser
456        self.active_window_mut().file_open_state = None;
457        self.active_window_mut().prompt = None;
458
459        // Reset key context to Normal so editor gets focus
460        // This is important when the file explorer was focused before opening the file browser
461        self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
462
463        // Open the file - this will create an unsaved buffer with the path set
464        if let Err(e) = self.open_file(&path) {
465            self.set_status_message(t!("file.error_opening", error = e.to_string()).to_string());
466        } else {
467            self.set_status_message(
468                t!("file.created_new", path = path.display().to_string()).to_string(),
469            );
470        }
471    }
472
473    /// Save the current buffer to a file (for SaveFileAs mode)
474    fn file_open_save_file(&mut self, path: std::path::PathBuf) {
475        // Close the file browser
476        self.active_window_mut().file_open_state = None;
477        self.active_window_mut().prompt = None;
478
479        self.save_file_as_with_checks(path);
480    }
481
482    /// Check if the input looks like a filename that should be created
483    /// (has an extension or contains a path separator)
484    fn should_create_new_file(input: &str) -> bool {
485        // If input contains a dot with something after it (extension), or
486        // contains a path separator, treat it as a file to be created
487        let has_extension = input.rfind('.').is_some_and(|pos| {
488            // Check there's something after the dot that's not a path separator
489            let after_dot = &input[pos + 1..];
490            !after_dot.is_empty() && !after_dot.contains('/') && !after_dot.contains('\\')
491        });
492
493        let has_path_separator = input.contains('/') || input.contains('\\');
494
495        has_extension || has_path_separator
496    }
497
498    /// Navigate to parent directory
499    fn file_open_go_parent(&mut self) {
500        let parent = self
501            .active_window_mut()
502            .file_open_state
503            .as_ref()
504            .and_then(|s| s.current_dir.parent())
505            .map(|p| p.to_path_buf());
506
507        if let Some(parent_path) = parent {
508            self.file_open_navigate_to(parent_path);
509        }
510    }
511
512    /// Update filter when prompt text changes
513    pub fn update_file_open_filter(&mut self) {
514        if !self.is_file_open_active() {
515            return;
516        }
517
518        let filter = self
519            .active_window()
520            .prompt
521            .as_ref()
522            .map(|p| p.input.clone())
523            .unwrap_or_default();
524
525        // Check if user typed/pasted a path containing directory separators
526        // Navigate to the parent directory of the path (so the file appears in the list)
527        if filter.contains('/') {
528            let current_dir = self
529                .active_window_mut()
530                .file_open_state
531                .as_ref()
532                .map(|s| s.current_dir.clone())
533                .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
534
535            // Build the full path
536            // Expand tilde and resolve path
537            let tilde_expanded = expand_tilde(&filter);
538            let full_path = if tilde_expanded.is_absolute() {
539                tilde_expanded
540            } else {
541                current_dir.join(&filter)
542            };
543
544            // Get the parent directory and filename
545            let (target_dir, filename) = if filter.ends_with('/') {
546                // Path ends with /, treat the whole thing as a directory
547                (full_path.clone(), String::new())
548            } else {
549                // Get parent directory so the file will be in the listing
550                let parent = full_path
551                    .parent()
552                    .map(|p| p.to_path_buf())
553                    .unwrap_or(full_path.clone());
554                let name = full_path
555                    .file_name()
556                    .map(|n| n.to_string_lossy().to_string())
557                    .unwrap_or_default();
558                (parent, name)
559            };
560
561            // Navigate to target directory if it exists and is different from current
562            if target_dir.is_dir() && target_dir != current_dir {
563                // Update prompt to only show the filename (directory is shown separately)
564                if let Some(prompt) = &mut self.active_window_mut().prompt {
565                    prompt.input = filename.clone();
566                    prompt.cursor_pos = prompt.input.len();
567                }
568                self.load_file_open_directory(target_dir);
569
570                // Apply filter with the filename only
571                if let Some(state) = &mut self.active_window_mut().file_open_state {
572                    state.apply_filter(&filename);
573                }
574                return;
575            }
576        }
577
578        if let Some(state) = &mut self.active_window_mut().file_open_state {
579            state.apply_filter(&filter);
580        }
581    }
582
583    /// Handle sorting toggle (called from keybinding)
584    pub fn file_open_toggle_sort(&mut self, mode: SortMode) {
585        if let Some(state) = &mut self.active_window_mut().file_open_state {
586            state.set_sort_mode(mode);
587        }
588    }
589
590    /// Handle hidden files toggle
591    pub fn file_open_toggle_hidden(&mut self) {
592        if let Some(state) = &mut self.active_window_mut().file_open_state {
593            let show_hidden = state.show_hidden;
594            state.show_hidden = !show_hidden;
595            let new_state = state.show_hidden;
596
597            // Reload directory to apply change
598            let current_dir = state.current_dir.clone();
599            self.load_file_open_directory(current_dir);
600
601            // Show status message
602            let msg = if new_state {
603                "Showing hidden files"
604            } else {
605                "Hiding hidden files"
606            };
607            self.set_status_message(msg.to_string());
608        }
609    }
610
611    /// Handle encoding detection toggle
612    pub fn file_open_toggle_detect_encoding(&mut self) {
613        if let Some(state) = &mut self.active_window_mut().file_open_state {
614            state.toggle_detect_encoding();
615            let new_state = state.detect_encoding;
616
617            // Show status message
618            let msg = if new_state {
619                "Encoding auto-detection enabled"
620            } else {
621                "Encoding auto-detection disabled - will prompt for encoding"
622            };
623            self.set_status_message(msg.to_string());
624        }
625    }
626
627    /// Handle mouse wheel scroll in file browser
628    /// Returns true if the scroll was handled
629    pub fn handle_file_open_scroll(&mut self, delta: i32) -> bool {
630        if !self.is_file_open_active() {
631            return false;
632        }
633
634        let visible_rows = self
635            .active_window_mut()
636            .file_browser_layout
637            .as_ref()
638            .map(|l| l.visible_rows)
639            .unwrap_or(10);
640
641        if let Some(state) = &mut self.active_window_mut().file_open_state {
642            let total_entries = state.entries.len();
643            if total_entries <= visible_rows {
644                // No scrolling needed if all entries fit
645                return true;
646            }
647
648            let max_scroll = total_entries.saturating_sub(visible_rows);
649
650            if delta < 0 {
651                // Scroll up
652                let scroll_amount = (-delta) as usize;
653                state.scroll_offset = state.scroll_offset.saturating_sub(scroll_amount);
654            } else {
655                // Scroll down
656                let scroll_amount = delta as usize;
657                state.scroll_offset = (state.scroll_offset + scroll_amount).min(max_scroll);
658            }
659            return true;
660        }
661
662        false
663    }
664
665    /// Handle mouse click in file browser
666    pub fn handle_file_open_click(&mut self, x: u16, y: u16) -> bool {
667        if !self.is_file_open_active() {
668            return false;
669        }
670
671        let layout = match &self.active_window_mut().file_browser_layout {
672            Some(l) => l.clone(),
673            None => return false,
674        };
675
676        // Check if click is in the file list
677        if layout.is_in_list(x, y) {
678            let scroll_offset = self
679                .active_window_mut()
680                .file_open_state
681                .as_ref()
682                .map(|s| s.scroll_offset)
683                .unwrap_or(0);
684
685            if let Some(index) = layout.click_to_index(y, scroll_offset) {
686                // Get the entry name before mutating state
687                let entry_name = self
688                    .active_window_mut()
689                    .file_open_state
690                    .as_ref()
691                    .and_then(|s| s.entries.get(index))
692                    .map(|e| e.fs_entry.name.clone());
693
694                if let Some(state) = &mut self.active_window_mut().file_open_state {
695                    state.active_section = FileOpenSection::Files;
696                    if index < state.entries.len() {
697                        state.selected_index = Some(index);
698                    }
699                }
700
701                // Update prompt text to show the selected entry name
702                if let Some(name) = entry_name {
703                    if let Some(prompt) = &mut self.active_window_mut().prompt {
704                        prompt.input = name;
705                        prompt.cursor_pos = prompt.input.len();
706                    }
707                }
708            }
709            return true;
710        }
711
712        // Check if click is on "Show Hidden" checkbox
713        if layout.is_on_show_hidden_checkbox(x, y) {
714            self.file_open_toggle_hidden();
715            return true;
716        }
717
718        // Check if click is on "Detect Encoding" checkbox
719        if layout.is_on_detect_encoding_checkbox(x, y) {
720            self.file_open_toggle_detect_encoding();
721            return true;
722        }
723
724        // Check if click is in navigation area
725        if layout.is_in_nav(x, y) {
726            // Get shortcut labels for hit testing
727            let shortcut_labels: Vec<&str> = self
728                .active_window_mut()
729                .file_open_state
730                .as_ref()
731                .map(|s| s.shortcuts.iter().map(|sc| sc.label.as_str()).collect())
732                .unwrap_or_default();
733
734            if let Some(shortcut_idx) = layout.nav_shortcut_at(x, y, &shortcut_labels) {
735                // Get the path from the shortcut and navigate there
736                let target_path = self
737                    .active_window_mut()
738                    .file_open_state
739                    .as_ref()
740                    .and_then(|s| s.shortcuts.get(shortcut_idx))
741                    .map(|sc| sc.path.clone());
742
743                if let Some(path) = target_path {
744                    if let Some(state) = &mut self.active_window_mut().file_open_state {
745                        state.active_section = FileOpenSection::Navigation;
746                        state.selected_shortcut = shortcut_idx;
747                    }
748                    self.file_open_navigate_to(path);
749                }
750            } else {
751                // Clicked in nav area but not on a shortcut
752                if let Some(state) = &mut self.active_window_mut().file_open_state {
753                    state.active_section = FileOpenSection::Navigation;
754                }
755            }
756            return true;
757        }
758
759        // Check if click is in header (sorting)
760        if layout.is_in_header(x, y) {
761            if let Some(mode) = layout.header_column_at(x) {
762                self.file_open_toggle_sort(mode);
763            }
764            return true;
765        }
766
767        // Check if click is in scrollbar
768        if layout.is_in_scrollbar(x, y) {
769            // Calculate scroll offset based on click position
770            let rel_y = y.saturating_sub(layout.scrollbar_area.y) as usize;
771            let track_height = layout.scrollbar_area.height as usize;
772
773            if let Some(state) = &mut self.active_window_mut().file_open_state {
774                let total_items = state.entries.len();
775                let visible_items = layout.visible_rows;
776
777                if total_items > visible_items && track_height > 0 {
778                    let max_scroll = total_items.saturating_sub(visible_items);
779                    let click_ratio = rel_y as f64 / track_height as f64;
780                    let new_offset = (click_ratio * max_scroll as f64) as usize;
781                    state.scroll_offset = new_offset.min(max_scroll);
782                }
783            }
784            return true;
785        }
786
787        false
788    }
789
790    /// Handle double-click in file browser
791    pub fn handle_file_open_double_click(&mut self, x: u16, y: u16) -> bool {
792        if !self.is_file_open_active() {
793            return false;
794        }
795
796        let layout = match &self.active_window_mut().file_browser_layout {
797            Some(l) => l.clone(),
798            None => return false,
799        };
800
801        // Double-click in file list opens/navigates
802        if layout.is_in_list(x, y) {
803            self.file_open_confirm();
804            return true;
805        }
806
807        false
808    }
809
810    /// Compute hover target for file browser
811    pub fn compute_file_browser_hover(&self, x: u16, y: u16) -> Option<super::types::HoverTarget> {
812        use super::types::HoverTarget;
813
814        let layout = self.active_window().file_browser_layout.as_ref()?;
815
816        // Check "Show Hidden" checkbox first (priority over navigation shortcuts)
817        if layout.is_on_show_hidden_checkbox(x, y) {
818            return Some(HoverTarget::FileBrowserShowHiddenCheckbox);
819        }
820
821        // Check "Detect Encoding" checkbox
822        if layout.is_on_detect_encoding_checkbox(x, y) {
823            return Some(HoverTarget::FileBrowserDetectEncodingCheckbox);
824        }
825
826        // Check navigation shortcuts
827        if layout.is_in_nav(x, y) {
828            let shortcut_labels: Vec<&str> = self
829                .active_window()
830                .file_open_state
831                .as_ref()
832                .map(|s| s.shortcuts.iter().map(|sc| sc.label.as_str()).collect())
833                .unwrap_or_default();
834
835            if let Some(idx) = layout.nav_shortcut_at(x, y, &shortcut_labels) {
836                return Some(HoverTarget::FileBrowserNavShortcut(idx));
837            }
838        }
839
840        // Check column headers
841        if layout.is_in_header(x, y) {
842            if let Some(mode) = layout.header_column_at(x) {
843                return Some(HoverTarget::FileBrowserHeader(mode));
844            }
845        }
846
847        // Check file list entries
848        if layout.is_in_list(x, y) {
849            let scroll_offset = self
850                .active_window()
851                .file_open_state
852                .as_ref()
853                .map(|s| s.scroll_offset)
854                .unwrap_or(0);
855
856            if let Some(idx) = layout.click_to_index(y, scroll_offset) {
857                let total_entries = self
858                    .active_window()
859                    .file_open_state
860                    .as_ref()
861                    .map(|s| s.entries.len())
862                    .unwrap_or(0);
863
864                if idx < total_entries {
865                    return Some(HoverTarget::FileBrowserEntry(idx));
866                }
867            }
868        }
869
870        // Check scrollbar
871        if layout.is_in_scrollbar(x, y) {
872            return Some(HoverTarget::FileBrowserScrollbar);
873        }
874
875        None
876    }
877}