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