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                    description_spans: None,
423                    text: format!("{} ({})", enc.display_name(), enc.description()),
424                    description: if is_default {
425                        Some("default".to_string())
426                    } else {
427                        None
428                    },
429                    value: Some(enc.display_name().to_string()),
430                    disabled: false,
431                    keybinding: None,
432                    source: None,
433                }
434            })
435            .collect();
436
437        self.active_window_mut().prompt = Some(crate::view::prompt::Prompt::with_suggestions(
438            "Select encoding: ".to_string(),
439            PromptType::OpenFileWithEncoding { path },
440            suggestions,
441        ));
442
443        // Pre-select UTF-8
444        if let Some(prompt) = self.active_window_mut().prompt.as_mut() {
445            if !prompt.suggestions.is_empty() {
446                prompt.selected_suggestion = Some(0); // UTF-8 is first
447                let enc = Encoding::Utf8;
448                prompt.input = format!("{} ({})", enc.display_name(), enc.description());
449                prompt.cursor_pos = prompt.input.len();
450            }
451        }
452    }
453
454    /// Create a new file (opens an unsaved buffer that will create the file on save)
455    fn file_open_create_new_file(&mut self, path: std::path::PathBuf) {
456        // Close the file browser
457        self.active_window_mut().file_open_state = None;
458        self.active_window_mut().prompt = None;
459
460        // Reset key context to Normal so editor gets focus
461        // This is important when the file explorer was focused before opening the file browser
462        self.active_window_mut().key_context = crate::input::keybindings::KeyContext::Normal;
463
464        // Open the file - this will create an unsaved buffer with the path set
465        if let Err(e) = self.open_file(&path) {
466            self.set_status_message(t!("file.error_opening", error = e.to_string()).to_string());
467        } else {
468            self.set_status_message(
469                t!("file.created_new", path = path.display().to_string()).to_string(),
470            );
471        }
472    }
473
474    /// Save the current buffer to a file (for SaveFileAs mode)
475    fn file_open_save_file(&mut self, path: std::path::PathBuf) {
476        // Close the file browser
477        self.active_window_mut().file_open_state = None;
478        self.active_window_mut().prompt = None;
479
480        self.save_file_as_with_checks(path);
481    }
482
483    /// Check if the input looks like a filename that should be created
484    /// (has an extension or contains a path separator)
485    fn should_create_new_file(input: &str) -> bool {
486        // If input contains a dot with something after it (extension), or
487        // contains a path separator, treat it as a file to be created
488        let has_extension = input.rfind('.').is_some_and(|pos| {
489            // Check there's something after the dot that's not a path separator
490            let after_dot = &input[pos + 1..];
491            !after_dot.is_empty() && !after_dot.contains('/') && !after_dot.contains('\\')
492        });
493
494        let has_path_separator = input.contains('/') || input.contains('\\');
495
496        has_extension || has_path_separator
497    }
498
499    /// Navigate to parent directory
500    fn file_open_go_parent(&mut self) {
501        let parent = self
502            .active_window_mut()
503            .file_open_state
504            .as_ref()
505            .and_then(|s| s.current_dir.parent())
506            .map(|p| p.to_path_buf());
507
508        if let Some(parent_path) = parent {
509            self.file_open_navigate_to(parent_path);
510        }
511    }
512
513    /// Update filter when prompt text changes
514    pub fn update_file_open_filter(&mut self) {
515        if !self.is_file_open_active() {
516            return;
517        }
518
519        let filter = self
520            .active_window()
521            .prompt
522            .as_ref()
523            .map(|p| p.input.clone())
524            .unwrap_or_default();
525
526        // Check if user typed/pasted a path containing directory separators
527        // Navigate to the parent directory of the path (so the file appears in the list)
528        if filter.contains('/') {
529            let current_dir = self
530                .active_window_mut()
531                .file_open_state
532                .as_ref()
533                .map(|s| s.current_dir.clone())
534                .unwrap_or_else(|| std::env::current_dir().unwrap_or_default());
535
536            // Build the full path
537            // Expand tilde and resolve path
538            let tilde_expanded = expand_tilde(&filter);
539            let full_path = if tilde_expanded.is_absolute() {
540                tilde_expanded
541            } else {
542                current_dir.join(&filter)
543            };
544
545            // Get the parent directory and filename
546            let (target_dir, filename) = if filter.ends_with('/') {
547                // Path ends with /, treat the whole thing as a directory
548                (full_path.clone(), String::new())
549            } else {
550                // Get parent directory so the file will be in the listing
551                let parent = full_path
552                    .parent()
553                    .map(|p| p.to_path_buf())
554                    .unwrap_or(full_path.clone());
555                let name = full_path
556                    .file_name()
557                    .map(|n| n.to_string_lossy().to_string())
558                    .unwrap_or_default();
559                (parent, name)
560            };
561
562            // Navigate to target directory if it exists and is different from current
563            if target_dir.is_dir() && target_dir != current_dir {
564                // Update prompt to only show the filename (directory is shown separately)
565                if let Some(prompt) = &mut self.active_window_mut().prompt {
566                    prompt.input = filename.clone();
567                    prompt.cursor_pos = prompt.input.len();
568                }
569                self.load_file_open_directory(target_dir);
570
571                // Apply filter with the filename only
572                if let Some(state) = &mut self.active_window_mut().file_open_state {
573                    state.apply_filter(&filename);
574                }
575                return;
576            }
577        }
578
579        if let Some(state) = &mut self.active_window_mut().file_open_state {
580            state.apply_filter(&filter);
581        }
582    }
583
584    /// Handle sorting toggle (called from keybinding)
585    pub fn file_open_toggle_sort(&mut self, mode: SortMode) {
586        if let Some(state) = &mut self.active_window_mut().file_open_state {
587            state.set_sort_mode(mode);
588        }
589    }
590
591    /// Handle hidden files toggle
592    pub fn file_open_toggle_hidden(&mut self) {
593        if let Some(state) = &mut self.active_window_mut().file_open_state {
594            let show_hidden = state.show_hidden;
595            state.show_hidden = !show_hidden;
596            let new_state = state.show_hidden;
597
598            // Reload directory to apply change
599            let current_dir = state.current_dir.clone();
600            self.load_file_open_directory(current_dir);
601
602            // Show status message
603            let msg = if new_state {
604                "Showing hidden files"
605            } else {
606                "Hiding hidden files"
607            };
608            self.set_status_message(msg.to_string());
609        }
610    }
611
612    /// Handle encoding detection toggle
613    pub fn file_open_toggle_detect_encoding(&mut self) {
614        if let Some(state) = &mut self.active_window_mut().file_open_state {
615            state.toggle_detect_encoding();
616            let new_state = state.detect_encoding;
617
618            // Show status message
619            let msg = if new_state {
620                "Encoding auto-detection enabled"
621            } else {
622                "Encoding auto-detection disabled - will prompt for encoding"
623            };
624            self.set_status_message(msg.to_string());
625        }
626    }
627
628    /// Handle mouse wheel scroll in file browser
629    /// Returns true if the scroll was handled
630    pub fn handle_file_open_scroll(&mut self, delta: i32) -> bool {
631        if !self.is_file_open_active() {
632            return false;
633        }
634
635        let visible_rows = self
636            .active_window_mut()
637            .file_browser_layout
638            .as_ref()
639            .map(|l| l.visible_rows)
640            .unwrap_or(10);
641
642        if let Some(state) = &mut self.active_window_mut().file_open_state {
643            let total_entries = state.entries.len();
644            if total_entries <= visible_rows {
645                // No scrolling needed if all entries fit
646                return true;
647            }
648
649            let max_scroll = total_entries.saturating_sub(visible_rows);
650
651            if delta < 0 {
652                // Scroll up
653                let scroll_amount = (-delta) as usize;
654                state.scroll_offset = state.scroll_offset.saturating_sub(scroll_amount);
655            } else {
656                // Scroll down
657                let scroll_amount = delta as usize;
658                state.scroll_offset = (state.scroll_offset + scroll_amount).min(max_scroll);
659            }
660            return true;
661        }
662
663        false
664    }
665
666    /// Handle mouse click in file browser
667    pub fn handle_file_open_click(&mut self, x: u16, y: u16) -> bool {
668        if !self.is_file_open_active() {
669            return false;
670        }
671
672        let layout = match &self.active_window_mut().file_browser_layout {
673            Some(l) => l.clone(),
674            None => return false,
675        };
676
677        // Check if click is in the file list
678        if layout.is_in_list(x, y) {
679            let scroll_offset = self
680                .active_window_mut()
681                .file_open_state
682                .as_ref()
683                .map(|s| s.scroll_offset)
684                .unwrap_or(0);
685
686            if let Some(index) = layout.click_to_index(y, scroll_offset) {
687                // Get the entry name before mutating state
688                let entry_name = self
689                    .active_window_mut()
690                    .file_open_state
691                    .as_ref()
692                    .and_then(|s| s.entries.get(index))
693                    .map(|e| e.fs_entry.name.clone());
694
695                if let Some(state) = &mut self.active_window_mut().file_open_state {
696                    state.active_section = FileOpenSection::Files;
697                    if index < state.entries.len() {
698                        state.selected_index = Some(index);
699                    }
700                }
701
702                // Update prompt text to show the selected entry name
703                if let Some(name) = entry_name {
704                    if let Some(prompt) = &mut self.active_window_mut().prompt {
705                        prompt.input = name;
706                        prompt.cursor_pos = prompt.input.len();
707                    }
708                }
709            }
710            return true;
711        }
712
713        // Check if click is on "Show Hidden" checkbox
714        if layout.is_on_show_hidden_checkbox(x, y) {
715            self.file_open_toggle_hidden();
716            return true;
717        }
718
719        // Check if click is on "Detect Encoding" checkbox
720        if layout.is_on_detect_encoding_checkbox(x, y) {
721            self.file_open_toggle_detect_encoding();
722            return true;
723        }
724
725        // Check if click is in navigation area
726        if layout.is_in_nav(x, y) {
727            // Get shortcut labels for hit testing
728            let shortcut_labels: Vec<&str> = self
729                .active_window_mut()
730                .file_open_state
731                .as_ref()
732                .map(|s| s.shortcuts.iter().map(|sc| sc.label.as_str()).collect())
733                .unwrap_or_default();
734
735            if let Some(shortcut_idx) = layout.nav_shortcut_at(x, y, &shortcut_labels) {
736                // Get the path from the shortcut and navigate there
737                let target_path = self
738                    .active_window_mut()
739                    .file_open_state
740                    .as_ref()
741                    .and_then(|s| s.shortcuts.get(shortcut_idx))
742                    .map(|sc| sc.path.clone());
743
744                if let Some(path) = target_path {
745                    if let Some(state) = &mut self.active_window_mut().file_open_state {
746                        state.active_section = FileOpenSection::Navigation;
747                        state.selected_shortcut = shortcut_idx;
748                    }
749                    self.file_open_navigate_to(path);
750                }
751            } else {
752                // Clicked in nav area but not on a shortcut
753                if let Some(state) = &mut self.active_window_mut().file_open_state {
754                    state.active_section = FileOpenSection::Navigation;
755                }
756            }
757            return true;
758        }
759
760        // Check if click is in header (sorting)
761        if layout.is_in_header(x, y) {
762            if let Some(mode) = layout.header_column_at(x) {
763                self.file_open_toggle_sort(mode);
764            }
765            return true;
766        }
767
768        // Check if click is in scrollbar
769        if layout.is_in_scrollbar(x, y) {
770            // Calculate scroll offset based on click position
771            let rel_y = y.saturating_sub(layout.scrollbar_area.y) as usize;
772            let track_height = layout.scrollbar_area.height as usize;
773
774            if let Some(state) = &mut self.active_window_mut().file_open_state {
775                let total_items = state.entries.len();
776                let visible_items = layout.visible_rows;
777
778                if total_items > visible_items && track_height > 0 {
779                    let max_scroll = total_items.saturating_sub(visible_items);
780                    let click_ratio = rel_y as f64 / track_height as f64;
781                    let new_offset = (click_ratio * max_scroll as f64) as usize;
782                    state.scroll_offset = new_offset.min(max_scroll);
783                }
784            }
785            return true;
786        }
787
788        false
789    }
790
791    /// Handle double-click in file browser
792    pub fn handle_file_open_double_click(&mut self, x: u16, y: u16) -> bool {
793        if !self.is_file_open_active() {
794            return false;
795        }
796
797        let layout = match &self.active_window_mut().file_browser_layout {
798            Some(l) => l.clone(),
799            None => return false,
800        };
801
802        // Double-click in file list opens/navigates
803        if layout.is_in_list(x, y) {
804            // In Switch Project (folder-only) mode, double-clicking a directory
805            // entry should navigate INTO it like a file manager would, rather
806            // than immediately selecting it as the new project root. Selecting
807            // a project root remains available via Enter (or by typing a path)
808            // so keyboard users keep one-keypress confirmation.
809            if self.is_folder_open_mode() {
810                let selected_dir = self.active_window().file_open_state.as_ref().and_then(|s| {
811                    s.selected_index
812                        .and_then(|idx| s.entries.get(idx))
813                        .filter(|e| e.fs_entry.is_dir())
814                        .map(|e| e.fs_entry.path.clone())
815                });
816                if let Some(path) = selected_dir {
817                    self.file_open_navigate_to(path);
818                    return true;
819                }
820            }
821            self.file_open_confirm();
822            return true;
823        }
824
825        false
826    }
827
828    /// Compute hover target for file browser
829    pub fn compute_file_browser_hover(&self, x: u16, y: u16) -> Option<super::types::HoverTarget> {
830        use super::types::HoverTarget;
831
832        let layout = self.active_window().file_browser_layout.as_ref()?;
833
834        // Check "Show Hidden" checkbox first (priority over navigation shortcuts)
835        if layout.is_on_show_hidden_checkbox(x, y) {
836            return Some(HoverTarget::FileBrowserShowHiddenCheckbox);
837        }
838
839        // Check "Detect Encoding" checkbox
840        if layout.is_on_detect_encoding_checkbox(x, y) {
841            return Some(HoverTarget::FileBrowserDetectEncodingCheckbox);
842        }
843
844        // Check navigation shortcuts
845        if layout.is_in_nav(x, y) {
846            let shortcut_labels: Vec<&str> = self
847                .active_window()
848                .file_open_state
849                .as_ref()
850                .map(|s| s.shortcuts.iter().map(|sc| sc.label.as_str()).collect())
851                .unwrap_or_default();
852
853            if let Some(idx) = layout.nav_shortcut_at(x, y, &shortcut_labels) {
854                return Some(HoverTarget::FileBrowserNavShortcut(idx));
855            }
856        }
857
858        // Check column headers
859        if layout.is_in_header(x, y) {
860            if let Some(mode) = layout.header_column_at(x) {
861                return Some(HoverTarget::FileBrowserHeader(mode));
862            }
863        }
864
865        // Check file list entries
866        if layout.is_in_list(x, y) {
867            let scroll_offset = self
868                .active_window()
869                .file_open_state
870                .as_ref()
871                .map(|s| s.scroll_offset)
872                .unwrap_or(0);
873
874            if let Some(idx) = layout.click_to_index(y, scroll_offset) {
875                let total_entries = self
876                    .active_window()
877                    .file_open_state
878                    .as_ref()
879                    .map(|s| s.entries.len())
880                    .unwrap_or(0);
881
882                if idx < total_entries {
883                    return Some(HoverTarget::FileBrowserEntry(idx));
884                }
885            }
886        }
887
888        // Check scrollbar
889        if layout.is_in_scrollbar(x, y) {
890            return Some(HoverTarget::FileBrowserScrollbar);
891        }
892
893        None
894    }
895}