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