Skip to main content

fresh/view/ui/
file_explorer.rs

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