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