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