Skip to main content

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