1use crate::primitives::display_width::str_width;
2use crate::view::file_tree::{FileExplorerDecorationCache, FileTreeView, NodeId};
3use crate::view::theme::Theme;
4use ratatui::{
5 layout::Rect,
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, Borders, List, ListItem, ListState},
9 Frame,
10};
11
12use std::collections::HashSet;
13use std::path::PathBuf;
14
15pub struct FileExplorerRenderer;
16
17impl FileExplorerRenderer {
18 fn folder_has_modified_files(
20 folder_path: &PathBuf,
21 files_with_unsaved_changes: &HashSet<PathBuf>,
22 ) -> bool {
23 for modified_file in files_with_unsaved_changes {
24 if modified_file.starts_with(folder_path) {
25 return true;
26 }
27 }
28 false
29 }
30
31 #[allow(clippy::too_many_arguments)]
33 pub fn render(
34 view: &mut FileTreeView,
35 frame: &mut Frame,
36 area: Rect,
37 is_focused: bool,
38 files_with_unsaved_changes: &HashSet<PathBuf>,
39 decorations: &FileExplorerDecorationCache,
40 keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
41 current_context: crate::input::keybindings::KeyContext,
42 theme: &Theme,
43 close_button_hovered: bool,
44 remote_connection: Option<&str>,
45 ) {
46 let viewport_height = area.height.saturating_sub(2) as usize;
49 view.set_viewport_height(viewport_height);
50
51 let display_nodes = view.get_display_nodes();
52 let scroll_offset = view.get_scroll_offset();
53 let selected_index = view.get_selected_index();
54
55 let scroll_offset = scroll_offset.min(display_nodes.len());
59
60 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
63 let visible_items = &display_nodes[scroll_offset..visible_end];
64
65 let content_width = area.width.saturating_sub(3) as usize;
67
68 let items: Vec<ListItem> = visible_items
70 .iter()
71 .enumerate()
72 .map(|(viewport_idx, &(node_id, indent))| {
73 let actual_idx = scroll_offset + viewport_idx;
75 let is_selected = selected_index == Some(actual_idx);
76 Self::render_node(
77 view,
78 node_id,
79 indent,
80 is_selected,
81 is_focused,
82 files_with_unsaved_changes,
83 decorations,
84 theme,
85 content_width,
86 )
87 })
88 .collect();
89
90 let keybinding_suffix = keybinding_resolver
92 .get_keybinding_for_action(
93 &crate::input::keybindings::Action::FocusFileExplorer,
94 current_context,
95 )
96 .map(|kb| format!(" ({})", kb))
97 .unwrap_or_default();
98 let title = if let Some(host) = remote_connection {
99 let hostname = host
101 .split('@')
102 .last()
103 .unwrap_or(host)
104 .split(':')
105 .next()
106 .unwrap_or(host);
107 format!(" [{}]{} ", hostname, keybinding_suffix)
108 } else {
109 format!(" File Explorer{} ", keybinding_suffix)
110 };
111
112 let (title_style, border_style) = if is_focused {
114 (
115 Style::default()
116 .fg(theme.editor_bg)
117 .bg(theme.editor_fg)
118 .add_modifier(Modifier::BOLD),
119 Style::default().fg(theme.cursor),
120 )
121 } else {
122 (
123 Style::default().fg(theme.line_number_fg),
124 Style::default().fg(theme.split_separator_fg),
125 )
126 };
127
128 let list = List::new(items)
130 .block(
131 Block::default()
132 .borders(Borders::ALL)
133 .title(title)
134 .title_style(title_style)
135 .border_style(border_style)
136 .style(Style::default().bg(theme.editor_bg)),
137 )
138 .highlight_style(if is_focused {
139 Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
140 } else {
141 Style::default().bg(theme.current_line_bg)
142 });
143
144 let mut list_state = ListState::default();
147 if let Some(selected) = selected_index {
148 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
149 list_state.select(Some(selected - scroll_offset));
151 }
152 }
153
154 frame.render_stateful_widget(list, area, &mut list_state);
155
156 let close_button_x = area.x + area.width.saturating_sub(3);
158 let close_fg = if close_button_hovered {
159 theme.tab_close_hover_fg
160 } else {
161 theme.line_number_fg
162 };
163 let close_button =
164 ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
165 let close_area = Rect::new(close_button_x, area.y, 1, 1);
166 frame.render_widget(close_button, close_area);
167
168 if is_focused {
172 if let Some(selected) = selected_index {
173 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
174 let cursor_x = area.x + 1;
176 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
177
178 let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
180 .style(Style::default().fg(theme.cursor));
181 let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
182 frame.render_widget(cursor_indicator, cursor_area);
183
184 frame.set_cursor_position((cursor_x, cursor_y));
186 }
187 }
188 }
189 }
190
191 #[allow(clippy::too_many_arguments)]
193 fn render_node(
194 view: &FileTreeView,
195 node_id: NodeId,
196 indent: usize,
197 is_selected: bool,
198 is_focused: bool,
199 files_with_unsaved_changes: &HashSet<PathBuf>,
200 decorations: &FileExplorerDecorationCache,
201 theme: &Theme,
202 content_width: usize,
203 ) -> ListItem<'static> {
204 let node = view.tree().get_node(node_id).expect("Node should exist");
205
206 let mut spans = Vec::new();
208
209 let indent_width = indent * 2;
211 let indicator_width = if node.is_dir() { 2 } else { 2 }; let name_width = str_width(&node.entry.name);
213 let left_side_width = indent_width + indicator_width + name_width;
214
215 if indent > 0 {
217 spans.push(Span::raw(" ".repeat(indent)));
218 }
219
220 if node.is_dir() {
222 let indicator = if node.is_expanded() {
223 "▼ "
224 } else if node.is_collapsed() {
225 "> "
226 } else if node.is_loading() {
227 "⟳ "
228 } else {
229 "! "
230 };
231 spans.push(Span::styled(
232 indicator,
233 Style::default().fg(theme.diagnostic_warning_fg),
234 ));
235 } else {
236 spans.push(Span::raw(" "));
238 }
239
240 let name_style = if is_selected && is_focused {
242 Style::default().fg(theme.editor_fg)
243 } else if node
244 .entry
245 .metadata
246 .as_ref()
247 .map(|m| m.is_hidden)
248 .unwrap_or(false)
249 {
250 Style::default().fg(theme.line_number_fg)
251 } else if node.entry.is_symlink() {
252 Style::default().fg(theme.syntax_type)
254 } else if node.is_dir() {
255 Style::default().fg(theme.syntax_keyword)
256 } else {
257 Style::default().fg(theme.editor_fg)
258 };
259
260 spans.push(Span::styled(node.entry.name.clone(), name_style));
261
262 let has_unsaved = if node.is_dir() {
265 Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
266 } else {
267 files_with_unsaved_changes.contains(&node.entry.path)
268 };
269
270 let direct_decoration = decorations.direct_for_path(&node.entry.path);
271 let bubbled_decoration = if node.is_dir() {
272 decorations
273 .bubbled_for_path(&node.entry.path)
274 .filter(|_| direct_decoration.is_none())
275 } else {
276 None
277 };
278
279 let right_indicator: Option<(String, Color)> = if has_unsaved {
280 Some(("●".to_string(), theme.diagnostic_warning_fg))
281 } else if let Some(decoration) = direct_decoration {
282 let symbol = Self::decoration_symbol(&decoration.symbol);
283 Some((symbol, Self::decoration_color(decoration)))
284 } else {
285 bubbled_decoration
286 .map(|decoration| ("●".to_string(), Self::decoration_color(decoration)))
287 };
288
289 let right_indicator_width = right_indicator
291 .as_ref()
292 .map(|(s, _)| str_width(s))
293 .unwrap_or(0);
294
295 let error_text = if node.is_error() { " [Error]" } else { "" };
297 let error_width = str_width(error_text);
298
299 let total_right_width = right_indicator_width + error_width;
300
301 let min_gap = 1;
303 let padding = if left_side_width + min_gap + total_right_width < content_width {
304 content_width - left_side_width - total_right_width
305 } else {
306 min_gap
307 };
308
309 spans.push(Span::raw(" ".repeat(padding)));
310
311 if let Some((symbol, color)) = right_indicator {
313 spans.push(Span::styled(symbol, Style::default().fg(color)));
314 }
315
316 if node.is_error() {
318 spans.push(Span::styled(
319 error_text,
320 Style::default().fg(theme.diagnostic_error_fg),
321 ));
322 }
323
324 ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
325 }
326
327 fn decoration_symbol(symbol: &str) -> String {
328 symbol
329 .chars()
330 .next()
331 .map(|c| c.to_string())
332 .unwrap_or_else(|| " ".to_string())
333 }
334
335 fn decoration_color(decoration: &crate::view::file_tree::FileExplorerDecoration) -> Color {
336 let [r, g, b] = decoration.color;
337 Color::Rgb(r, g, b)
338 }
339}