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 (title_style, border_style) = if is_focused {
128 (
129 Style::default()
130 .fg(theme.editor_bg)
131 .bg(theme.editor_fg)
132 .add_modifier(Modifier::BOLD),
133 Style::default().fg(theme.cursor),
134 )
135 } else {
136 (
137 Style::default().fg(theme.line_number_fg),
138 Style::default().fg(theme.split_separator_fg),
139 )
140 };
141
142 let list = List::new(items)
144 .block(
145 Block::default()
146 .borders(Borders::ALL)
147 .title(title)
148 .title_style(title_style)
149 .border_style(border_style)
150 .style(Style::default().bg(theme.editor_bg)),
151 )
152 .highlight_style(if is_focused {
153 Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
154 } else {
155 Style::default().bg(theme.current_line_bg)
156 });
157
158 let mut list_state = ListState::default();
161 if let Some(selected) = selected_index {
162 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
163 list_state.select(Some(selected - scroll_offset));
165 }
166 }
167
168 frame.render_stateful_widget(list, area, &mut list_state);
169
170 let close_button_x = area.x + area.width.saturating_sub(3);
172 let close_fg = if close_button_hovered {
173 theme.tab_close_hover_fg
174 } else {
175 theme.line_number_fg
176 };
177 let close_button =
178 ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
179 let close_area = Rect::new(close_button_x, area.y, 1, 1);
180 frame.render_widget(close_button, close_area);
181
182 if is_focused {
186 if let Some(selected) = selected_index {
187 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
188 let cursor_x = area.x + 1;
190 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
191
192 let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
194 .style(Style::default().fg(theme.cursor));
195 let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
196 frame.render_widget(cursor_indicator, cursor_area);
197
198 frame.set_cursor_position((cursor_x, cursor_y));
200 }
201 }
202 }
203 }
204
205 #[allow(clippy::too_many_arguments)]
207 fn render_node(
208 view: &FileTreeView,
209 node_id: NodeId,
210 indent: usize,
211 is_selected: bool,
212 is_focused: bool,
213 files_with_unsaved_changes: &HashSet<PathBuf>,
214 decorations: &FileExplorerDecorationCache,
215 theme: &Theme,
216 content_width: usize,
217 fuzzy_match: Option<&FuzzyMatch>,
218 ) -> ListItem<'static> {
219 let node = view.tree().get_node(node_id).expect("Node should exist");
220
221 let mut spans = Vec::new();
223
224 let indent_width = indent * 2;
226 let indicator_width = if node.is_dir() { 2 } else { 2 }; let name_width = str_width(&node.entry.name);
228 let left_side_width = indent_width + indicator_width + name_width;
229
230 if indent > 0 {
232 spans.push(Span::raw(" ".repeat(indent)));
233 }
234
235 if node.is_dir() {
237 let indicator = if node.is_expanded() {
238 "▼ "
239 } else if node.is_collapsed() {
240 "> "
241 } else if node.is_loading() {
242 "⟳ "
243 } else {
244 "! "
245 };
246 spans.push(Span::styled(
247 indicator,
248 Style::default().fg(theme.diagnostic_warning_fg),
249 ));
250 } else {
251 spans.push(Span::raw(" "));
253 }
254
255 let base_fg = if is_selected && is_focused {
257 theme.editor_fg
258 } else if node
259 .entry
260 .metadata
261 .as_ref()
262 .map(|m| m.is_hidden)
263 .unwrap_or(false)
264 {
265 theme.line_number_fg
266 } else if node.entry.is_symlink() {
267 theme.syntax_type
269 } else if node.is_dir() {
270 theme.syntax_keyword
271 } else {
272 theme.editor_fg
273 };
274
275 if let Some(fm) = fuzzy_match {
277 Self::render_name_with_highlights(
278 &node.entry.name,
279 &fm.match_positions,
280 base_fg,
281 theme,
282 &mut spans,
283 );
284 } else {
285 spans.push(Span::styled(
286 node.entry.name.clone(),
287 Style::default().fg(base_fg),
288 ));
289 }
290
291 let has_unsaved = if node.is_dir() {
294 Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
295 } else {
296 files_with_unsaved_changes.contains(&node.entry.path)
297 };
298
299 let direct_decoration = decorations.direct_for_path(&node.entry.path);
300 let bubbled_decoration = if node.is_dir() {
301 decorations
302 .bubbled_for_path(&node.entry.path)
303 .filter(|_| direct_decoration.is_none())
304 } else {
305 None
306 };
307
308 let right_indicator: Option<(String, Color)> = if has_unsaved {
309 Some(("●".to_string(), theme.diagnostic_warning_fg))
310 } else if let Some(decoration) = direct_decoration {
311 let symbol = Self::decoration_symbol(&decoration.symbol);
312 Some((symbol, Self::decoration_color(decoration)))
313 } else {
314 bubbled_decoration
315 .map(|decoration| ("●".to_string(), Self::decoration_color(decoration)))
316 };
317
318 let right_indicator_width = right_indicator
320 .as_ref()
321 .map(|(s, _)| str_width(s))
322 .unwrap_or(0);
323
324 let error_text = if node.is_error() { " [Error]" } else { "" };
326 let error_width = str_width(error_text);
327
328 let total_right_width = right_indicator_width + error_width;
329
330 let min_gap = 1;
332 let padding = if left_side_width + min_gap + total_right_width < content_width {
333 content_width - left_side_width - total_right_width
334 } else {
335 min_gap
336 };
337
338 spans.push(Span::raw(" ".repeat(padding)));
339
340 if let Some((symbol, color)) = right_indicator {
342 spans.push(Span::styled(symbol, Style::default().fg(color)));
343 }
344
345 if node.is_error() {
347 spans.push(Span::styled(
348 error_text,
349 Style::default().fg(theme.diagnostic_error_fg),
350 ));
351 }
352
353 ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
354 }
355
356 fn decoration_symbol(symbol: &str) -> String {
357 symbol
358 .chars()
359 .next()
360 .map(|c| c.to_string())
361 .unwrap_or_else(|| " ".to_string())
362 }
363
364 fn decoration_color(decoration: &crate::view::file_tree::FileExplorerDecoration) -> Color {
365 let [r, g, b] = decoration.color;
366 Color::Rgb(r, g, b)
367 }
368
369 fn render_name_with_highlights(
371 name: &str,
372 match_positions: &[usize],
373 base_fg: Color,
374 theme: &Theme,
375 spans: &mut Vec<Span<'static>>,
376 ) {
377 if match_positions.is_empty() {
378 spans.push(Span::styled(name.to_string(), Style::default().fg(base_fg)));
379 return;
380 }
381
382 let chars: Vec<char> = name.chars().collect();
383 let match_set: std::collections::HashSet<usize> = match_positions.iter().copied().collect();
384
385 let base_style = Style::default().fg(base_fg);
386 let highlight_style = Style::default()
387 .fg(theme.search_match_fg)
388 .bg(theme.search_match_bg);
389
390 let mut current_span = String::new();
391 let mut current_is_match = false;
392
393 for (i, &c) in chars.iter().enumerate() {
394 let is_match = match_set.contains(&i);
395
396 if i == 0 {
397 current_is_match = is_match;
398 current_span.push(c);
399 } else if is_match == current_is_match {
400 current_span.push(c);
401 } else {
402 let style = if current_is_match {
404 highlight_style
405 } else {
406 base_style
407 };
408 spans.push(Span::styled(current_span.clone(), style));
409 current_span.clear();
410 current_span.push(c);
411 current_is_match = is_match;
412 }
413 }
414
415 if !current_span.is_empty() {
417 let style = if current_is_match {
418 highlight_style
419 } else {
420 base_style
421 };
422 spans.push(Span::styled(current_span, style));
423 }
424 }
425}