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::{
4    ExplorerSlotContext, ExplorerSlotResolution, ExplorerSlotResolver, FileExplorerDecorationCache,
5    FileExplorerSlotOverrideCache, FileTreeView, NodeId,
6};
7use crate::view::theme::Theme;
8use ratatui::{
9    layout::Rect,
10    style::{Color, Modifier, Style},
11    text::{Line, Span},
12    widgets::{Block, Borders, List, ListItem, ListState},
13    Frame,
14};
15
16use std::collections::HashSet;
17use std::path::PathBuf;
18
19pub struct FileExplorerRenderer;
20
21impl FileExplorerRenderer {
22    /// Check if a directory contains any modified files
23    fn folder_has_modified_files(
24        folder_path: &PathBuf,
25        files_with_unsaved_changes: &HashSet<PathBuf>,
26    ) -> bool {
27        for modified_file in files_with_unsaved_changes {
28            if modified_file.starts_with(folder_path) {
29                return true;
30            }
31        }
32        false
33    }
34
35    /// Render the file explorer in the given frame area
36    #[allow(clippy::too_many_arguments)]
37    pub fn render(
38        view: &mut FileTreeView,
39        frame: &mut Frame,
40        area: Rect,
41        slot_resolver: ExplorerSlotResolver<'static>,
42        is_focused: bool,
43        files_with_unsaved_changes: &HashSet<PathBuf>,
44        decorations: &FileExplorerDecorationCache,
45        slot_overrides: &FileExplorerSlotOverrideCache,
46        keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
47        current_context: crate::input::keybindings::KeyContext,
48        theme: &Theme,
49        close_button_hovered: bool,
50        remote_connection: Option<&str>,
51        cut_paths: &[PathBuf],
52        tree_indicator_collapsed: &str,
53        tree_indicator_expanded: &str,
54    ) {
55        let search_active = view.is_search_active();
56
57        // Update viewport height for scrolling calculations
58        // Account for borders (top + bottom = 2)
59        let viewport_height = area.height.saturating_sub(2) as usize;
60        view.set_viewport_height(viewport_height);
61
62        let display_nodes = view.get_display_nodes();
63        let scroll_offset = view.get_scroll_offset();
64        let selected_index = view.get_selected_index();
65
66        // Clamp scroll_offset to valid range to prevent panic after tree mutations
67        // (e.g., when deleting a folder with many children while scrolled down)
68        // Issue #562: scroll_offset can become larger than display_nodes.len()
69        let scroll_offset = scroll_offset.min(display_nodes.len());
70
71        // Only render the visible subset of items (for manual scroll control)
72        // This prevents ratatui's List widget from auto-scrolling
73        let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
74        let visible_items = &display_nodes[scroll_offset..visible_end];
75
76        // Available width for content (subtract borders and cursor indicator)
77        let content_width = area.width.saturating_sub(3) as usize;
78
79        let multi_selection = view.multi_selection();
80
81        // Create list items for visible nodes only
82        let items: Vec<ListItem> = visible_items
83            .iter()
84            .enumerate()
85            .map(|(viewport_idx, &(node_id, indent))| {
86                let actual_idx = scroll_offset + viewport_idx;
87                let is_selected = selected_index == Some(actual_idx);
88                let is_multi_selected = multi_selection.contains(&node_id);
89                let fuzzy_match = if search_active {
90                    view.get_match_for_node(node_id)
91                } else {
92                    None
93                };
94                Self::render_node(
95                    view,
96                    slot_resolver,
97                    node_id,
98                    indent,
99                    is_selected,
100                    is_multi_selected,
101                    is_focused,
102                    files_with_unsaved_changes,
103                    decorations,
104                    slot_overrides,
105                    theme,
106                    content_width,
107                    fuzzy_match.as_ref(),
108                    cut_paths,
109                    tree_indicator_collapsed,
110                    tree_indicator_expanded,
111                )
112            })
113            .collect();
114
115        // Build the title with keybinding and optional remote host
116        let keybinding_suffix = keybinding_resolver
117            .get_keybinding_for_action(
118                &crate::input::keybindings::Action::FocusFileExplorer,
119                current_context,
120            )
121            .map(|kb| format!(" ({})", kb))
122            .unwrap_or_default();
123
124        // Show search query in title when search is active
125        let title = if search_active {
126            format!(" /{} ", view.search_query())
127        } else if let Some(host) = remote_connection {
128            // Extract just the hostname from "user@host" or "user@host:port"
129            let hostname = host
130                .split('@')
131                .next_back()
132                .unwrap_or(host)
133                .split(':')
134                .next()
135                .unwrap_or(host);
136            format!(" [{}]{} ", hostname, keybinding_suffix)
137        } else {
138            format!(" File Explorer{} ", keybinding_suffix)
139        };
140
141        // Title style: use warning colors when remote is disconnected,
142        // otherwise inverted colors (dark on light) when focused.
143        let remote_disconnected = remote_connection
144            .map(|c| c.contains("(Disconnected)"))
145            .unwrap_or(false);
146        let (title_style, border_style) = if remote_disconnected {
147            (
148                Style::default()
149                    .fg(theme.status_error_indicator_fg)
150                    .bg(theme.status_error_indicator_bg)
151                    .add_modifier(Modifier::BOLD),
152                Style::default().fg(theme.status_error_indicator_bg),
153            )
154        } else if is_focused {
155            (
156                Style::default()
157                    .fg(theme.editor_bg)
158                    .bg(theme.editor_fg)
159                    .add_modifier(Modifier::BOLD),
160                Style::default().fg(theme.cursor),
161            )
162        } else {
163            (
164                Style::default().fg(theme.line_number_fg),
165                Style::default().fg(theme.split_separator_fg),
166            )
167        };
168
169        // Create the list widget
170        let list = List::new(items)
171            .block(
172                Block::default()
173                    .borders(Borders::ALL)
174                    .title(title)
175                    .title_style(title_style)
176                    .border_style(border_style)
177                    .style(Style::default().bg(theme.editor_bg)),
178            )
179            .highlight_style(if is_focused {
180                Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
181            } else {
182                Style::default().bg(theme.current_line_bg)
183            });
184
185        // Create list state for scrolling
186        // Since we're only passing visible items, the selection is relative to viewport
187        let mut list_state = ListState::default();
188        if let Some(selected) = selected_index {
189            if selected >= scroll_offset && selected < scroll_offset + viewport_height {
190                // Selected item is in the visible range
191                list_state.select(Some(selected - scroll_offset));
192            }
193        }
194
195        frame.render_stateful_widget(list, area, &mut list_state);
196
197        // Render close button "×" at the right side of the title bar
198        let close_button_x = area.x + area.width.saturating_sub(3);
199        let close_fg = if close_button_hovered {
200            theme.tab_close_hover_fg
201        } else {
202            theme.line_number_fg
203        };
204        let close_button =
205            ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
206        let close_area = Rect::new(close_button_x, area.y, 1, 1);
207        frame.render_widget(close_button, close_area);
208
209        // When focused, show a blinking cursor indicator at the selected row
210        // We render a cursor indicator character and position the hardware cursor there
211        // The hardware cursor provides efficient terminal-native blinking
212        if is_focused {
213            if let Some(selected) = selected_index {
214                if selected >= scroll_offset && selected < scroll_offset + viewport_height {
215                    // Position at the left edge of the selected row (after border)
216                    let cursor_x = area.x + 1;
217                    let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
218
219                    // Render a cursor indicator character that the hardware cursor will blink over
220                    let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
221                        .style(Style::default().fg(theme.cursor));
222                    let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
223                    frame.render_widget(cursor_indicator, cursor_area);
224
225                    // Position hardware cursor here for blinking effect
226                    frame.set_cursor_position((cursor_x, cursor_y));
227                }
228            }
229        }
230    }
231
232    /// Render a single tree node as a ListItem
233    #[allow(clippy::too_many_arguments)]
234    fn render_node(
235        view: &FileTreeView,
236        slot_resolver: ExplorerSlotResolver<'static>,
237        node_id: NodeId,
238        indent: usize,
239        is_selected: bool,
240        is_multi_selected: bool,
241        is_focused: bool,
242        files_with_unsaved_changes: &HashSet<PathBuf>,
243        decorations: &FileExplorerDecorationCache,
244        slot_overrides: &FileExplorerSlotOverrideCache,
245        theme: &Theme,
246        content_width: usize,
247        fuzzy_match: Option<&FuzzyMatch>,
248        cut_paths: &[PathBuf],
249        tree_indicator_collapsed: &str,
250        tree_indicator_expanded: &str,
251    ) -> ListItem<'static> {
252        let line = Self::build_node_line(
253            view,
254            slot_resolver,
255            node_id,
256            indent,
257            is_selected,
258            is_multi_selected,
259            is_focused,
260            files_with_unsaved_changes,
261            decorations,
262            slot_overrides,
263            theme,
264            content_width,
265            fuzzy_match,
266            cut_paths,
267            tree_indicator_collapsed,
268            tree_indicator_expanded,
269        );
270        let row_bg = if (is_selected || is_multi_selected) && is_focused {
271            theme.selection_bg
272        } else {
273            theme.editor_bg
274        };
275        ListItem::new(line).style(Style::default().bg(row_bg))
276    }
277
278    #[allow(clippy::too_many_arguments)]
279    fn build_node_line(
280        view: &FileTreeView,
281        slot_resolver: ExplorerSlotResolver<'static>,
282        node_id: NodeId,
283        indent: usize,
284        is_selected: bool,
285        is_multi_selected: bool,
286        is_focused: bool,
287        files_with_unsaved_changes: &HashSet<PathBuf>,
288        decorations: &FileExplorerDecorationCache,
289        slot_overrides: &FileExplorerSlotOverrideCache,
290        theme: &Theme,
291        content_width: usize,
292        fuzzy_match: Option<&FuzzyMatch>,
293        cut_paths: &[PathBuf],
294        tree_indicator_collapsed: &str,
295        tree_indicator_expanded: &str,
296    ) -> Line<'static> {
297        let node = view.tree().get_node(node_id).expect("Node should exist");
298
299        let mut spans = Vec::new();
300        // Names of any ancestor directories that compact-mode folded into
301        // this row. Outermost-first; each gets prefixed before the anchor
302        // name and joined by `/`.
303        let chain_prefix_names: Vec<String> = view
304            .compact_chain_for_anchor(node_id)
305            .into_iter()
306            .filter_map(|id| view.tree().get_node(id).map(|n| n.entry.name.clone()))
307            .collect();
308
309        // Width reserved for the tree-indicator column. We size it from the
310        // configured collapsed/expanded glyphs (plus a trailing space) so file
311        // and directory names stay aligned even when the user picks wider
312        // custom indicators.
313        let collapsed_w = str_width(tree_indicator_collapsed);
314        let expanded_w = str_width(tree_indicator_expanded);
315        let indicator_width = collapsed_w.max(expanded_w).max(1) + 1;
316
317        let has_unsaved = if node.is_dir() {
318            Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
319        } else {
320            files_with_unsaved_changes.contains(&node.entry.path)
321        };
322
323        let is_pending_cut = cut_paths.iter().any(|cp| cp == &node.entry.path);
324        let neutral_fg = if node
325            .entry
326            .metadata
327            .as_ref()
328            .map(|m| m.is_hidden)
329            .unwrap_or(false)
330        {
331            theme.line_number_fg
332        } else if node.entry.is_symlink() {
333            theme.syntax_type
334        } else if node.is_dir() {
335            theme.syntax_keyword
336        } else {
337            theme.editor_fg
338        };
339        let slot_context = ExplorerSlotContext {
340            path: &node.entry.path,
341            is_dir: node.is_dir(),
342            has_unsaved,
343            is_symlink: node.entry.is_symlink(),
344            is_hidden: node
345                .entry
346                .metadata
347                .as_ref()
348                .map(|m| m.is_hidden)
349                .unwrap_or(false),
350            decorations,
351            slot_overrides,
352            theme,
353            neutral_fg,
354        };
355        let slot_resolution = slot_resolver.resolve(&slot_context);
356        let leading_slot_width = slot_resolution
357            .leading
358            .as_ref()
359            .map(|slot| slot.width() + 1)
360            .unwrap_or(0);
361
362        let base_fg = if is_pending_cut {
363            theme.line_number_fg
364        } else if let Some(name_color_hint) = slot_resolution.name_color_hint {
365            name_color_hint
366        } else if (is_selected || is_multi_selected) && is_focused {
367            theme.editor_fg
368        } else {
369            neutral_fg
370        };
371
372        let chain_prefix_width: usize = chain_prefix_names.iter().map(|s| str_width(s) + 1).sum();
373        let name_width = str_width(&node.entry.name);
374
375        let indent_width = indent * 2;
376        let left_side_width =
377            indent_width + indicator_width + leading_slot_width + chain_prefix_width + name_width;
378        let trailing_slot_width = slot_resolution
379            .trailing
380            .as_ref()
381            .map(|slot| slot.width())
382            .unwrap_or(0);
383        let error_text = if node.is_error() { " [Error]" } else { "" };
384        let error_width = str_width(error_text);
385        let total_right_width = trailing_slot_width + error_width;
386
387        if indent > 0 {
388            spans.push(Span::raw("  ".repeat(indent)));
389        }
390
391        if node.is_dir() {
392            let (indicator, glyph_width) = if node.is_expanded() {
393                (format!("{} ", tree_indicator_expanded), expanded_w + 1)
394            } else if node.is_collapsed() {
395                (format!("{} ", tree_indicator_collapsed), collapsed_w + 1)
396            } else if node.is_loading() {
397                ("⟳ ".to_string(), 2)
398            } else {
399                ("! ".to_string(), 2)
400            };
401            spans.push(Span::styled(
402                indicator,
403                Style::default().fg(theme.diagnostic_warning_fg),
404            ));
405            let pad = indicator_width.saturating_sub(glyph_width);
406            if pad > 0 {
407                spans.push(Span::raw(" ".repeat(pad)));
408            }
409        } else {
410            spans.push(Span::raw(" ".repeat(indicator_width)));
411        }
412
413        if let Some(slot) = slot_resolution.leading {
414            let slot_width = slot.width();
415            let slot_text_width = str_width(&slot.text);
416            spans.push(Span::styled(slot.text, Style::default().fg(slot.fg)));
417            let slot_padding = slot_width.saturating_sub(slot_text_width) + 1;
418            spans.push(Span::raw(" ".repeat(slot_padding)));
419        }
420
421        let chain_segment_style = Style::default().fg(theme.syntax_keyword);
422        let chain_separator_style = Style::default().fg(theme.line_number_fg);
423        for name in &chain_prefix_names {
424            spans.push(Span::styled(name.clone(), chain_segment_style));
425            spans.push(Span::styled("/", chain_separator_style));
426        }
427
428        if let Some(fm) = fuzzy_match {
429            Self::render_name_with_highlights(
430                &node.entry.name,
431                &fm.match_positions,
432                base_fg,
433                theme,
434                &mut spans,
435            );
436        } else {
437            spans.push(Span::styled(
438                node.entry.name.clone(),
439                Style::default().fg(base_fg),
440            ));
441        }
442
443        let min_gap = 1;
444        let padding = if left_side_width + min_gap + total_right_width < content_width {
445            content_width - left_side_width - total_right_width
446        } else {
447            min_gap
448        };
449        spans.push(Span::raw(" ".repeat(padding)));
450
451        if let Some(slot) = slot_resolution.trailing {
452            spans.push(Span::styled(slot.text, Style::default().fg(slot.fg)));
453        }
454
455        if node.is_error() {
456            spans.push(Span::styled(
457                error_text,
458                Style::default().fg(theme.diagnostic_error_fg),
459            ));
460        }
461
462        Line::from(spans)
463    }
464
465    pub(crate) fn trailing_slot_screen_bounds(
466        view: &FileTreeView,
467        node_id: NodeId,
468        indent: usize,
469        content_width: usize,
470        slot_resolution: &ExplorerSlotResolution,
471        tree_indicator_collapsed: &str,
472        tree_indicator_expanded: &str,
473        explorer_area: Rect,
474    ) -> Option<(u16, u16)> {
475        let trailing_slot = slot_resolution.trailing.as_ref()?;
476        let node = view.tree().get_node(node_id).expect("Node should exist");
477
478        let chain_prefix_names: Vec<String> = view
479            .compact_chain_for_anchor(node_id)
480            .into_iter()
481            .filter_map(|id| view.tree().get_node(id).map(|n| n.entry.name.clone()))
482            .collect();
483        let collapsed_w = str_width(tree_indicator_collapsed);
484        let expanded_w = str_width(tree_indicator_expanded);
485        let indicator_width = collapsed_w.max(expanded_w).max(1) + 1;
486        let leading_slot_width = slot_resolution
487            .leading
488            .as_ref()
489            .map(|slot| slot.width() + 1)
490            .unwrap_or(0);
491        let chain_prefix_width: usize = chain_prefix_names.iter().map(|s| str_width(s) + 1).sum();
492        let name_width = str_width(&node.entry.name);
493        let left_side_width =
494            indent * 2 + indicator_width + leading_slot_width + chain_prefix_width + name_width;
495        let trailing_slot_width = trailing_slot.width();
496        let error_width = if node.is_error() {
497            str_width(" [Error]")
498        } else {
499            0
500        };
501        let total_right_width = trailing_slot_width + error_width;
502        let min_gap = 1;
503        let padding = if left_side_width + min_gap + total_right_width < content_width {
504            content_width - left_side_width - total_right_width
505        } else {
506            min_gap
507        };
508        let content_start_x = explorer_area.x + 2;
509        let slot_start = content_start_x + (left_side_width + padding) as u16;
510        let slot_end = slot_start + trailing_slot_width as u16;
511        Some((slot_start, slot_end))
512    }
513
514    /// Render a file/directory name with matched characters highlighted
515    fn render_name_with_highlights(
516        name: &str,
517        match_positions: &[usize],
518        base_fg: Color,
519        theme: &Theme,
520        spans: &mut Vec<Span<'static>>,
521    ) {
522        if match_positions.is_empty() {
523            spans.push(Span::styled(name.to_string(), Style::default().fg(base_fg)));
524            return;
525        }
526
527        let chars: Vec<char> = name.chars().collect();
528        let match_set: std::collections::HashSet<usize> = match_positions.iter().copied().collect();
529
530        let base_style = Style::default().fg(base_fg);
531        let highlight_style = Style::default()
532            .fg(theme.search_match_fg)
533            .bg(theme.search_match_bg);
534
535        let mut current_span = String::new();
536        let mut current_is_match = false;
537
538        for (i, &c) in chars.iter().enumerate() {
539            let is_match = match_set.contains(&i);
540
541            if i == 0 {
542                current_is_match = is_match;
543                current_span.push(c);
544            } else if is_match == current_is_match {
545                current_span.push(c);
546            } else {
547                // Style changed, push current span and start new one
548                let style = if current_is_match {
549                    highlight_style
550                } else {
551                    base_style
552                };
553                spans.push(Span::styled(current_span.clone(), style));
554                current_span.clear();
555                current_span.push(c);
556                current_is_match = is_match;
557            }
558        }
559
560        // Push final span
561        if !current_span.is_empty() {
562            let style = if current_is_match {
563                highlight_style
564            } else {
565                base_style
566            };
567            spans.push(Span::styled(current_span, style));
568        }
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use crate::model::filesystem::StdFileSystem;
576    use crate::services::fs::FsManager;
577    use std::collections::{HashMap, HashSet};
578    use std::fs as std_fs;
579    use std::sync::Arc;
580    use tempfile::TempDir;
581
582    async fn create_renderer_view() -> (TempDir, FileTreeView) {
583        let temp_dir = TempDir::new().unwrap();
584        let root = temp_dir.path();
585
586        std_fs::create_dir(root.join("src")).unwrap();
587        std_fs::write(root.join("README.md"), "hello").unwrap();
588        std_fs::write(root.join("src/schema.ts"), "export const value = 1;\n").unwrap();
589
590        let manager = Arc::new(FsManager::new(Arc::new(StdFileSystem)));
591        let mut tree = crate::view::file_tree::FileTree::new(root.to_path_buf(), manager)
592            .await
593            .unwrap();
594        let root_id = tree.root_id();
595        tree.expand_node(root_id).await.unwrap();
596        let src_id = tree
597            .get_node(root_id)
598            .unwrap()
599            .children
600            .iter()
601            .copied()
602            .find(|id| tree.get_node(*id).unwrap().entry.name == "src")
603            .unwrap();
604        tree.expand_node(src_id).await.unwrap();
605
606        (temp_dir, FileTreeView::new(tree))
607    }
608
609    fn build_line(
610        view: &FileTreeView,
611        node_id: NodeId,
612        indent: usize,
613        decorations: &FileExplorerDecorationCache,
614        slot_overrides: &FileExplorerSlotOverrideCache,
615        theme: &Theme,
616    ) -> Line<'static> {
617        FileExplorerRenderer::build_node_line(
618            view,
619            crate::view::file_tree::default_slot_providers().resolver(),
620            node_id,
621            indent,
622            false,
623            false,
624            false,
625            &HashSet::new(),
626            decorations,
627            slot_overrides,
628            theme,
629            80,
630            None,
631            &[],
632            ">",
633            "▼",
634        )
635    }
636
637    #[tokio::test]
638    async fn renderer_line_shows_plugin_decoration_badge() {
639        let (_temp_dir, view) = create_renderer_view().await;
640        let theme = Theme::load_builtin("dark").unwrap();
641        let schema_path = view.tree().root_path().join("src/schema.ts");
642        let schema_id = view.tree().get_node_by_path(&schema_path).unwrap().id;
643        let decorations = FileExplorerDecorationCache::rebuild(
644            vec![crate::view::file_tree::FileExplorerDecoration {
645                path: schema_path,
646                symbol: "M".to_string(),
647                color: fresh_core::api::OverlayColorSpec::ThemeKey(
648                    "ui.file_status_modified_fg".into(),
649                ),
650                priority: 50,
651            }],
652            view.tree().root_path(),
653            &HashMap::new(),
654        );
655
656        let line = build_line(
657            &view,
658            schema_id,
659            2,
660            &decorations,
661            &FileExplorerSlotOverrideCache::default(),
662            &theme,
663        );
664
665        assert!(line.spans.iter().any(|span| {
666            span.content.as_ref() == "M" && span.style.fg == Some(theme.file_status_modified_fg)
667        }));
668    }
669
670    #[tokio::test]
671    async fn directories_render_bubbled_plugin_status() {
672        let (_temp_dir, view) = create_renderer_view().await;
673        let theme = Theme::load_builtin("dark").unwrap();
674        let src_path = view.tree().root_path().join("src");
675        let schema_path = src_path.join("schema.ts");
676        let src_id = view.tree().get_node_by_path(&src_path).unwrap().id;
677        let decorations = FileExplorerDecorationCache::rebuild(
678            vec![crate::view::file_tree::FileExplorerDecoration {
679                path: schema_path,
680                symbol: "R".to_string(),
681                color: fresh_core::api::OverlayColorSpec::ThemeKey(
682                    "ui.file_status_renamed_fg".into(),
683                ),
684                priority: 40,
685            }],
686            view.tree().root_path(),
687            &HashMap::new(),
688        );
689
690        let line = build_line(
691            &view,
692            src_id,
693            1,
694            &decorations,
695            &FileExplorerSlotOverrideCache::default(),
696            &theme,
697        );
698
699        assert!(line.spans.iter().any(|span| {
700            span.content.as_ref() == "●" && span.style.fg == Some(theme.file_status_renamed_fg)
701        }));
702    }
703
704    #[tokio::test]
705    async fn default_slot_providers_allow_explicit_slot_and_name_color_overrides() {
706        let (_temp_dir, view) = create_renderer_view().await;
707        let theme = Theme::load_builtin("dark").unwrap();
708        let schema_path = view.tree().root_path().join("src/schema.ts");
709        let schema_id = view.tree().get_node_by_path(&schema_path).unwrap().id;
710        let slot_overrides = FileExplorerSlotOverrideCache::rebuild(
711            vec![fresh_core::file_explorer::FileExplorerSlotEntry {
712                path: schema_path.clone(),
713                leading: Some(fresh_core::file_explorer::FileExplorerLeadingSlot {
714                    text: "PL".to_string(),
715                    color: fresh_core::api::OverlayColorSpec::ThemeKey("syntax.string".into()),
716                    min_width: 2,
717                }),
718                trailing: Some(fresh_core::file_explorer::FileExplorerTrailingSlot {
719                    text: "X".to_string(),
720                    color: fresh_core::api::OverlayColorSpec::ThemeKey("syntax.type".into()),
721                    tooltip: Some(fresh_core::file_explorer::FileExplorerTooltip {
722                        title: "Plugin".to_string(),
723                        lines: vec!["Overridden".to_string()],
724                    }),
725                }),
726                name_color: Some(fresh_core::api::OverlayColorSpec::ThemeKey(
727                    "ui.file_status_added_fg".into(),
728                )),
729                priority: 50,
730                suppress_leading: false,
731                suppress_trailing: false,
732                suppress_name_color: false,
733            }],
734            view.tree().root_path(),
735            &HashMap::new(),
736        );
737
738        let line = build_line(
739            &view,
740            schema_id,
741            2,
742            &FileExplorerDecorationCache::default(),
743            &slot_overrides,
744            &theme,
745        );
746
747        assert!(line.spans.iter().any(|span| span.content.as_ref() == "PL"));
748        assert!(line.spans.iter().any(|span| span.content.as_ref() == "X"));
749        assert!(line.spans.iter().any(|span| {
750            span.content.as_ref() == "schema.ts"
751                && span.style.fg == Some(theme.file_status_added_fg)
752        }));
753    }
754
755    #[tokio::test]
756    async fn default_slot_providers_fall_back_when_only_name_color_is_overridden() {
757        let (_temp_dir, view) = create_renderer_view().await;
758        let theme = Theme::load_builtin("dark").unwrap();
759        let schema_path = view.tree().root_path().join("src/schema.ts");
760        let schema_id = view.tree().get_node_by_path(&schema_path).unwrap().id;
761        let decorations = FileExplorerDecorationCache::rebuild(
762            vec![crate::view::file_tree::FileExplorerDecoration {
763                path: schema_path.clone(),
764                symbol: "M".to_string(),
765                color: fresh_core::api::OverlayColorSpec::ThemeKey(
766                    "ui.file_status_modified_fg".into(),
767                ),
768                priority: 50,
769            }],
770            view.tree().root_path(),
771            &HashMap::new(),
772        );
773        let slot_overrides = FileExplorerSlotOverrideCache::rebuild(
774            vec![fresh_core::file_explorer::FileExplorerSlotEntry {
775                path: schema_path,
776                leading: None,
777                trailing: None,
778                name_color: Some(fresh_core::api::OverlayColorSpec::ThemeKey(
779                    "syntax.string".into(),
780                )),
781                priority: 50,
782                suppress_leading: false,
783                suppress_trailing: false,
784                suppress_name_color: false,
785            }],
786            view.tree().root_path(),
787            &HashMap::new(),
788        );
789
790        let line = build_line(&view, schema_id, 2, &decorations, &slot_overrides, &theme);
791
792        assert!(line.spans.iter().any(|span| {
793            span.content.as_ref() == "schema.ts" && span.style.fg == Some(theme.syntax_string)
794        }));
795        assert!(line.spans.iter().any(|span| {
796            span.content.as_ref() == "M" && span.style.fg == Some(theme.file_status_modified_fg)
797        }));
798    }
799
800    #[tokio::test]
801    async fn trailing_slot_bounds_track_rendered_right_edge_geometry() {
802        let (_temp_dir, view) = create_renderer_view().await;
803        let theme = Theme::load_builtin("dark").unwrap();
804        let schema_path = view.tree().root_path().join("src/schema.ts");
805        let schema_id = view.tree().get_node_by_path(&schema_path).unwrap().id;
806        let decorations = FileExplorerDecorationCache::rebuild(
807            vec![crate::view::file_tree::FileExplorerDecoration {
808                path: schema_path.clone(),
809                symbol: "M".to_string(),
810                color: fresh_core::api::OverlayColorSpec::ThemeKey(
811                    "ui.file_status_modified_fg".into(),
812                ),
813                priority: 50,
814            }],
815            view.tree().root_path(),
816            &HashMap::new(),
817        );
818        let slot_context = ExplorerSlotContext {
819            path: &schema_path,
820            is_dir: false,
821            has_unsaved: false,
822            is_symlink: false,
823            is_hidden: false,
824            decorations: &decorations,
825            slot_overrides: &FileExplorerSlotOverrideCache::default(),
826            theme: &theme,
827            neutral_fg: theme.editor_fg,
828        };
829        let slot_resolution = crate::view::file_tree::default_slot_providers()
830            .resolver()
831            .resolve(&slot_context);
832        let area = Rect::new(0, 0, 40, 10);
833        let content_width = area.width.saturating_sub(3) as usize;
834
835        let bounds = FileExplorerRenderer::trailing_slot_screen_bounds(
836            &view,
837            schema_id,
838            2,
839            content_width,
840            &slot_resolution,
841            ">",
842            "▼",
843            area,
844        )
845        .expect("modified file should render a trailing slot");
846
847        assert_eq!(bounds.1, area.x + area.width.saturating_sub(1));
848        assert_eq!(bounds.1 - bounds.0, 1);
849    }
850}