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#[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 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 #[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 rec: &mut CellThemeRecorder,
70 draw: bool,
74 ) {
75 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 let tree_indicator_collapsed = config.tree_indicator_collapsed.as_str();
87 let tree_indicator_expanded = config.tree_indicator_expanded.as_str();
88
89 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 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 let scroll_offset = scroll_offset.min(display_nodes.len());
113
114 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
117 let visible_items = &display_nodes[scroll_offset..visible_end];
118
119 let content_width = area.width.saturating_sub(3) as usize;
121
122 let multi_selection = view.multi_selection();
123
124 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 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 let title = if search_active {
167 format!(" /{} ", view.search_query())
168 } else if let Some(host) = remote_connection {
169 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 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 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 let mut list_state = ListState::default();
229 if let Some(selected) = selected_index {
230 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
231 list_state.select(Some(selected - scroll_offset));
233 }
234 }
235
236 frame.render_stateful_widget(list, area, &mut list_state);
237
238 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 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 if is_focused {
277 if let Some(selected) = selected_index {
278 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
279 let cursor_x = area.x + 1;
281 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
282
283 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 frame.set_cursor_position((cursor_x, cursor_y));
291 }
292 }
293 }
294 }
295
296 #[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 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 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 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 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 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}