fresh/view/ui/
file_explorer.rs

1use crate::primitives::display_width::str_width;
2use crate::view::file_tree::{FileExplorerDecorationCache, FileTreeView, NodeId};
3use crate::view::theme::Theme;
4use ratatui::{
5    layout::Rect,
6    style::{Color, Modifier, Style},
7    text::{Line, Span},
8    widgets::{Block, Borders, List, ListItem, ListState},
9    Frame,
10};
11
12use std::collections::HashSet;
13use std::path::PathBuf;
14
15pub struct FileExplorerRenderer;
16
17impl FileExplorerRenderer {
18    /// Check if a directory contains any modified files
19    fn folder_has_modified_files(
20        folder_path: &PathBuf,
21        files_with_unsaved_changes: &HashSet<PathBuf>,
22    ) -> bool {
23        for modified_file in files_with_unsaved_changes {
24            if modified_file.starts_with(folder_path) {
25                return true;
26            }
27        }
28        false
29    }
30
31    /// Render the file explorer in the given frame area
32    #[allow(clippy::too_many_arguments)]
33    pub fn render(
34        view: &mut FileTreeView,
35        frame: &mut Frame,
36        area: Rect,
37        is_focused: bool,
38        files_with_unsaved_changes: &HashSet<PathBuf>,
39        decorations: &FileExplorerDecorationCache,
40        keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
41        current_context: crate::input::keybindings::KeyContext,
42        theme: &Theme,
43        close_button_hovered: bool,
44    ) {
45        // Update viewport height for scrolling calculations
46        // Account for borders (top + bottom = 2)
47        let viewport_height = area.height.saturating_sub(2) as usize;
48        view.set_viewport_height(viewport_height);
49
50        let display_nodes = view.get_display_nodes();
51        let scroll_offset = view.get_scroll_offset();
52        let selected_index = view.get_selected_index();
53
54        // Clamp scroll_offset to valid range to prevent panic after tree mutations
55        // (e.g., when deleting a folder with many children while scrolled down)
56        // Issue #562: scroll_offset can become larger than display_nodes.len()
57        let scroll_offset = scroll_offset.min(display_nodes.len());
58
59        // Only render the visible subset of items (for manual scroll control)
60        // This prevents ratatui's List widget from auto-scrolling
61        let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
62        let visible_items = &display_nodes[scroll_offset..visible_end];
63
64        // Available width for content (subtract borders and cursor indicator)
65        let content_width = area.width.saturating_sub(3) as usize;
66
67        // Create list items for visible nodes only
68        let items: Vec<ListItem> = visible_items
69            .iter()
70            .enumerate()
71            .map(|(viewport_idx, &(node_id, indent))| {
72                // The actual index in the full list
73                let actual_idx = scroll_offset + viewport_idx;
74                let is_selected = selected_index == Some(actual_idx);
75                Self::render_node(
76                    view,
77                    node_id,
78                    indent,
79                    is_selected,
80                    is_focused,
81                    files_with_unsaved_changes,
82                    decorations,
83                    theme,
84                    content_width,
85                )
86            })
87            .collect();
88
89        // Build the title with keybinding
90        let title = if let Some(keybinding) = keybinding_resolver.get_keybinding_for_action(
91            &crate::input::keybindings::Action::FocusFileExplorer,
92            current_context,
93        ) {
94            format!(" File Explorer ({}) ", keybinding)
95        } else {
96            " File Explorer ".to_string()
97        };
98
99        // Title style: inverted colors (dark on light) when focused using theme colors
100        let (title_style, border_style) = if is_focused {
101            (
102                Style::default()
103                    .fg(theme.editor_bg)
104                    .bg(theme.editor_fg)
105                    .add_modifier(Modifier::BOLD),
106                Style::default().fg(theme.cursor),
107            )
108        } else {
109            (
110                Style::default().fg(theme.line_number_fg),
111                Style::default().fg(theme.split_separator_fg),
112            )
113        };
114
115        // Create the list widget
116        let list = List::new(items)
117            .block(
118                Block::default()
119                    .borders(Borders::ALL)
120                    .title(title)
121                    .title_style(title_style)
122                    .border_style(border_style)
123                    .style(Style::default().bg(theme.editor_bg)),
124            )
125            .highlight_style(if is_focused {
126                Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
127            } else {
128                Style::default().bg(theme.current_line_bg)
129            });
130
131        // Create list state for scrolling
132        // Since we're only passing visible items, the selection is relative to viewport
133        let mut list_state = ListState::default();
134        if let Some(selected) = selected_index {
135            if selected >= scroll_offset && selected < scroll_offset + viewport_height {
136                // Selected item is in the visible range
137                list_state.select(Some(selected - scroll_offset));
138            }
139        }
140
141        frame.render_stateful_widget(list, area, &mut list_state);
142
143        // Render close button "×" at the right side of the title bar
144        let close_button_x = area.x + area.width.saturating_sub(3);
145        let close_fg = if close_button_hovered {
146            theme.tab_close_hover_fg
147        } else {
148            theme.line_number_fg
149        };
150        let close_button =
151            ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
152        let close_area = Rect::new(close_button_x, area.y, 1, 1);
153        frame.render_widget(close_button, close_area);
154
155        // When focused, show a blinking cursor indicator at the selected row
156        // We render a cursor indicator character and position the hardware cursor there
157        // The hardware cursor provides efficient terminal-native blinking
158        if is_focused {
159            if let Some(selected) = selected_index {
160                if selected >= scroll_offset && selected < scroll_offset + viewport_height {
161                    // Position at the left edge of the selected row (after border)
162                    let cursor_x = area.x + 1;
163                    let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
164
165                    // Render a cursor indicator character that the hardware cursor will blink over
166                    let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
167                        .style(Style::default().fg(theme.cursor));
168                    let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
169                    frame.render_widget(cursor_indicator, cursor_area);
170
171                    // Position hardware cursor here for blinking effect
172                    frame.set_cursor_position((cursor_x, cursor_y));
173                }
174            }
175        }
176    }
177
178    /// Render a single tree node as a ListItem
179    #[allow(clippy::too_many_arguments)]
180    fn render_node(
181        view: &FileTreeView,
182        node_id: NodeId,
183        indent: usize,
184        is_selected: bool,
185        is_focused: bool,
186        files_with_unsaved_changes: &HashSet<PathBuf>,
187        decorations: &FileExplorerDecorationCache,
188        theme: &Theme,
189        content_width: usize,
190    ) -> ListItem<'static> {
191        let node = view.tree().get_node(node_id).expect("Node should exist");
192
193        // Build the line with indentation and tree structure
194        let mut spans = Vec::new();
195
196        // Calculate the left side width for padding calculation
197        let indent_width = indent * 2;
198        let indicator_width = if node.is_dir() { 2 } else { 2 }; // "▼ " or "  "
199        let name_width = str_width(&node.entry.name);
200        let left_side_width = indent_width + indicator_width + name_width;
201
202        // Indentation
203        if indent > 0 {
204            spans.push(Span::raw("  ".repeat(indent)));
205        }
206
207        // Tree expansion indicator (only for directories)
208        if node.is_dir() {
209            let indicator = if node.is_expanded() {
210                "▼ "
211            } else if node.is_collapsed() {
212                "> "
213            } else if node.is_loading() {
214                "⟳ "
215            } else {
216                "! "
217            };
218            spans.push(Span::styled(
219                indicator,
220                Style::default().fg(theme.diagnostic_warning_fg),
221            ));
222        } else {
223            // For files, add spacing to align with directory names
224            spans.push(Span::raw("  "));
225        }
226
227        // Name styling using theme colors
228        let name_style = if is_selected && is_focused {
229            Style::default().fg(theme.editor_fg)
230        } else if node
231            .entry
232            .metadata
233            .as_ref()
234            .map(|m| m.is_hidden)
235            .unwrap_or(false)
236        {
237            Style::default().fg(theme.line_number_fg)
238        } else if node.entry.is_symlink() {
239            // Symlinks use a distinct color (type color, typically cyan)
240            Style::default().fg(theme.syntax_type)
241        } else if node.is_dir() {
242            Style::default().fg(theme.syntax_keyword)
243        } else {
244            Style::default().fg(theme.editor_fg)
245        };
246
247        spans.push(Span::styled(node.entry.name.clone(), name_style));
248
249        // Determine the right-side indicator (status symbol)
250        // Priority: unsaved changes > direct decoration > bubbled decoration (for dirs)
251        let has_unsaved = if node.is_dir() {
252            Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
253        } else {
254            files_with_unsaved_changes.contains(&node.entry.path)
255        };
256
257        let direct_decoration = decorations.direct_for_path(&node.entry.path);
258        let bubbled_decoration = if node.is_dir() {
259            decorations
260                .bubbled_for_path(&node.entry.path)
261                .filter(|_| direct_decoration.is_none())
262        } else {
263            None
264        };
265
266        let right_indicator: Option<(String, Color)> = if has_unsaved {
267            Some(("●".to_string(), theme.diagnostic_warning_fg))
268        } else if let Some(decoration) = direct_decoration {
269            let symbol = Self::decoration_symbol(&decoration.symbol);
270            Some((symbol, Self::decoration_color(decoration)))
271        } else {
272            bubbled_decoration
273                .map(|decoration| ("●".to_string(), Self::decoration_color(decoration)))
274        };
275
276        // Calculate right-side content width
277        let right_indicator_width = right_indicator
278            .as_ref()
279            .map(|(s, _)| str_width(s))
280            .unwrap_or(0);
281
282        // Error indicator
283        let error_text = if node.is_error() { " [Error]" } else { "" };
284        let error_width = str_width(error_text);
285
286        let total_right_width = right_indicator_width + error_width;
287
288        // Calculate padding for right-alignment
289        let min_gap = 1;
290        let padding = if left_side_width + min_gap + total_right_width < content_width {
291            content_width - left_side_width - total_right_width
292        } else {
293            min_gap
294        };
295
296        spans.push(Span::raw(" ".repeat(padding)));
297
298        // Add right-aligned status indicator
299        if let Some((symbol, color)) = right_indicator {
300            spans.push(Span::styled(symbol, Style::default().fg(color)));
301        }
302
303        // Error indicator
304        if node.is_error() {
305            spans.push(Span::styled(
306                error_text,
307                Style::default().fg(theme.diagnostic_error_fg),
308            ));
309        }
310
311        ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
312    }
313
314    fn decoration_symbol(symbol: &str) -> String {
315        symbol
316            .chars()
317            .next()
318            .map(|c| c.to_string())
319            .unwrap_or_else(|| " ".to_string())
320    }
321
322    fn decoration_color(decoration: &crate::view::file_tree::FileExplorerDecoration) -> Color {
323        let [r, g, b] = decoration.color;
324        Color::Rgb(r, g, b)
325    }
326}