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 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 #[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 draw: bool,
60 ) {
61 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 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 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 let scroll_offset = scroll_offset.min(display_nodes.len());
96
97 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
100 let visible_items = &display_nodes[scroll_offset..visible_end];
101
102 let content_width = area.width.saturating_sub(3) as usize;
104
105 let multi_selection = view.multi_selection();
106
107 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 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 let title = if search_active {
152 format!(" /{} ", view.search_query())
153 } else if let Some(host) = remote_connection {
154 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 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 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 let mut list_state = ListState::default();
214 if let Some(selected) = selected_index {
215 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
216 list_state.select(Some(selected - scroll_offset));
218 }
219 }
220
221 frame.render_stateful_widget(list, area, &mut list_state);
222
223 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 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 if is_focused {
264 if let Some(selected) = selected_index {
265 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
266 let cursor_x = area.x + 1;
268 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
269
270 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 frame.set_cursor_position((cursor_x, cursor_y));
278 }
279 }
280 }
281 }
282
283 #[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 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 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 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 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 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}