use crate::primitives::display_width::str_width;
use crate::view::file_tree::{FileTreeView, NodeId};
use crate::view::theme::Theme;
use ratatui::{
layout::Rect,
style::{Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, List, ListItem, ListState},
Frame,
};
use std::collections::HashSet;
use std::path::PathBuf;
pub struct FileExplorerRenderer;
impl FileExplorerRenderer {
fn folder_has_modified_files(
folder_path: &PathBuf,
files_with_unsaved_changes: &HashSet<PathBuf>,
) -> bool {
for modified_file in files_with_unsaved_changes {
if modified_file.starts_with(folder_path) {
return true;
}
}
false
}
pub fn render(
view: &mut FileTreeView,
frame: &mut Frame,
area: Rect,
is_focused: bool,
files_with_unsaved_changes: &HashSet<PathBuf>,
keybinding_resolver: &crate::input::keybindings::KeybindingResolver,
current_context: crate::input::keybindings::KeyContext,
theme: &Theme,
close_button_hovered: bool,
) {
let viewport_height = area.height.saturating_sub(2) as usize;
view.set_viewport_height(viewport_height);
let display_nodes = view.get_display_nodes();
let scroll_offset = view.get_scroll_offset();
let selected_index = view.get_selected_index();
let scroll_offset = scroll_offset.min(display_nodes.len());
let visible_end = (scroll_offset + viewport_height).min(display_nodes.len());
let visible_items = &display_nodes[scroll_offset..visible_end];
let content_width = area.width.saturating_sub(3) as usize;
let items: Vec<ListItem> = visible_items
.iter()
.enumerate()
.map(|(viewport_idx, &(node_id, indent))| {
let actual_idx = scroll_offset + viewport_idx;
let is_selected = selected_index == Some(actual_idx);
Self::render_node(
view,
node_id,
indent,
is_selected,
is_focused,
files_with_unsaved_changes,
theme,
content_width,
)
})
.collect();
let title = if let Some(keybinding) = keybinding_resolver.get_keybinding_for_action(
&crate::input::keybindings::Action::FocusFileExplorer,
current_context,
) {
format!(" File Explorer ({}) ", keybinding)
} else {
" File Explorer ".to_string()
};
let (title_style, border_style) = if is_focused {
(
Style::default()
.fg(theme.editor_bg)
.bg(theme.editor_fg)
.add_modifier(Modifier::BOLD),
Style::default().fg(theme.cursor),
)
} else {
(
Style::default().fg(theme.line_number_fg),
Style::default().fg(theme.split_separator_fg),
)
};
let list = List::new(items)
.block(
Block::default()
.borders(Borders::ALL)
.title(title)
.title_style(title_style)
.border_style(border_style)
.style(Style::default().bg(theme.editor_bg)),
)
.highlight_style(if is_focused {
Style::default().bg(theme.selection_bg).fg(theme.editor_fg)
} else {
Style::default().bg(theme.current_line_bg)
});
let mut list_state = ListState::default();
if let Some(selected) = selected_index {
if selected >= scroll_offset && selected < scroll_offset + viewport_height {
list_state.select(Some(selected - scroll_offset));
}
}
frame.render_stateful_widget(list, area, &mut list_state);
let close_button_x = area.x + area.width.saturating_sub(3);
let close_fg = if close_button_hovered {
theme.tab_close_hover_fg
} else {
theme.line_number_fg
};
let close_button =
ratatui::widgets::Paragraph::new("×").style(Style::default().fg(close_fg));
let close_area = Rect::new(close_button_x, area.y, 1, 1);
frame.render_widget(close_button, close_area);
if is_focused {
if let Some(selected) = selected_index {
if selected >= scroll_offset && selected < scroll_offset + viewport_height {
let cursor_x = area.x + 1;
let cursor_y = area.y + 1 + (selected - scroll_offset) as u16;
let cursor_indicator = ratatui::widgets::Paragraph::new("▌")
.style(Style::default().fg(theme.cursor));
let cursor_area = ratatui::layout::Rect::new(cursor_x, cursor_y, 1, 1);
frame.render_widget(cursor_indicator, cursor_area);
frame.set_cursor_position((cursor_x, cursor_y));
}
}
}
}
fn render_node(
view: &FileTreeView,
node_id: NodeId,
indent: usize,
is_selected: bool,
is_focused: bool,
files_with_unsaved_changes: &HashSet<PathBuf>,
theme: &Theme,
content_width: usize,
) -> ListItem<'static> {
let node = view.tree().get_node(node_id).expect("Node should exist");
let mut spans = Vec::new();
let indent_width = indent * 2;
let indicator_width = 2; let name_width = str_width(&node.entry.name);
let left_side_width = indent_width + indicator_width + name_width;
if indent > 0 {
spans.push(Span::raw(" ".repeat(indent)));
}
if node.is_dir() {
let has_modified =
Self::folder_has_modified_files(&node.entry.path, files_with_unsaved_changes);
let indicator = if node.is_expanded() {
"▼"
} else if node.is_collapsed() {
">"
} else if node.is_loading() {
"⟳"
} else {
"!"
};
spans.push(Span::styled(
indicator,
Style::default().fg(theme.diagnostic_warning_fg),
));
if has_modified {
spans.push(Span::styled(
"●",
Style::default().fg(theme.diagnostic_warning_fg),
));
} else {
spans.push(Span::raw(" "));
}
} else {
if files_with_unsaved_changes.contains(&node.entry.path) {
spans.push(Span::styled(
"● ",
Style::default().fg(theme.diagnostic_warning_fg),
));
} else {
spans.push(Span::raw(" "));
}
}
let name_style = if is_selected && is_focused {
Style::default().fg(theme.editor_fg)
} else if node
.entry
.metadata
.as_ref()
.map(|m| m.is_hidden)
.unwrap_or(false)
{
Style::default().fg(theme.line_number_fg)
} else if node.is_dir() {
Style::default().fg(theme.syntax_keyword)
} else {
Style::default().fg(theme.editor_fg)
};
spans.push(Span::styled(node.entry.name.clone(), name_style));
let size_str = if node.is_file() {
node.entry
.metadata
.as_ref()
.and_then(|m| m.size)
.map(|size| Self::format_size(size))
} else if node.is_expanded() {
let count = node.children.len();
Some(if count == 1 {
"1 item".to_string()
} else {
format!("{} items", count)
})
} else {
None
};
if let Some(size_text) = size_str {
let size_display_width = str_width(&size_text);
let min_gap = 1;
let padding = if left_side_width + min_gap + size_display_width < content_width {
content_width - left_side_width - size_display_width
} else {
min_gap
};
spans.push(Span::raw(" ".repeat(padding)));
spans.push(Span::styled(
size_text,
Style::default().fg(theme.line_number_fg),
));
}
if node.is_error() {
spans.push(Span::styled(
" [Error]",
Style::default().fg(theme.diagnostic_error_fg),
));
}
ListItem::new(Line::from(spans)).style(Style::default().bg(theme.editor_bg))
}
fn format_size(size: u64) -> String {
const KB: f64 = 1024.0;
const MB: f64 = KB * 1024.0;
const GB: f64 = MB * 1024.0;
let size_f = size as f64;
if size_f >= GB {
format!("{:.1} GB", size_f / GB)
} else if size_f >= MB {
format!("{:.1} MB", size_f / MB)
} else {
format!("{:.1} KB", size_f / KB)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_format_size() {
assert_eq!(FileExplorerRenderer::format_size(0), "0.0 KB");
assert_eq!(FileExplorerRenderer::format_size(300), "0.3 KB");
assert_eq!(FileExplorerRenderer::format_size(500), "0.5 KB");
assert_eq!(FileExplorerRenderer::format_size(1024), "1.0 KB");
assert_eq!(FileExplorerRenderer::format_size(1536), "1.5 KB");
assert_eq!(FileExplorerRenderer::format_size(10240), "10.0 KB");
assert_eq!(FileExplorerRenderer::format_size(1024 * 1024), "1.0 MB");
assert_eq!(FileExplorerRenderer::format_size(1536 * 1024), "1.5 MB");
assert_eq!(
FileExplorerRenderer::format_size(1024 * 1024 * 1024),
"1.0 GB"
);
}
}