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