1use crate::input::fuzzy::FuzzyMatch;
2use crate::primitives::display_width::str_width;
3use crate::view::file_tree::{FileExplorerDecorationCache, FileTreeView, NodeId};
4use crate::view::theme::Theme;
5use ratatui::{
6 layout::Rect,
7 style::{Color, Modifier, Style},
8 text::{Line, Span},
9 widgets::{Block, Borders, List, ListItem, ListState},
10 Frame,
11};
12
13use std::collections::HashSet;
14use std::path::PathBuf;
15
16pub struct FileExplorerRenderer;
17
18impl FileExplorerRenderer {
19 fn folder_has_modified_files(
21 folder_path: &PathBuf,
22 files_with_unsaved_changes: &HashSet<PathBuf>,
23 ) -> bool {
24 for modified_file in files_with_unsaved_changes {
25 if modified_file.starts_with(folder_path) {
26 return true;
27 }
28 }
29 false
30 }
31
32 #[allow(clippy::too_many_arguments)]
34 pub fn render(
35 view: &mut FileTreeView,
36 frame: &mut Frame,
37 area: Rect,
38 is_focused: bool,
39 files_with_unsaved_changes: &HashSet<PathBuf>,
40 decorations: &FileExplorerDecorationCache,
41 keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
42 current_context: crate::input::keybindings::KeyContext,
43 theme: &Theme,
44 close_button_hovered: bool,
45 remote_connection: Option<&str>,
46 cut_paths: &[PathBuf],
47 tree_indicator_collapsed: &str,
48 tree_indicator_expanded: &str,
49 ) {
50 let search_active = view.is_search_active();
51
52 let viewport_height = area.height.saturating_sub(2) as usize;
55 view.set_viewport_height(viewport_height);
56
57 let display_nodes = view.get_display_nodes();
58 let scroll_offset = view.get_scroll_offset();
59 let selected_index = view.get_selected_index();
60
61 let scroll_offset = scroll_offset.min(display_nodes.len());
65
66 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
69 let visible_items = &display_nodes[scroll_offset..visible_end];
70
71 let content_width = area.width.saturating_sub(3) as usize;
73
74 let multi_selection = view.multi_selection();
75
76 let items: Vec<ListItem> = visible_items
78 .iter()
79 .enumerate()
80 .map(|(viewport_idx, &(node_id, indent))| {
81 let actual_idx = scroll_offset + viewport_idx;
82 let is_selected = selected_index == Some(actual_idx);
83 let is_multi_selected = multi_selection.contains(&node_id);
84 let fuzzy_match = if search_active {
85 view.get_match_for_node(node_id)
86 } else {
87 None
88 };
89 Self::render_node(
90 view,
91 node_id,
92 indent,
93 is_selected,
94 is_multi_selected,
95 is_focused,
96 files_with_unsaved_changes,
97 decorations,
98 theme,
99 content_width,
100 fuzzy_match.as_ref(),
101 cut_paths,
102 tree_indicator_collapsed,
103 tree_indicator_expanded,
104 )
105 })
106 .collect();
107
108 let keybinding_suffix = keybinding_resolver
110 .get_keybinding_for_action(
111 &crate::input::keybindings::Action::FocusFileExplorer,
112 current_context,
113 )
114 .map(|kb| format!(" ({})", kb))
115 .unwrap_or_default();
116
117 let title = if search_active {
119 format!(" /{} ", view.search_query())
120 } else if let Some(host) = remote_connection {
121 let hostname = host
123 .split('@')
124 .next_back()
125 .unwrap_or(host)
126 .split(':')
127 .next()
128 .unwrap_or(host);
129 format!(" [{}]{} ", hostname, keybinding_suffix)
130 } else {
131 format!(" File Explorer{} ", keybinding_suffix)
132 };
133
134 let remote_disconnected = remote_connection
137 .map(|c| c.contains("(Disconnected)"))
138 .unwrap_or(false);
139 let (title_style, border_style) = if remote_disconnected {
140 (
141 Style::default()
142 .fg(theme.status_error_indicator_fg)
143 .bg(theme.status_error_indicator_bg)
144 .add_modifier(Modifier::BOLD),
145 Style::default().fg(theme.status_error_indicator_bg),
146 )
147 } else if is_focused {
148 (
149 Style::default()
150 .fg(theme.editor_bg)
151 .bg(theme.editor_fg)
152 .add_modifier(Modifier::BOLD),
153 Style::default().fg(theme.cursor),
154 )
155 } else {
156 (
157 Style::default().fg(theme.line_number_fg),
158 Style::default().fg(theme.split_separator_fg),
159 )
160 };
161
162 let list = List::new(items)
164 .block(
165 Block::default()
166 .borders(Borders::ALL)
167 .title(title)
168 .title_style(title_style)
169 .border_style(border_style)
170 .style(Style::default().bg(theme.editor_bg)),
171 )
172 .highlight_style(if is_focused {
173 Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
174 } else {
175 Style::default().bg(theme.current_line_bg)
176 });
177
178 let mut list_state = ListState::default();
181 if let Some(selected) = selected_index {
182 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
183 list_state.select(Some(selected - scroll_offset));
185 }
186 }
187
188 frame.render_stateful_widget(list, area, &mut list_state);
189
190 let close_button_x = area.x + area.width.saturating_sub(3);
192 let close_fg = if close_button_hovered {
193 theme.tab_close_hover_fg
194 } else {
195 theme.line_number_fg
196 };
197 let close_button =
198 ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
199 let close_area = Rect::new(close_button_x, area.y, 1, 1);
200 frame.render_widget(close_button, close_area);
201
202 if is_focused {
206 if let Some(selected) = selected_index {
207 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
208 let cursor_x = area.x + 1;
210 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
211
212 let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
214 .style(Style::default().fg(theme.cursor));
215 let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
216 frame.render_widget(cursor_indicator, cursor_area);
217
218 frame.set_cursor_position((cursor_x, cursor_y));
220 }
221 }
222 }
223 }
224
225 #[allow(clippy::too_many_arguments)]
227 fn render_node(
228 view: &FileTreeView,
229 node_id: NodeId,
230 indent: usize,
231 is_selected: bool,
232 is_multi_selected: bool,
233 is_focused: bool,
234 files_with_unsaved_changes: &HashSet<PathBuf>,
235 decorations: &FileExplorerDecorationCache,
236 theme: &Theme,
237 content_width: usize,
238 fuzzy_match: Option<&FuzzyMatch>,
239 cut_paths: &[PathBuf],
240 tree_indicator_collapsed: &str,
241 tree_indicator_expanded: &str,
242 ) -> ListItem<'static> {
243 let node = view.tree().get_node(node_id).expect("Node should exist");
244
245 let mut spans = Vec::new();
247
248 let chain_prefix_names: Vec<String> = view
252 .compact_chain_for_anchor(node_id)
253 .into_iter()
254 .filter_map(|id| view.tree().get_node(id).map(|n| n.entry.name.clone()))
255 .collect();
256
257 let collapsed_w = str_width(tree_indicator_collapsed);
264 let expanded_w = str_width(tree_indicator_expanded);
265 let indicator_width = collapsed_w.max(expanded_w).max(1) + 1;
266
267 let indent_width = indent * 2;
269 let chain_prefix_width: usize = chain_prefix_names.iter().map(|s| str_width(s) + 1).sum();
271 let name_width = str_width(&node.entry.name);
272 let left_side_width = indent_width + indicator_width + chain_prefix_width + name_width;
273
274 if indent > 0 {
276 spans.push(Span::raw(" ".repeat(indent)));
277 }
278
279 if node.is_dir() {
281 let (indicator, glyph_width) = if node.is_expanded() {
282 (format!("{} ", tree_indicator_expanded), expanded_w + 1)
283 } else if node.is_collapsed() {
284 (format!("{} ", tree_indicator_collapsed), collapsed_w + 1)
285 } else if node.is_loading() {
286 ("⟳ ".to_string(), 2)
287 } else {
288 ("! ".to_string(), 2)
289 };
290 spans.push(Span::styled(
291 indicator,
292 Style::default().fg(theme.diagnostic_warning_fg),
293 ));
294 let pad = indicator_width.saturating_sub(glyph_width);
295 if pad > 0 {
296 spans.push(Span::raw(" ".repeat(pad)));
297 }
298 } else {
299 spans.push(Span::raw(" ".repeat(indicator_width)));
301 }
302
303 let is_pending_cut = cut_paths.iter().any(|cp| cp == &node.entry.path);
305
306 let base_fg = if is_pending_cut {
307 theme.line_number_fg
308 } else if (is_selected || is_multi_selected) && is_focused {
309 theme.editor_fg
310 } else if node
311 .entry
312 .metadata
313 .as_ref()
314 .map(|m| m.is_hidden)
315 .unwrap_or(false)
316 {
317 theme.line_number_fg
318 } else if node.entry.is_symlink() {
319 theme.syntax_type
321 } else if node.is_dir() {
322 theme.syntax_keyword
323 } else {
324 theme.editor_fg
325 };
326
327 let chain_segment_style = Style::default().fg(theme.syntax_keyword);
332 let chain_separator_style = Style::default().fg(theme.line_number_fg);
333 for name in &chain_prefix_names {
334 spans.push(Span::styled(name.clone(), chain_segment_style));
335 spans.push(Span::styled("/", chain_separator_style));
336 }
337
338 if let Some(fm) = fuzzy_match {
340 Self::render_name_with_highlights(
341 &node.entry.name,
342 &fm.match_positions,
343 base_fg,
344 theme,
345 &mut spans,
346 );
347 } else {
348 spans.push(Span::styled(
349 node.entry.name.clone(),
350 Style::default().fg(base_fg),
351 ));
352 }
353
354 let has_unsaved = if node.is_dir() {
357 Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
358 } else {
359 files_with_unsaved_changes.contains(&node.entry.path)
360 };
361
362 let direct_decoration = decorations.direct_for_path(&node.entry.path);
363 let bubbled_decoration = if node.is_dir() {
364 decorations
365 .bubbled_for_path(&node.entry.path)
366 .filter(|_| direct_decoration.is_none())
367 } else {
368 None
369 };
370
371 let right_indicator: Option<(String, Color)> = if has_unsaved {
372 Some(("●".to_string(), theme.diagnostic_warning_fg))
373 } else if let Some(decoration) = direct_decoration {
374 let symbol = Self::decoration_symbol(&decoration.symbol);
375 Some((symbol, Self::decoration_color(decoration, theme)))
376 } else {
377 bubbled_decoration
378 .map(|decoration| ("●".to_string(), Self::decoration_color(decoration, theme)))
379 };
380
381 let right_indicator_width = right_indicator
383 .as_ref()
384 .map(|(s, _)| str_width(s))
385 .unwrap_or(0);
386
387 let error_text = if node.is_error() { " [Error]" } else { "" };
389 let error_width = str_width(error_text);
390
391 let total_right_width = right_indicator_width + error_width;
392
393 let min_gap = 1;
395 let padding = if left_side_width + min_gap + total_right_width < content_width {
396 content_width - left_side_width - total_right_width
397 } else {
398 min_gap
399 };
400
401 spans.push(Span::raw(" ".repeat(padding)));
402
403 if let Some((symbol, color)) = right_indicator {
405 spans.push(Span::styled(symbol, Style::default().fg(color)));
406 }
407
408 if node.is_error() {
410 spans.push(Span::styled(
411 error_text,
412 Style::default().fg(theme.diagnostic_error_fg),
413 ));
414 }
415
416 let row_bg = if (is_selected || is_multi_selected) && is_focused {
417 theme.selection_bg
418 } else {
419 theme.editor_bg
420 };
421 ListItem::new(Line::from(spans)).style(Style::default().bg(row_bg))
422 }
423
424 fn decoration_symbol(symbol: &str) -> String {
425 symbol
426 .chars()
427 .next()
428 .map(|c| c.to_string())
429 .unwrap_or_else(|| " ".to_string())
430 }
431
432 fn decoration_color(
433 decoration: &crate::view::file_tree::FileExplorerDecoration,
434 theme: &Theme,
435 ) -> Color {
436 match &decoration.color {
437 fresh_core::api::OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
438 fresh_core::api::OverlayColorSpec::ThemeKey(key) => {
439 theme.resolve_theme_key(key).unwrap_or(theme.editor_fg)
440 }
441 }
442 }
443
444 fn render_name_with_highlights(
446 name: &str,
447 match_positions: &[usize],
448 base_fg: Color,
449 theme: &Theme,
450 spans: &mut Vec<Span<'static>>,
451 ) {
452 if match_positions.is_empty() {
453 spans.push(Span::styled(name.to_string(), Style::default().fg(base_fg)));
454 return;
455 }
456
457 let chars: Vec<char> = name.chars().collect();
458 let match_set: std::collections::HashSet<usize> = match_positions.iter().copied().collect();
459
460 let base_style = Style::default().fg(base_fg);
461 let highlight_style = Style::default()
462 .fg(theme.search_match_fg)
463 .bg(theme.search_match_bg);
464
465 let mut current_span = String::new();
466 let mut current_is_match = false;
467
468 for (i, &c) in chars.iter().enumerate() {
469 let is_match = match_set.contains(&i);
470
471 if i == 0 {
472 current_is_match = is_match;
473 current_span.push(c);
474 } else if is_match == current_is_match {
475 current_span.push(c);
476 } else {
477 let style = if current_is_match {
479 highlight_style
480 } else {
481 base_style
482 };
483 spans.push(Span::styled(current_span.clone(), style));
484 current_span.clear();
485 current_span.push(c);
486 current_is_match = is_match;
487 }
488 }
489
490 if !current_span.is_empty() {
492 let style = if current_is_match {
493 highlight_style
494 } else {
495 base_style
496 };
497 spans.push(Span::styled(current_span, style));
498 }
499 }
500}