Skip to main content

fresh/view/ui/
file_explorer.rs

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