fresh/view/ui/
file_browser.rs

1//! File browser popup renderer for the Open File dialog
2//!
3//! Renders a structured popup above the prompt with:
4//! - Navigation shortcuts (parent, root, home)
5//! - Sortable column headers (name, size, modified)
6//! - File list with metadata
7//! - Scrollbar for long lists
8
9use super::scrollbar::{render_scrollbar, ScrollbarColors, ScrollbarState};
10use super::status_bar::truncate_path;
11use crate::app::file_open::{
12    format_modified, format_size, FileOpenSection, FileOpenState, SortMode,
13};
14use crate::primitives::display_width::str_width;
15use ratatui::layout::Rect;
16use ratatui::style::{Modifier, Style};
17use ratatui::text::{Line, Span};
18use ratatui::widgets::{Block, Borders, Clear, Paragraph};
19use ratatui::Frame;
20use rust_i18n::t;
21
22/// Renderer for the file browser popup
23pub struct FileBrowserRenderer;
24
25impl FileBrowserRenderer {
26    /// Render the file browser popup
27    ///
28    /// # Arguments
29    /// * `frame` - The ratatui frame to render to
30    /// * `area` - The rectangular area for the popup (above the prompt)
31    /// * `state` - The file open dialog state
32    /// * `theme` - The active theme for colors
33    /// * `hover_target` - Current mouse hover target (for highlighting)
34    /// * `keybindings` - Optional keybinding resolver for displaying shortcuts
35    ///
36    /// # Returns
37    /// Information for mouse hit testing (scrollbar area, thumb positions, etc.)
38    pub fn render(
39        frame: &mut Frame,
40        area: Rect,
41        state: &FileOpenState,
42        theme: &crate::view::theme::Theme,
43        hover_target: &Option<crate::app::HoverTarget>,
44        keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
45    ) -> Option<FileBrowserLayout> {
46        if area.height < 5 || area.width < 20 {
47            return None;
48        }
49
50        // Clear the area behind the popup
51        frame.render_widget(Clear, area);
52
53        // Truncate path for title if needed (leave space for borders and padding)
54        let max_title_len = (area.width as usize).saturating_sub(4); // 2 for borders, 2 for padding
55        let truncated_path = truncate_path(&state.current_dir, max_title_len);
56        let title = format!(" {} ", truncated_path.to_string_plain());
57
58        // Create styled title with highlighted [...] if truncated
59        let title_line = if truncated_path.truncated {
60            Line::from(vec![
61                Span::raw(" "),
62                Span::styled(
63                    truncated_path.prefix.clone(),
64                    Style::default().fg(theme.popup_border_fg),
65                ),
66                Span::styled("/[...]", Style::default().fg(theme.menu_highlight_fg)),
67                Span::styled(
68                    truncated_path.suffix.clone(),
69                    Style::default().fg(theme.popup_border_fg),
70                ),
71                Span::raw(" "),
72            ])
73        } else {
74            Line::from(title)
75        };
76
77        // Create the popup block with border
78        let block = Block::default()
79            .borders(Borders::ALL)
80            .border_style(Style::default().fg(theme.popup_border_fg))
81            .style(Style::default().bg(theme.popup_bg))
82            .title(title_line);
83
84        let inner_area = block.inner(area);
85        frame.render_widget(block, area);
86
87        if inner_area.height < 3 || inner_area.width < 10 {
88            return None;
89        }
90
91        // Layout: Navigation (2-3 rows) | Header (1 row) | File list (remaining) | Scrollbar (1 col)
92        let nav_height = 2u16; // Navigation shortcuts section
93        let header_height = 1u16;
94        let scrollbar_width = 1u16;
95
96        let content_width = inner_area.width.saturating_sub(scrollbar_width);
97        let list_height = inner_area.height.saturating_sub(nav_height + header_height);
98
99        // Navigation area
100        let nav_area = Rect::new(inner_area.x, inner_area.y, content_width, nav_height);
101
102        // Header area
103        let header_area = Rect::new(
104            inner_area.x,
105            inner_area.y + nav_height,
106            content_width,
107            header_height,
108        );
109
110        // File list area
111        let list_area = Rect::new(
112            inner_area.x,
113            inner_area.y + nav_height + header_height,
114            content_width,
115            list_height,
116        );
117
118        // Scrollbar area
119        let scrollbar_area = Rect::new(
120            inner_area.x + content_width,
121            inner_area.y + nav_height + header_height,
122            scrollbar_width,
123            list_height,
124        );
125
126        // Render each section with hover state
127        Self::render_navigation(frame, nav_area, state, theme, hover_target, keybindings);
128        Self::render_header(frame, header_area, state, theme, hover_target);
129        let visible_rows = Self::render_file_list(frame, list_area, state, theme, hover_target);
130
131        // Render scrollbar with theme colors (hover-aware)
132        let scrollbar_state =
133            ScrollbarState::new(state.entries.len(), visible_rows, state.scroll_offset);
134        let is_scrollbar_hovered = matches!(
135            hover_target,
136            Some(crate::app::HoverTarget::FileBrowserScrollbar)
137        );
138        let colors = if is_scrollbar_hovered {
139            ScrollbarColors::from_theme_hover(theme)
140        } else {
141            ScrollbarColors::from_theme(theme)
142        };
143        let (thumb_start, thumb_end) =
144            render_scrollbar(frame, scrollbar_area, &scrollbar_state, &colors);
145
146        Some(FileBrowserLayout {
147            nav_area,
148            header_area,
149            list_area,
150            scrollbar_area,
151            thumb_start,
152            thumb_end,
153            visible_rows,
154            content_width,
155        })
156    }
157
158    /// Render navigation shortcuts section with "Show Hidden" checkbox on separate row
159    fn render_navigation(
160        frame: &mut Frame,
161        area: Rect,
162        state: &FileOpenState,
163        theme: &crate::view::theme::Theme,
164        hover_target: &Option<crate::app::HoverTarget>,
165        keybindings: Option<&crate::input::keybindings::KeybindingResolver>,
166    ) {
167        use crate::app::HoverTarget;
168
169        // Look up the keybinding for toggle hidden action
170        let shortcut_hint = keybindings
171            .and_then(|kb| {
172                kb.get_keybinding_for_action(
173                    &crate::input::keybindings::Action::FileBrowserToggleHidden,
174                    crate::input::keybindings::KeyContext::Prompt,
175                )
176            })
177            .unwrap_or_default();
178
179        // First line: "Show Hidden" checkbox (on its own row to avoid truncation on Windows)
180        let checkbox_icon = if state.show_hidden { "☑" } else { "☐" };
181        let checkbox_label = format!("{} {}", checkbox_icon, t!("file_browser.show_hidden"));
182        let shortcut_text = if shortcut_hint.is_empty() {
183            String::new()
184        } else {
185            format!(" ({})", shortcut_hint)
186        };
187
188        let is_checkbox_hovered = matches!(
189            hover_target,
190            Some(HoverTarget::FileBrowserShowHiddenCheckbox)
191        );
192        let checkbox_style = if is_checkbox_hovered {
193            Style::default()
194                .fg(theme.menu_hover_fg)
195                .bg(theme.menu_hover_bg)
196        } else if state.show_hidden {
197            Style::default()
198                .fg(theme.menu_highlight_fg)
199                .bg(theme.popup_bg)
200        } else {
201            Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
202        };
203        let shortcut_style = if is_checkbox_hovered {
204            Style::default()
205                .fg(theme.menu_hover_fg)
206                .bg(theme.menu_hover_bg)
207        } else {
208            Style::default()
209                .fg(theme.help_separator_fg)
210                .bg(theme.popup_bg)
211        };
212
213        let mut checkbox_spans = Vec::new();
214        checkbox_spans.push(Span::styled(format!(" {}", checkbox_label), checkbox_style));
215        if !shortcut_text.is_empty() {
216            checkbox_spans.push(Span::styled(shortcut_text, shortcut_style));
217        }
218        // Fill rest of row with background
219        let checkbox_line_width: usize = checkbox_spans.iter().map(|s| str_width(&s.content)).sum();
220        let remaining = (area.width as usize).saturating_sub(checkbox_line_width);
221        if remaining > 0 {
222            checkbox_spans.push(Span::styled(
223                " ".repeat(remaining),
224                Style::default().bg(theme.popup_bg),
225            ));
226        }
227        let checkbox_line = Line::from(checkbox_spans);
228
229        // Second line: Navigation shortcuts
230        let is_nav_active = state.active_section == FileOpenSection::Navigation;
231
232        let mut nav_spans = Vec::new();
233        nav_spans.push(Span::styled(
234            format!(" {}", t!("file_browser.navigation")),
235            Style::default()
236                .fg(theme.help_separator_fg)
237                .bg(theme.popup_bg),
238        ));
239
240        for (idx, shortcut) in state.shortcuts.iter().enumerate() {
241            let is_selected = is_nav_active && idx == state.selected_shortcut;
242            let is_hovered =
243                matches!(hover_target, Some(HoverTarget::FileBrowserNavShortcut(i)) if *i == idx);
244
245            let style = if is_selected {
246                Style::default()
247                    .fg(theme.popup_text_fg)
248                    .bg(theme.suggestion_selected_bg)
249                    .add_modifier(Modifier::BOLD)
250            } else if is_hovered {
251                Style::default()
252                    .fg(theme.menu_hover_fg)
253                    .bg(theme.menu_hover_bg)
254            } else {
255                Style::default().fg(theme.help_key_fg).bg(theme.popup_bg)
256            };
257
258            nav_spans.push(Span::styled(format!(" {} ", shortcut.label), style));
259
260            if idx < state.shortcuts.len() - 1 {
261                nav_spans.push(Span::styled(
262                    " │ ",
263                    Style::default()
264                        .fg(theme.help_separator_fg)
265                        .bg(theme.popup_bg),
266                ));
267            }
268        }
269
270        // Fill rest of navigation row with background
271        let nav_line_width: usize = nav_spans.iter().map(|s| str_width(&s.content)).sum();
272        let nav_remaining = (area.width as usize).saturating_sub(nav_line_width);
273        if nav_remaining > 0 {
274            nav_spans.push(Span::styled(
275                " ".repeat(nav_remaining),
276                Style::default().bg(theme.popup_bg),
277            ));
278        }
279        let nav_line = Line::from(nav_spans);
280
281        let paragraph = Paragraph::new(vec![checkbox_line, nav_line]);
282        frame.render_widget(paragraph, area);
283    }
284
285    /// Render sortable column headers
286    fn render_header(
287        frame: &mut Frame,
288        area: Rect,
289        state: &FileOpenState,
290        theme: &crate::view::theme::Theme,
291        hover_target: &Option<crate::app::HoverTarget>,
292    ) {
293        use crate::app::HoverTarget;
294
295        let width = area.width as usize;
296
297        // Column widths
298        let size_col_width = 10;
299        let date_col_width = 14;
300        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
301
302        let header_style = Style::default()
303            .fg(theme.help_key_fg)
304            .bg(theme.menu_dropdown_bg)
305            .add_modifier(Modifier::BOLD);
306
307        let active_header_style = Style::default()
308            .fg(theme.menu_highlight_fg)
309            .bg(theme.menu_dropdown_bg)
310            .add_modifier(Modifier::BOLD);
311
312        let hover_header_style = Style::default()
313            .fg(theme.menu_hover_fg)
314            .bg(theme.menu_hover_bg)
315            .add_modifier(Modifier::BOLD);
316
317        // Sort indicator
318        let sort_arrow = if state.sort_ascending { "▲" } else { "▼" };
319
320        let mut spans = Vec::new();
321
322        // Name column
323        let name_header = format!(
324            " {}{}",
325            t!("file_browser.name"),
326            if state.sort_mode == SortMode::Name {
327                sort_arrow
328            } else {
329                " "
330            }
331        );
332        let is_name_hovered = matches!(
333            hover_target,
334            Some(HoverTarget::FileBrowserHeader(SortMode::Name))
335        );
336        let name_style = if state.sort_mode == SortMode::Name {
337            active_header_style
338        } else if is_name_hovered {
339            hover_header_style
340        } else {
341            header_style
342        };
343        let name_display = if name_header.len() < name_col_width {
344            format!("{:<width$}", name_header, width = name_col_width)
345        } else {
346            name_header[..name_col_width].to_string()
347        };
348        spans.push(Span::styled(name_display, name_style));
349
350        // Size column
351        let size_header = format!(
352            "{:>width$}",
353            format!(
354                "{}{}",
355                t!("file_browser.size"),
356                if state.sort_mode == SortMode::Size {
357                    sort_arrow
358                } else {
359                    " "
360                }
361            ),
362            width = size_col_width
363        );
364        let is_size_hovered = matches!(
365            hover_target,
366            Some(HoverTarget::FileBrowserHeader(SortMode::Size))
367        );
368        let size_style = if state.sort_mode == SortMode::Size {
369            active_header_style
370        } else if is_size_hovered {
371            hover_header_style
372        } else {
373            header_style
374        };
375        spans.push(Span::styled(size_header, size_style));
376
377        // Separator
378        spans.push(Span::styled("  ", header_style));
379
380        // Modified column
381        let modified_header = format!(
382            "{:>width$}",
383            format!(
384                "{}{}",
385                t!("file_browser.modified"),
386                if state.sort_mode == SortMode::Modified {
387                    sort_arrow
388                } else {
389                    " "
390                }
391            ),
392            width = date_col_width
393        );
394        let is_modified_hovered = matches!(
395            hover_target,
396            Some(HoverTarget::FileBrowserHeader(SortMode::Modified))
397        );
398        let modified_style = if state.sort_mode == SortMode::Modified {
399            active_header_style
400        } else if is_modified_hovered {
401            hover_header_style
402        } else {
403            header_style
404        };
405        spans.push(Span::styled(modified_header, modified_style));
406
407        let line = Line::from(spans);
408        let paragraph = Paragraph::new(vec![line]);
409        frame.render_widget(paragraph, area);
410    }
411
412    /// Render the file list with metadata columns
413    ///
414    /// Returns the number of visible rows
415    fn render_file_list(
416        frame: &mut Frame,
417        area: Rect,
418        state: &FileOpenState,
419        theme: &crate::view::theme::Theme,
420        hover_target: &Option<crate::app::HoverTarget>,
421    ) -> usize {
422        use crate::app::HoverTarget;
423
424        let visible_rows = area.height as usize;
425        let width = area.width as usize;
426
427        // Column widths (matching header)
428        let size_col_width = 10;
429        let date_col_width = 14;
430        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
431
432        let is_files_active = state.active_section == FileOpenSection::Files;
433
434        // Loading state
435        if state.loading {
436            let loading_line = Line::from(Span::styled(
437                t!("file_browser.loading").to_string(),
438                Style::default()
439                    .fg(theme.help_separator_fg)
440                    .bg(theme.popup_bg),
441            ));
442            let paragraph = Paragraph::new(vec![loading_line]);
443            frame.render_widget(paragraph, area);
444            return visible_rows;
445        }
446
447        // Error state
448        if let Some(error) = &state.error {
449            let error_line = Line::from(Span::styled(
450                t!("file_browser.error", error = error).to_string(),
451                Style::default()
452                    .fg(theme.diagnostic_error_fg)
453                    .bg(theme.popup_bg),
454            ));
455            let paragraph = Paragraph::new(vec![error_line]);
456            frame.render_widget(paragraph, area);
457            return visible_rows;
458        }
459
460        // Empty state
461        if state.entries.is_empty() {
462            let empty_line = Line::from(Span::styled(
463                format!(" {}", t!("file_browser.empty")),
464                Style::default()
465                    .fg(theme.help_separator_fg)
466                    .bg(theme.popup_bg),
467            ));
468            let paragraph = Paragraph::new(vec![empty_line]);
469            frame.render_widget(paragraph, area);
470            return visible_rows;
471        }
472
473        let mut lines = Vec::new();
474        let visible_entries = state.visible_entries(visible_rows);
475
476        for (view_idx, entry) in visible_entries.iter().enumerate() {
477            let actual_idx = state.scroll_offset + view_idx;
478            let is_selected = is_files_active && state.selected_index == Some(actual_idx);
479            let is_hovered =
480                matches!(hover_target, Some(HoverTarget::FileBrowserEntry(i)) if *i == actual_idx);
481
482            // Base style based on selection, hover, and filter match
483            let base_style = if is_selected {
484                Style::default()
485                    .fg(theme.popup_text_fg)
486                    .bg(theme.suggestion_selected_bg)
487            } else if is_hovered && entry.matches_filter {
488                Style::default()
489                    .fg(theme.menu_hover_fg)
490                    .bg(theme.menu_hover_bg)
491            } else if !entry.matches_filter {
492                // Non-matching items are dimmed using the separator color
493                Style::default()
494                    .fg(theme.help_separator_fg)
495                    .bg(theme.popup_bg)
496                    .add_modifier(Modifier::DIM)
497            } else {
498                Style::default().fg(theme.popup_text_fg).bg(theme.popup_bg)
499            };
500
501            let mut spans = Vec::new();
502
503            // Name column with trailing type indicator (dirs get /, symlinks get @)
504            let name_with_indicator = if entry.fs_entry.is_dir() {
505                format!("{}/", entry.fs_entry.name)
506            } else if entry.fs_entry.is_symlink() {
507                format!("{}@", entry.fs_entry.name)
508            } else {
509                entry.fs_entry.name.clone()
510            };
511            let name_display = if name_with_indicator.len() < name_col_width {
512                format!("{:<width$}", name_with_indicator, width = name_col_width)
513            } else {
514                // Truncate with ellipsis
515                let truncated: String = name_with_indicator
516                    .chars()
517                    .take(name_col_width - 3)
518                    .collect();
519                format!("{}...", truncated)
520            };
521
522            // Color directories differently
523            let name_style = if entry.fs_entry.is_dir() && !is_selected {
524                base_style.fg(theme.help_key_fg)
525            } else {
526                base_style
527            };
528            spans.push(Span::styled(name_display, name_style));
529
530            // Size column
531            let size_display = if entry.fs_entry.is_dir() {
532                format!("{:>width$}", "--", width = size_col_width)
533            } else {
534                let size = entry
535                    .fs_entry
536                    .metadata
537                    .as_ref()
538                    .and_then(|m| m.size)
539                    .map(format_size)
540                    .unwrap_or_else(|| "--".to_string());
541                format!("{:>width$}", size, width = size_col_width)
542            };
543            spans.push(Span::styled(size_display, base_style));
544
545            // Separator
546            spans.push(Span::styled("  ", base_style));
547
548            // Modified column
549            let modified_display = entry
550                .fs_entry
551                .metadata
552                .as_ref()
553                .and_then(|m| m.modified)
554                .map(format_modified)
555                .unwrap_or_else(|| "--".to_string());
556            let modified_formatted =
557                format!("{:>width$}", modified_display, width = date_col_width);
558            spans.push(Span::styled(modified_formatted, base_style));
559
560            lines.push(Line::from(spans));
561        }
562
563        // Fill remaining rows with empty lines
564        while lines.len() < visible_rows {
565            lines.push(Line::from(Span::styled(
566                " ".repeat(width),
567                Style::default().bg(theme.popup_bg),
568            )));
569        }
570
571        let paragraph = Paragraph::new(lines);
572        frame.render_widget(paragraph, area);
573
574        visible_rows
575    }
576}
577
578/// Layout information for mouse hit testing
579#[derive(Debug, Clone)]
580pub struct FileBrowserLayout {
581    /// Navigation shortcuts area
582    pub nav_area: Rect,
583    /// Column headers area
584    pub header_area: Rect,
585    /// File list area
586    pub list_area: Rect,
587    /// Scrollbar area
588    pub scrollbar_area: Rect,
589    /// Scrollbar thumb start position
590    pub thumb_start: usize,
591    /// Scrollbar thumb end position
592    pub thumb_end: usize,
593    /// Number of visible rows in the file list
594    pub visible_rows: usize,
595    /// Width of the content area (for checkbox position calculation)
596    pub content_width: u16,
597}
598
599impl FileBrowserLayout {
600    /// Check if a position is within the file list area
601    pub fn is_in_list(&self, x: u16, y: u16) -> bool {
602        x >= self.list_area.x
603            && x < self.list_area.x + self.list_area.width
604            && y >= self.list_area.y
605            && y < self.list_area.y + self.list_area.height
606    }
607
608    /// Convert a click in the list area to an entry index
609    pub fn click_to_index(&self, y: u16, scroll_offset: usize) -> Option<usize> {
610        if y < self.list_area.y || y >= self.list_area.y + self.list_area.height {
611            return None;
612        }
613        let row = (y - self.list_area.y) as usize;
614        Some(scroll_offset + row)
615    }
616
617    /// Check if a position is in the navigation area
618    pub fn is_in_nav(&self, x: u16, y: u16) -> bool {
619        x >= self.nav_area.x
620            && x < self.nav_area.x + self.nav_area.width
621            && y >= self.nav_area.y
622            && y < self.nav_area.y + self.nav_area.height
623    }
624
625    /// Determine which navigation shortcut was clicked based on x position
626    /// The layout is: " Navigation: " (13 chars) then for each shortcut: " {label} " + " │ " separator
627    /// Navigation shortcuts are on the second row (y == nav_area.y + 1)
628    pub fn nav_shortcut_at(&self, x: u16, y: u16, shortcut_labels: &[&str]) -> Option<usize> {
629        // Navigation shortcuts are on the second row of the nav area
630        if y != self.nav_area.y + 1 {
631            return None;
632        }
633
634        let rel_x = x.saturating_sub(self.nav_area.x) as usize;
635
636        // Skip " Navigation: " prefix
637        let prefix_len = 13;
638        if rel_x < prefix_len {
639            return None;
640        }
641
642        let mut current_x = prefix_len;
643        for (idx, label) in shortcut_labels.iter().enumerate() {
644            // Each shortcut: " {label} " = visual width + 2 spaces
645            let shortcut_width = str_width(label) + 2;
646
647            if rel_x >= current_x && rel_x < current_x + shortcut_width {
648                return Some(idx);
649            }
650            current_x += shortcut_width;
651
652            // Separator: " │ " = 3 chars
653            if idx < shortcut_labels.len() - 1 {
654                current_x += 3;
655            }
656        }
657
658        None
659    }
660
661    /// Check if a position is in the header area (for sorting)
662    pub fn is_in_header(&self, x: u16, y: u16) -> bool {
663        x >= self.header_area.x
664            && x < self.header_area.x + self.header_area.width
665            && y >= self.header_area.y
666            && y < self.header_area.y + self.header_area.height
667    }
668
669    /// Determine which column header was clicked
670    pub fn header_column_at(&self, x: u16) -> Option<SortMode> {
671        let rel_x = x.saturating_sub(self.header_area.x) as usize;
672        let width = self.header_area.width as usize;
673
674        let size_col_width = 10;
675        let date_col_width = 14;
676        let name_col_width = width.saturating_sub(size_col_width + date_col_width + 4);
677
678        if rel_x < name_col_width {
679            Some(SortMode::Name)
680        } else if rel_x < name_col_width + size_col_width {
681            Some(SortMode::Size)
682        } else {
683            Some(SortMode::Modified)
684        }
685    }
686
687    /// Check if a position is in the scrollbar area
688    pub fn is_in_scrollbar(&self, x: u16, y: u16) -> bool {
689        x >= self.scrollbar_area.x
690            && x < self.scrollbar_area.x + self.scrollbar_area.width
691            && y >= self.scrollbar_area.y
692            && y < self.scrollbar_area.y + self.scrollbar_area.height
693    }
694
695    /// Check if a position is in the scrollbar thumb
696    pub fn is_in_thumb(&self, y: u16) -> bool {
697        let rel_y = y.saturating_sub(self.scrollbar_area.y) as usize;
698        rel_y >= self.thumb_start && rel_y < self.thumb_end
699    }
700
701    /// Check if a position is on the "Show Hidden" checkbox
702    /// The checkbox is on its own row (first row of navigation area)
703    /// Format: " ☐ Show Hidden (Alt+.)" (includes keyboard shortcut hint)
704    pub fn is_on_show_hidden_checkbox(&self, x: u16, y: u16) -> bool {
705        // Must be on the first row of navigation area (checkbox row)
706        if y != self.nav_area.y {
707            return false;
708        }
709
710        // Must be within the x bounds of the navigation area
711        if x < self.nav_area.x || x >= self.nav_area.x + self.nav_area.width {
712            return false;
713        }
714
715        // Checkbox spans the left portion of the row
716        // " ☐ Show Hidden (Alt+.)" is approximately 24 characters
717        let checkbox_width = 24u16;
718        x < self.nav_area.x + checkbox_width
719    }
720}