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