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: use warning colors when remote is disconnected,
127        // otherwise inverted colors (dark on light) when focused.
128        let remote_disconnected = remote_connection
129            .map(|c| c.contains("(Disconnected)"))
130            .unwrap_or(false);
131        let (title_style, border_style) = if remote_disconnected {
132            (
133                Style::default()
134                    .fg(theme.status_error_indicator_fg)
135                    .bg(theme.status_error_indicator_bg)
136                    .add_modifier(Modifier::BOLD),
137                Style::default().fg(theme.status_error_indicator_bg),
138            )
139        } else if is_focused {
140            (
141                Style::default()
142                    .fg(theme.editor_bg)
143                    .bg(theme.editor_fg)
144                    .add_modifier(Modifier::BOLD),
145                Style::default().fg(theme.cursor),
146            )
147        } else {
148            (
149                Style::default().fg(theme.line_number_fg),
150                Style::default().fg(theme.split_separator_fg),
151            )
152        };
153
154        // Create the list widget
155        let list = List::new(items)
156            .block(
157                Block::default()
158                    .borders(Borders::ALL)
159                    .title(title)
160                    .title_style(title_style)
161                    .border_style(border_style)
162                    .style(Style::default().bg(theme.editor_bg)),
163            )
164            .highlight_style(if is_focused {
165                Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
166            } else {
167                Style::default().bg(theme.current_line_bg)
168            });
169
170        // Create list state for scrolling
171        // Since we're only passing visible items, the selection is relative to viewport
172        let mut list_state = ListState::default();
173        if let Some(selected) = selected_index {
174            if selected >= scroll_offset && selected < scroll_offset + viewport_height {
175                // Selected item is in the visible range
176                list_state.select(Some(selected - scroll_offset));
177            }
178        }
179
180        frame.render_stateful_widget(list, area, &mut list_state);
181
182        // Render close button "×" at the right side of the title bar
183        let close_button_x = area.x + area.width.saturating_sub(3);
184        let close_fg = if close_button_hovered {
185            theme.tab_close_hover_fg
186        } else {
187            theme.line_number_fg
188        };
189        let close_button =
190            ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
191        let close_area = Rect::new(close_button_x, area.y, 1, 1);
192        frame.render_widget(close_button, close_area);
193
194        // When focused, show a blinking cursor indicator at the selected row
195        // We render a cursor indicator character and position the hardware cursor there
196        // The hardware cursor provides efficient terminal-native blinking
197        if is_focused {
198            if let Some(selected) = selected_index {
199                if selected >= scroll_offset && selected < scroll_offset + viewport_height {
200                    // Position at the left edge of the selected row (after border)
201                    let cursor_x = area.x + 1;
202                    let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
203
204                    // Render a cursor indicator character that the hardware cursor will blink over
205                    let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
206                        .style(Style::default().fg(theme.cursor));
207                    let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
208                    frame.render_widget(cursor_indicator, cursor_area);
209
210                    // Position hardware cursor here for blinking effect
211                    frame.set_cursor_position((cursor_x, cursor_y));
212                }
213            }
214        }
215    }
216
217    /// Render a single tree node as a ListItem
218    #[allow(clippy::too_many_arguments)]
219    fn render_node(
220        view: &FileTreeView,
221        node_id: NodeId,
222        indent: usize,
223        is_selected: bool,
224        is_focused: bool,
225        files_with_unsaved_changes: &HashSet<PathBuf>,
226        decorations: &FileExplorerDecorationCache,
227        theme: &Theme,
228        content_width: usize,
229        fuzzy_match: Option<&FuzzyMatch>,
230    ) -> ListItem<'static> {
231        let node = view.tree().get_node(node_id).expect("Node should exist");
232
233        // Build the line with indentation and tree structure
234        let mut spans = Vec::new();
235
236        // Calculate the left side width for padding calculation
237        let indent_width = indent * 2;
238        let indicator_width = 2; // "▼ " or "  "
239        let name_width = str_width(&node.entry.name);
240        let left_side_width = indent_width + indicator_width + name_width;
241
242        // Indentation
243        if indent > 0 {
244            spans.push(Span::raw("  ".repeat(indent)));
245        }
246
247        // Tree expansion indicator (only for directories)
248        if node.is_dir() {
249            let indicator = if node.is_expanded() {
250                "▼ "
251            } else if node.is_collapsed() {
252                "> "
253            } else if node.is_loading() {
254                "⟳ "
255            } else {
256                "! "
257            };
258            spans.push(Span::styled(
259                indicator,
260                Style::default().fg(theme.diagnostic_warning_fg),
261            ));
262        } else {
263            // For files, add spacing to align with directory names
264            spans.push(Span::raw("  "));
265        }
266
267        // Name styling using theme colors
268        let base_fg = if is_selected && is_focused {
269            theme.editor_fg
270        } else if node
271            .entry
272            .metadata
273            .as_ref()
274            .map(|m| m.is_hidden)
275            .unwrap_or(false)
276        {
277            theme.line_number_fg
278        } else if node.entry.is_symlink() {
279            // Symlinks use a distinct color (type color, typically cyan)
280            theme.syntax_type
281        } else if node.is_dir() {
282            theme.syntax_keyword
283        } else {
284            theme.editor_fg
285        };
286
287        // Render name with match highlighting
288        if let Some(fm) = fuzzy_match {
289            Self::render_name_with_highlights(
290                &node.entry.name,
291                &fm.match_positions,
292                base_fg,
293                theme,
294                &mut spans,
295            );
296        } else {
297            spans.push(Span::styled(
298                node.entry.name.clone(),
299                Style::default().fg(base_fg),
300            ));
301        }
302
303        // Determine the right-side indicator (status symbol)
304        // Priority: unsaved changes > direct decoration > bubbled decoration (for dirs)
305        let has_unsaved = if node.is_dir() {
306            Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
307        } else {
308            files_with_unsaved_changes.contains(&node.entry.path)
309        };
310
311        let direct_decoration = decorations.direct_for_path(&node.entry.path);
312        let bubbled_decoration = if node.is_dir() {
313            decorations
314                .bubbled_for_path(&node.entry.path)
315                .filter(|_| direct_decoration.is_none())
316        } else {
317            None
318        };
319
320        let right_indicator: Option<(String, Color)> = if has_unsaved {
321            Some(("●".to_string(), theme.diagnostic_warning_fg))
322        } else if let Some(decoration) = direct_decoration {
323            let symbol = Self::decoration_symbol(&decoration.symbol);
324            Some((symbol, Self::decoration_color(decoration, theme)))
325        } else {
326            bubbled_decoration
327                .map(|decoration| ("●".to_string(), Self::decoration_color(decoration, theme)))
328        };
329
330        // Calculate right-side content width
331        let right_indicator_width = right_indicator
332            .as_ref()
333            .map(|(s, _)| str_width(s))
334            .unwrap_or(0);
335
336        // Error indicator
337        let error_text = if node.is_error() { " [Error]" } else { "" };
338        let error_width = str_width(error_text);
339
340        let total_right_width = right_indicator_width + error_width;
341
342        // Calculate padding for right-alignment
343        let min_gap = 1;
344        let padding = if left_side_width + min_gap + total_right_width < content_width {
345            content_width - left_side_width - total_right_width
346        } else {
347            min_gap
348        };
349
350        spans.push(Span::raw(" ".repeat(padding)));
351
352        // Add right-aligned status indicator
353        if let Some((symbol, color)) = right_indicator {
354            spans.push(Span::styled(symbol, Style::default().fg(color)));
355        }
356
357        // Error indicator
358        if node.is_error() {
359            spans.push(Span::styled(
360                error_text,
361                Style::default().fg(theme.diagnostic_error_fg),
362            ));
363        }
364
365        ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
366    }
367
368    fn decoration_symbol(symbol: &str) -> String {
369        symbol
370            .chars()
371            .next()
372            .map(|c| c.to_string())
373            .unwrap_or_else(|| " ".to_string())
374    }
375
376    fn decoration_color(
377        decoration: &crate::view::file_tree::FileExplorerDecoration,
378        theme: &Theme,
379    ) -> Color {
380        match &decoration.color {
381            fresh_core::api::OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
382            fresh_core::api::OverlayColorSpec::ThemeKey(key) => {
383                theme.resolve_theme_key(key).unwrap_or(theme.editor_fg)
384            }
385        }
386    }
387
388    /// Render a file/directory name with matched characters highlighted
389    fn render_name_with_highlights(
390        name: &str,
391        match_positions: &[usize],
392        base_fg: Color,
393        theme: &Theme,
394        spans: &mut Vec<Span<'static>>,
395    ) {
396        if match_positions.is_empty() {
397            spans.push(Span::styled(name.to_string(), Style::default().fg(base_fg)));
398            return;
399        }
400
401        let chars: Vec<char> = name.chars().collect();
402        let match_set: std::collections::HashSet<usize> = match_positions.iter().copied().collect();
403
404        let base_style = Style::default().fg(base_fg);
405        let highlight_style = Style::default()
406            .fg(theme.search_match_fg)
407            .bg(theme.search_match_bg);
408
409        let mut current_span = String::new();
410        let mut current_is_match = false;
411
412        for (i, &c) in chars.iter().enumerate() {
413            let is_match = match_set.contains(&i);
414
415            if i == 0 {
416                current_is_match = is_match;
417                current_span.push(c);
418            } else if is_match == current_is_match {
419                current_span.push(c);
420            } else {
421                // Style changed, push current span and start new one
422                let style = if current_is_match {
423                    highlight_style
424                } else {
425                    base_style
426                };
427                spans.push(Span::styled(current_span.clone(), style));
428                current_span.clear();
429                current_span.push(c);
430                current_is_match = is_match;
431            }
432        }
433
434        // Push final span
435        if !current_span.is_empty() {
436            let style = if current_is_match {
437                highlight_style
438            } else {
439                base_style
440            };
441            spans.push(Span::styled(current_span, style));
442        }
443    }
444}