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