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 ) {
48 let search_active = view.is_search_active();
49
50 let viewport_height = area.height.saturating_sub(2) as usize;
53 view.set_viewport_height(viewport_height);
54
55 let display_nodes = view.get_display_nodes();
56 let scroll_offset = view.get_scroll_offset();
57 let selected_index = view.get_selected_index();
58
59 let scroll_offset = scroll_offset.min(display_nodes.len());
63
64 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
67 let visible_items = &display_nodes[scroll_offset..visible_end];
68
69 let content_width = area.width.saturating_sub(3) as usize;
71
72 let multi_selection = view.multi_selection();
73
74 let items: Vec<ListItem> = visible_items
76 .iter()
77 .enumerate()
78 .map(|(viewport_idx, &(node_id, indent))| {
79 let actual_idx = scroll_offset + viewport_idx;
80 let is_selected = selected_index == Some(actual_idx);
81 let is_multi_selected = multi_selection.contains(&node_id);
82 let fuzzy_match = if search_active {
83 view.get_match_for_node(node_id)
84 } else {
85 None
86 };
87 Self::render_node(
88 view,
89 node_id,
90 indent,
91 is_selected,
92 is_multi_selected,
93 is_focused,
94 files_with_unsaved_changes,
95 decorations,
96 theme,
97 content_width,
98 fuzzy_match.as_ref(),
99 cut_paths,
100 )
101 })
102 .collect();
103
104 let keybinding_suffix = keybinding_resolver
106 .get_keybinding_for_action(
107 &crate::input::keybindings::Action::FocusFileExplorer,
108 current_context,
109 )
110 .map(|kb| format!(" ({})", kb))
111 .unwrap_or_default();
112
113 let title = if search_active {
115 format!(" /{} ", view.search_query())
116 } else if let Some(host) = remote_connection {
117 let hostname = host
119 .split('@')
120 .next_back()
121 .unwrap_or(host)
122 .split(':')
123 .next()
124 .unwrap_or(host);
125 format!(" [{}]{} ", hostname, keybinding_suffix)
126 } else {
127 format!(" File Explorer{} ", keybinding_suffix)
128 };
129
130 let remote_disconnected = remote_connection
133 .map(|c| c.contains("(Disconnected)"))
134 .unwrap_or(false);
135 let (title_style, border_style) = if remote_disconnected {
136 (
137 Style::default()
138 .fg(theme.status_error_indicator_fg)
139 .bg(theme.status_error_indicator_bg)
140 .add_modifier(Modifier::BOLD),
141 Style::default().fg(theme.status_error_indicator_bg),
142 )
143 } else if is_focused {
144 (
145 Style::default()
146 .fg(theme.editor_bg)
147 .bg(theme.editor_fg)
148 .add_modifier(Modifier::BOLD),
149 Style::default().fg(theme.cursor),
150 )
151 } else {
152 (
153 Style::default().fg(theme.line_number_fg),
154 Style::default().fg(theme.split_separator_fg),
155 )
156 };
157
158 let list = List::new(items)
160 .block(
161 Block::default()
162 .borders(Borders::ALL)
163 .title(title)
164 .title_style(title_style)
165 .border_style(border_style)
166 .style(Style::default().bg(theme.editor_bg)),
167 )
168 .highlight_style(if is_focused {
169 Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
170 } else {
171 Style::default().bg(theme.current_line_bg)
172 });
173
174 let mut list_state = ListState::default();
177 if let Some(selected) = selected_index {
178 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
179 list_state.select(Some(selected - scroll_offset));
181 }
182 }
183
184 frame.render_stateful_widget(list, area, &mut list_state);
185
186 let close_button_x = area.x + area.width.saturating_sub(3);
188 let close_fg = if close_button_hovered {
189 theme.tab_close_hover_fg
190 } else {
191 theme.line_number_fg
192 };
193 let close_button =
194 ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
195 let close_area = Rect::new(close_button_x, area.y, 1, 1);
196 frame.render_widget(close_button, close_area);
197
198 if is_focused {
202 if let Some(selected) = selected_index {
203 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
204 let cursor_x = area.x + 1;
206 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
207
208 let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
210 .style(Style::default().fg(theme.cursor));
211 let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
212 frame.render_widget(cursor_indicator, cursor_area);
213
214 frame.set_cursor_position((cursor_x, cursor_y));
216 }
217 }
218 }
219 }
220
221 #[allow(clippy::too_many_arguments)]
223 fn render_node(
224 view: &FileTreeView,
225 node_id: NodeId,
226 indent: usize,
227 is_selected: bool,
228 is_multi_selected: bool,
229 is_focused: bool,
230 files_with_unsaved_changes: &HashSet<PathBuf>,
231 decorations: &FileExplorerDecorationCache,
232 theme: &Theme,
233 content_width: usize,
234 fuzzy_match: Option<&FuzzyMatch>,
235 cut_paths: &[PathBuf],
236 ) -> ListItem<'static> {
237 let node = view.tree().get_node(node_id).expect("Node should exist");
238
239 let mut spans = Vec::new();
241
242 let indent_width = indent * 2;
244 let indicator_width = 2; let name_width = str_width(&node.entry.name);
246 let left_side_width = indent_width + indicator_width + name_width;
247
248 if indent > 0 {
250 spans.push(Span::raw(" ".repeat(indent)));
251 }
252
253 if node.is_dir() {
255 let indicator = if node.is_expanded() {
256 "▼ "
257 } else if node.is_collapsed() {
258 "> "
259 } else if node.is_loading() {
260 "⟳ "
261 } else {
262 "! "
263 };
264 spans.push(Span::styled(
265 indicator,
266 Style::default().fg(theme.diagnostic_warning_fg),
267 ));
268 } else {
269 spans.push(Span::raw(" "));
271 }
272
273 let is_pending_cut = cut_paths.iter().any(|cp| cp == &node.entry.path);
275
276 let base_fg = if is_pending_cut {
277 theme.line_number_fg
278 } else if (is_selected || is_multi_selected) && is_focused {
279 theme.editor_fg
280 } else if node
281 .entry
282 .metadata
283 .as_ref()
284 .map(|m| m.is_hidden)
285 .unwrap_or(false)
286 {
287 theme.line_number_fg
288 } else if node.entry.is_symlink() {
289 theme.syntax_type
291 } else if node.is_dir() {
292 theme.syntax_keyword
293 } else {
294 theme.editor_fg
295 };
296
297 if let Some(fm) = fuzzy_match {
299 Self::render_name_with_highlights(
300 &node.entry.name,
301 &fm.match_positions,
302 base_fg,
303 theme,
304 &mut spans,
305 );
306 } else {
307 spans.push(Span::styled(
308 node.entry.name.clone(),
309 Style::default().fg(base_fg),
310 ));
311 }
312
313 let has_unsaved = if node.is_dir() {
316 Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
317 } else {
318 files_with_unsaved_changes.contains(&node.entry.path)
319 };
320
321 let direct_decoration = decorations.direct_for_path(&node.entry.path);
322 let bubbled_decoration = if node.is_dir() {
323 decorations
324 .bubbled_for_path(&node.entry.path)
325 .filter(|_| direct_decoration.is_none())
326 } else {
327 None
328 };
329
330 let right_indicator: Option<(String, Color)> = if has_unsaved {
331 Some(("●".to_string(), theme.diagnostic_warning_fg))
332 } else if let Some(decoration) = direct_decoration {
333 let symbol = Self::decoration_symbol(&decoration.symbol);
334 Some((symbol, Self::decoration_color(decoration, theme)))
335 } else {
336 bubbled_decoration
337 .map(|decoration| ("●".to_string(), Self::decoration_color(decoration, theme)))
338 };
339
340 let right_indicator_width = right_indicator
342 .as_ref()
343 .map(|(s, _)| str_width(s))
344 .unwrap_or(0);
345
346 let error_text = if node.is_error() { " [Error]" } else { "" };
348 let error_width = str_width(error_text);
349
350 let total_right_width = right_indicator_width + error_width;
351
352 let min_gap = 1;
354 let padding = if left_side_width + min_gap + total_right_width < content_width {
355 content_width - left_side_width - total_right_width
356 } else {
357 min_gap
358 };
359
360 spans.push(Span::raw(" ".repeat(padding)));
361
362 if let Some((symbol, color)) = right_indicator {
364 spans.push(Span::styled(symbol, Style::default().fg(color)));
365 }
366
367 if node.is_error() {
369 spans.push(Span::styled(
370 error_text,
371 Style::default().fg(theme.diagnostic_error_fg),
372 ));
373 }
374
375 let row_bg = if (is_selected || is_multi_selected) && is_focused {
376 theme.selection_bg
377 } else {
378 theme.editor_bg
379 };
380 ListItem::new(Line::from(spans)).style(Style::default().bg(row_bg))
381 }
382
383 fn decoration_symbol(symbol: &str) -> String {
384 symbol
385 .chars()
386 .next()
387 .map(|c| c.to_string())
388 .unwrap_or_else(|| " ".to_string())
389 }
390
391 fn decoration_color(
392 decoration: &crate::view::file_tree::FileExplorerDecoration,
393 theme: &Theme,
394 ) -> Color {
395 match &decoration.color {
396 fresh_core::api::OverlayColorSpec::Rgb(r, g, b) => Color::Rgb(*r, *g, *b),
397 fresh_core::api::OverlayColorSpec::ThemeKey(key) => {
398 theme.resolve_theme_key(key).unwrap_or(theme.editor_fg)
399 }
400 }
401 }
402
403 fn render_name_with_highlights(
405 name: &str,
406 match_positions: &[usize],
407 base_fg: Color,
408 theme: &Theme,
409 spans: &mut Vec<Span<'static>>,
410 ) {
411 if match_positions.is_empty() {
412 spans.push(Span::styled(name.to_string(), Style::default().fg(base_fg)));
413 return;
414 }
415
416 let chars: Vec<char> = name.chars().collect();
417 let match_set: std::collections::HashSet<usize> = match_positions.iter().copied().collect();
418
419 let base_style = Style::default().fg(base_fg);
420 let highlight_style = Style::default()
421 .fg(theme.search_match_fg)
422 .bg(theme.search_match_bg);
423
424 let mut current_span = String::new();
425 let mut current_is_match = false;
426
427 for (i, &c) in chars.iter().enumerate() {
428 let is_match = match_set.contains(&i);
429
430 if i == 0 {
431 current_is_match = is_match;
432 current_span.push(c);
433 } else if is_match == current_is_match {
434 current_span.push(c);
435 } else {
436 let style = if current_is_match {
438 highlight_style
439 } else {
440 base_style
441 };
442 spans.push(Span::styled(current_span.clone(), style));
443 current_span.clear();
444 current_span.push(c);
445 current_is_match = is_match;
446 }
447 }
448
449 if !current_span.is_empty() {
451 let style = if current_is_match {
452 highlight_style
453 } else {
454 base_style
455 };
456 spans.push(Span::styled(current_span, style));
457 }
458 }
459}