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