use std::collections::HashSet;
use std::path::{Path, PathBuf};
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, StatefulWidget, Widget};
use gravityfile_core::{FileNode, GitStatus, NodeKind};
use crate::app::state::{ClipboardMode, ClipboardState};
use crate::theme::Theme;
use crate::ui::{CompactSizeBar, format_size, truncate_to_width};
#[derive(Debug, Default, Clone)]
pub struct TreeState {
pub selected: usize,
pub offset: usize,
pub expanded: HashSet<PathBuf>,
#[allow(dead_code)]
root_path: PathBuf,
}
impl TreeState {
pub fn new(root_path: PathBuf) -> Self {
let mut expanded = HashSet::new();
expanded.insert(root_path.clone());
Self {
selected: 0,
offset: 0,
expanded,
root_path,
}
}
pub fn toggle_expand(&mut self, path: &Path) {
if self.expanded.contains(path) {
self.expanded.remove(path);
} else {
self.expanded.insert(path.to_path_buf());
}
}
pub fn expand(&mut self, path: &Path) {
self.expanded.insert(path.to_path_buf());
}
pub fn collapse(&mut self, path: &Path) {
self.expanded.remove(path);
}
pub fn is_expanded(&self, path: &PathBuf) -> bool {
self.expanded.contains(path)
}
pub fn move_up(&mut self, count: usize) {
self.selected = self.selected.saturating_sub(count);
}
pub fn move_down(&mut self, count: usize, max: usize) {
self.selected = (self.selected + count).min(max.saturating_sub(1));
}
pub fn jump_to_top(&mut self) {
self.selected = 0;
}
pub fn jump_to_bottom(&mut self, max: usize) {
self.selected = max.saturating_sub(1);
}
pub fn ensure_visible(&mut self, viewport_height: usize) {
if self.selected < self.offset {
self.offset = self.selected;
} else if self.selected >= self.offset + viewport_height {
self.offset = self.selected - viewport_height + 1;
}
}
}
#[derive(Debug, Clone)]
pub struct VisibleItem {
pub path: PathBuf,
pub node: VisibleNode,
pub depth: usize,
pub is_last_sibling: bool,
pub parent_last_siblings: Vec<bool>,
}
#[derive(Debug, Clone)]
pub struct VisibleNode {
pub name: String,
pub size: u64,
pub kind: VisibleNodeKind,
pub git_status: Option<GitStatus>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum VisibleNodeKind {
Directory { expanded: bool },
File { executable: bool },
Symlink { broken: bool },
Other,
}
pub struct TreeView<'a> {
root: &'a FileNode,
root_path: &'a PathBuf,
total_size: u64,
theme: &'a Theme,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
block: Option<Block<'a>>,
}
impl<'a> TreeView<'a> {
pub fn new(
root: &'a FileNode,
root_path: &'a PathBuf,
theme: &'a Theme,
marked: &'a HashSet<PathBuf>,
clipboard: &'a ClipboardState,
) -> Self {
Self {
root,
root_path,
total_size: root.size,
theme,
marked,
clipboard,
block: None,
}
}
pub fn block(mut self, block: Block<'a>) -> Self {
self.block = Some(block);
self
}
pub fn flatten(&self, state: &TreeState) -> Vec<VisibleItem> {
let mut items = Vec::new();
self.flatten_node(
self.root,
self.root_path.clone(),
0,
true,
Vec::new(),
state,
&mut items,
);
items
}
pub fn flatten_count(&self, state: &TreeState) -> usize {
self.count_node(self.root, &self.root_path.clone(), state)
}
fn count_node(&self, node: &FileNode, path: &PathBuf, state: &TreeState) -> usize {
let is_expanded = state.is_expanded(path);
let mut count = 1; if is_expanded && node.is_dir() {
for child in &node.children {
let child_path = path.join(&*child.name);
count += self.count_node(child, &child_path, state);
}
}
count
}
#[allow(clippy::too_many_arguments)]
fn flatten_node(
&self,
node: &FileNode,
path: PathBuf,
depth: usize,
is_last: bool,
parent_last_siblings: Vec<bool>,
state: &TreeState,
items: &mut Vec<VisibleItem>,
) {
let is_expanded = state.is_expanded(&path);
let kind = match &node.kind {
NodeKind::Directory { .. } => VisibleNodeKind::Directory {
expanded: is_expanded,
},
NodeKind::File { executable } => VisibleNodeKind::File {
executable: *executable,
},
NodeKind::Symlink { broken, .. } => VisibleNodeKind::Symlink { broken: *broken },
NodeKind::Other => VisibleNodeKind::Other,
};
items.push(VisibleItem {
path: path.clone(),
node: VisibleNode {
name: node.name.to_string(),
size: node.size,
kind,
git_status: node.git_status,
},
depth,
is_last_sibling: is_last,
parent_last_siblings: parent_last_siblings.clone(),
});
if is_expanded && node.is_dir() {
let child_count = node.children.len();
for (i, child) in node.children.iter().enumerate() {
let child_path = path.join(&*child.name);
let child_is_last = i == child_count - 1;
let mut child_parent_lasts = parent_last_siblings.clone();
child_parent_lasts.push(is_last);
self.flatten_node(
child,
child_path,
depth + 1,
child_is_last,
child_parent_lasts,
state,
items,
);
}
}
}
}
impl StatefulWidget for TreeView<'_> {
type State = TreeState;
fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
let inner_area = if let Some(block) = &self.block {
let inner = block.inner(area);
block.clone().render(area, buf);
inner
} else {
area
};
if inner_area.height == 0 || inner_area.width == 0 {
return;
}
let items = self.flatten(state);
let viewport_height = inner_area.height as usize;
state.ensure_visible(viewport_height);
let start = state.offset;
let end = (start + viewport_height).min(items.len());
let size_bar_width: u16 = 10;
let size_text_width: u16 = 10;
for (row_idx, item_idx) in (start..end).enumerate() {
let item = &items[item_idx];
let y = inner_area.y + row_idx as u16;
let is_selected = item_idx == state.selected;
let is_marked = self.marked.contains(&item.path);
let in_clipboard = self.clipboard.paths.contains(&item.path);
let clipboard_mode = if in_clipboard {
self.clipboard.mode
} else {
ClipboardMode::Empty
};
let mut prefix = String::new();
for &parent_is_last in &item.parent_last_siblings {
prefix.push_str(if parent_is_last { " " } else { "│ " });
}
if item.depth > 0 {
prefix.push_str(if item.is_last_sibling {
"└─"
} else {
"├─"
});
}
let (checkbox, checkbox_style) = if is_marked {
("● ", Style::default().fg(self.theme.info))
} else if clipboard_mode == ClipboardMode::Cut {
("✂ ", Style::default().fg(self.theme.warning))
} else if clipboard_mode == ClipboardMode::Copy {
("⎘ ", Style::default().fg(self.theme.success))
} else {
(" ", Style::default().fg(self.theme.muted))
};
let checkbox_width: u16 = 2;
let expand_indicator = match item.node.kind {
VisibleNodeKind::Directory { expanded } => {
if expanded {
"▼ "
} else {
"▶ "
}
}
_ => " ",
};
let base_style = match item.node.kind {
VisibleNodeKind::Directory { .. } => self.theme.directory,
VisibleNodeKind::File { executable: true } => self.theme.executable,
VisibleNodeKind::File { executable: false } => self.theme.file,
VisibleNodeKind::Symlink { broken: true } => {
self.theme.symlink.add_modifier(Modifier::DIM)
}
VisibleNodeKind::Symlink { broken: false } => self.theme.symlink,
VisibleNodeKind::Other => self.theme.muted.into(),
};
let base_style = if clipboard_mode == ClipboardMode::Cut {
base_style.add_modifier(Modifier::DIM)
} else if clipboard_mode == ClipboardMode::Copy {
base_style.add_modifier(Modifier::ITALIC)
} else {
base_style
};
let prefix_width = prefix.len() + checkbox_width as usize + expand_indicator.len();
let available_for_name = inner_area
.width
.saturating_sub(prefix_width as u16)
.saturating_sub(size_bar_width + 1)
.saturating_sub(size_text_width + 1);
let name = {
use unicode_width::UnicodeWidthStr;
let max = available_for_name as usize;
if item.node.name.width() > max {
let truncated = truncate_to_width(&item.node.name, max.saturating_sub(1));
format!("{}…", truncated)
} else {
item.node.name.clone()
}
};
let prefix_span = Span::styled(&prefix, self.theme.tree_lines);
let checkbox_span = Span::styled(checkbox, checkbox_style);
let expand_span = Span::styled(expand_indicator, Style::default().fg(self.theme.muted));
let name_span = Span::styled(&name, base_style);
let (git_indicator, git_style) = match item.node.git_status {
Some(status) if status.is_displayable() => {
let color = self.theme.git_status_color(status);
(
format!(" {}", status.indicator()),
Style::default().fg(color),
)
}
_ => (String::new(), Style::default()),
};
let git_span = Span::styled(&git_indicator, git_style);
let git_display_width = {
use unicode_width::UnicodeWidthStr;
git_indicator.width() as u16
};
let name_display_width = {
use unicode_width::UnicodeWidthStr;
name.width() as u16
};
let name_padding = " ".repeat(
available_for_name
.saturating_sub(name_display_width)
.saturating_sub(git_display_width) as usize,
);
let padding_span = Span::raw(&name_padding);
let size_text = if matches!(item.node.kind, VisibleNodeKind::Directory { .. })
&& item.node.size == 0
{
format!("{:>10}", "...")
} else {
format!("{:>10}", format_size(item.node.size))
};
let size_span = Span::styled(&size_text, Style::default().fg(self.theme.muted));
let line = Line::from(vec![
prefix_span,
checkbox_span,
expand_span,
name_span,
git_span,
padding_span,
Span::raw(" "),
size_span,
]);
let line = if is_selected {
line.style(self.theme.selected)
} else if is_marked {
line.style(Style::default().add_modifier(Modifier::BOLD))
} else {
line
};
let line_area = Rect::new(
inner_area.x,
y,
inner_area.width.saturating_sub(size_bar_width + 1),
1,
);
Widget::render(line, line_area, buf);
let ratio = if self.total_size > 0 {
item.node.size as f64 / self.total_size as f64
} else {
0.0
};
let bar_area = Rect::new(
inner_area.x + inner_area.width - size_bar_width,
y,
size_bar_width,
1,
);
let bar = CompactSizeBar::new(ratio).style(self.theme.size_bar_style(ratio));
if is_selected {
Widget::render(bar, bar_area, buf);
} else {
Widget::render(bar, bar_area, buf);
}
}
}
}
#[allow(dead_code)]
pub fn get_selected_item<'a>(
state: &TreeState,
items: &'a [VisibleItem],
) -> Option<&'a VisibleItem> {
items.get(state.selected)
}