fresh/view/ui/
file_explorer.rs1use 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 ) {
45 let viewport_height = area.height.saturating_sub(2) as usize;
48 view.set_viewport_height(viewport_height);
49
50 let display_nodes = view.get_display_nodes();
51 let scroll_offset = view.get_scroll_offset();
52 let selected_index = view.get_selected_index();
53
54 let scroll_offset = scroll_offset.min(display_nodes.len());
58
59 let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
62 let visible_items = &display_nodes[scroll_offset..visible_end];
63
64 let content_width = area.width.saturating_sub(3) as usize;
66
67 let items: Vec<ListItem> = visible_items
69 .iter()
70 .enumerate()
71 .map(|(viewport_idx, &(node_id, indent))| {
72 let actual_idx = scroll_offset + viewport_idx;
74 let is_selected = selected_index == Some(actual_idx);
75 Self::render_node(
76 view,
77 node_id,
78 indent,
79 is_selected,
80 is_focused,
81 files_with_unsaved_changes,
82 decorations,
83 theme,
84 content_width,
85 )
86 })
87 .collect();
88
89 let title = if let Some(keybinding) = keybinding_resolver.get_keybinding_for_action(
91 &crate::input::keybindings::Action::FocusFileExplorer,
92 current_context,
93 ) {
94 format!(" File Explorer ({}) ", keybinding)
95 } else {
96 " File Explorer ".to_string()
97 };
98
99 let (title_style, border_style) = if is_focused {
101 (
102 Style::default()
103 .fg(theme.editor_bg)
104 .bg(theme.editor_fg)
105 .add_modifier(Modifier::BOLD),
106 Style::default().fg(theme.cursor),
107 )
108 } else {
109 (
110 Style::default().fg(theme.line_number_fg),
111 Style::default().fg(theme.split_separator_fg),
112 )
113 };
114
115 let list = List::new(items)
117 .block(
118 Block::default()
119 .borders(Borders::ALL)
120 .title(title)
121 .title_style(title_style)
122 .border_style(border_style)
123 .style(Style::default().bg(theme.editor_bg)),
124 )
125 .highlight_style(if is_focused {
126 Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
127 } else {
128 Style::default().bg(theme.current_line_bg)
129 });
130
131 let mut list_state = ListState::default();
134 if let Some(selected) = selected_index {
135 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
136 list_state.select(Some(selected - scroll_offset));
138 }
139 }
140
141 frame.render_stateful_widget(list, area, &mut list_state);
142
143 let close_button_x = area.x + area.width.saturating_sub(3);
145 let close_fg = if close_button_hovered {
146 theme.tab_close_hover_fg
147 } else {
148 theme.line_number_fg
149 };
150 let close_button =
151 ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
152 let close_area = Rect::new(close_button_x, area.y, 1, 1);
153 frame.render_widget(close_button, close_area);
154
155 if is_focused {
159 if let Some(selected) = selected_index {
160 if selected >= scroll_offset && selected < scroll_offset + viewport_height {
161 let cursor_x = area.x + 1;
163 let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
164
165 let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
167 .style(Style::default().fg(theme.cursor));
168 let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
169 frame.render_widget(cursor_indicator, cursor_area);
170
171 frame.set_cursor_position((cursor_x, cursor_y));
173 }
174 }
175 }
176 }
177
178 #[allow(clippy::too_many_arguments)]
180 fn render_node(
181 view: &FileTreeView,
182 node_id: NodeId,
183 indent: usize,
184 is_selected: bool,
185 is_focused: bool,
186 files_with_unsaved_changes: &HashSet<PathBuf>,
187 decorations: &FileExplorerDecorationCache,
188 theme: &Theme,
189 content_width: usize,
190 ) -> ListItem<'static> {
191 let node = view.tree().get_node(node_id).expect("Node should exist");
192
193 let mut spans = Vec::new();
195
196 let indent_width = indent * 2;
198 let indicator_width = if node.is_dir() { 2 } else { 2 }; let name_width = str_width(&node.entry.name);
200 let left_side_width = indent_width + indicator_width + name_width;
201
202 if indent > 0 {
204 spans.push(Span::raw(" ".repeat(indent)));
205 }
206
207 if node.is_dir() {
209 let indicator = if node.is_expanded() {
210 "▼ "
211 } else if node.is_collapsed() {
212 "> "
213 } else if node.is_loading() {
214 "⟳ "
215 } else {
216 "! "
217 };
218 spans.push(Span::styled(
219 indicator,
220 Style::default().fg(theme.diagnostic_warning_fg),
221 ));
222 } else {
223 spans.push(Span::raw(" "));
225 }
226
227 let name_style = if is_selected && is_focused {
229 Style::default().fg(theme.editor_fg)
230 } else if node
231 .entry
232 .metadata
233 .as_ref()
234 .map(|m| m.is_hidden)
235 .unwrap_or(false)
236 {
237 Style::default().fg(theme.line_number_fg)
238 } else if node.entry.is_symlink() {
239 Style::default().fg(theme.syntax_type)
241 } else if node.is_dir() {
242 Style::default().fg(theme.syntax_keyword)
243 } else {
244 Style::default().fg(theme.editor_fg)
245 };
246
247 spans.push(Span::styled(node.entry.name.clone(), name_style));
248
249 let has_unsaved = if node.is_dir() {
252 Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes)
253 } else {
254 files_with_unsaved_changes.contains(&node.entry.path)
255 };
256
257 let direct_decoration = decorations.direct_for_path(&node.entry.path);
258 let bubbled_decoration = if node.is_dir() {
259 decorations
260 .bubbled_for_path(&node.entry.path)
261 .filter(|_| direct_decoration.is_none())
262 } else {
263 None
264 };
265
266 let right_indicator: Option<(String, Color)> = if has_unsaved {
267 Some(("●".to_string(), theme.diagnostic_warning_fg))
268 } else if let Some(decoration) = direct_decoration {
269 let symbol = Self::decoration_symbol(&decoration.symbol);
270 Some((symbol, Self::decoration_color(decoration)))
271 } else {
272 bubbled_decoration
273 .map(|decoration| ("●".to_string(), Self::decoration_color(decoration)))
274 };
275
276 let right_indicator_width = right_indicator
278 .as_ref()
279 .map(|(s, _)| str_width(s))
280 .unwrap_or(0);
281
282 let error_text = if node.is_error() { " [Error]" } else { "" };
284 let error_width = str_width(error_text);
285
286 let total_right_width = right_indicator_width + error_width;
287
288 let min_gap = 1;
290 let padding = if left_side_width + min_gap + total_right_width < content_width {
291 content_width - left_side_width - total_right_width
292 } else {
293 min_gap
294 };
295
296 spans.push(Span::raw(" ".repeat(padding)));
297
298 if let Some((symbol, color)) = right_indicator {
300 spans.push(Span::styled(symbol, Style::default().fg(color)));
301 }
302
303 if node.is_error() {
305 spans.push(Span::styled(
306 error_text,
307 Style::default().fg(theme.diagnostic_error_fg),
308 ));
309 }
310
311 ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
312 }
313
314 fn decoration_symbol(symbol: &str) -> String {
315 symbol
316 .chars()
317 .next()
318 .map(|c| c.to_string())
319 .unwrap_or_else(|| " ".to_string())
320 }
321
322 fn decoration_color(decoration: &crate::view::file_tree::FileExplorerDecoration) -> Color {
323 let [r, g, b] = decoration.color;
324 Color::Rgb(r, g, b)
325 }
326}