use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{
Block, Borders, Paragraph, Scrollbar, ScrollbarOrientation, ScrollbarState,
};
use std::collections::HashSet;
use std::path::{Path, PathBuf};
use unicode_width::UnicodeWidthStr;
use crate::studio::theme;
use crate::studio::utils::truncate_width;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum FileGitStatus {
#[default]
Normal,
Staged,
Modified,
Untracked,
Deleted,
Renamed,
Conflict,
}
impl FileGitStatus {
#[must_use]
pub fn indicator(self) -> &'static str {
match self {
Self::Normal => " ",
Self::Staged => "●",
Self::Modified => "○",
Self::Untracked => "?",
Self::Deleted => "✕",
Self::Renamed => "→",
Self::Conflict => "!",
}
}
#[must_use]
pub fn style(self) -> Style {
match self {
Self::Normal => theme::dimmed(),
Self::Staged => theme::git_staged(),
Self::Modified => theme::git_modified(),
Self::Untracked => theme::git_untracked(),
Self::Deleted => theme::git_deleted(),
Self::Renamed => theme::git_staged(),
Self::Conflict => theme::error(),
}
}
}
#[derive(Debug, Clone)]
pub struct TreeNode {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub git_status: FileGitStatus,
pub depth: usize,
pub children: Vec<TreeNode>,
}
impl TreeNode {
pub fn file(name: impl Into<String>, path: impl Into<PathBuf>, depth: usize) -> Self {
Self {
name: name.into(),
path: path.into(),
is_dir: false,
git_status: FileGitStatus::Normal,
depth,
children: Vec::new(),
}
}
pub fn dir(name: impl Into<String>, path: impl Into<PathBuf>, depth: usize) -> Self {
Self {
name: name.into(),
path: path.into(),
is_dir: true,
git_status: FileGitStatus::Normal,
depth,
children: Vec::new(),
}
}
#[must_use]
pub fn with_status(mut self, status: FileGitStatus) -> Self {
self.git_status = status;
self
}
pub fn add_child(&mut self, child: TreeNode) {
self.children.push(child);
}
pub fn sort_children(&mut self) {
self.children.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
for child in &mut self.children {
child.sort_children();
}
}
}
#[derive(Debug, Clone)]
pub struct FlatEntry {
pub name: String,
pub path: PathBuf,
pub is_dir: bool,
pub git_status: FileGitStatus,
pub depth: usize,
pub is_expanded: bool,
pub has_children: bool,
}
#[derive(Debug, Clone)]
pub struct FileTreeState {
root: Vec<TreeNode>,
expanded: HashSet<PathBuf>,
selected: usize,
scroll_offset: usize,
flat_cache: Vec<FlatEntry>,
cache_dirty: bool,
}
impl Default for FileTreeState {
fn default() -> Self {
Self::new()
}
}
impl FileTreeState {
#[must_use]
pub fn new() -> Self {
Self {
root: Vec::new(),
expanded: HashSet::new(),
selected: 0,
scroll_offset: 0,
flat_cache: Vec::new(),
cache_dirty: true,
}
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.root.is_empty()
}
pub fn set_root(&mut self, root: Vec<TreeNode>) {
self.root = root;
self.cache_dirty = true;
self.selected = 0;
self.scroll_offset = 0;
}
#[must_use]
pub fn from_paths(paths: &[PathBuf], git_statuses: &[(PathBuf, FileGitStatus)]) -> Self {
let mut state = Self::new();
let mut root_nodes: Vec<TreeNode> = Vec::new();
let status_map: std::collections::HashMap<_, _> = git_statuses.iter().cloned().collect();
for path in paths {
let components: Vec<_> = path.components().collect();
insert_path(&mut root_nodes, &components, 0, path, &status_map);
}
for node in &mut root_nodes {
node.sort_children();
}
root_nodes.sort_by(|a, b| match (a.is_dir, b.is_dir) {
(true, false) => std::cmp::Ordering::Less,
(false, true) => std::cmp::Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
});
state.root = root_nodes;
state.cache_dirty = true;
state.expand_to_depth(2);
state
}
pub fn flat_view(&mut self) -> &[FlatEntry] {
if self.cache_dirty {
self.rebuild_cache();
}
&self.flat_cache
}
fn rebuild_cache(&mut self) {
self.flat_cache.clear();
let root_clone = self.root.clone();
for node in &root_clone {
self.flatten_node(node);
}
self.cache_dirty = false;
}
fn flatten_node(&mut self, node: &TreeNode) {
let is_expanded = self.expanded.contains(&node.path);
self.flat_cache.push(FlatEntry {
name: node.name.clone(),
path: node.path.clone(),
is_dir: node.is_dir,
git_status: node.git_status,
depth: node.depth,
is_expanded,
has_children: !node.children.is_empty(),
});
if is_expanded {
let children = node.children.clone();
for child in &children {
self.flatten_node(child);
}
}
}
pub fn selected_entry(&mut self) -> Option<FlatEntry> {
self.ensure_cache();
self.flat_cache.get(self.selected).cloned()
}
fn ensure_cache(&mut self) {
if self.cache_dirty {
self.rebuild_cache();
}
}
pub fn selected_path(&mut self) -> Option<PathBuf> {
self.selected_entry().map(|e| e.path)
}
pub fn select_path(&mut self, path: &Path) -> bool {
let selected = self.flat_view().iter().position(|entry| entry.path == path);
if let Some(index) = selected {
self.selected = index;
self.ensure_visible();
true
} else {
false
}
}
pub fn select_prev(&mut self) {
if self.selected > 0 {
self.selected -= 1;
self.ensure_visible();
}
}
pub fn select_next(&mut self) {
let len = self.flat_view().len();
if self.selected + 1 < len {
self.selected += 1;
self.ensure_visible();
}
}
pub fn select_first(&mut self) {
self.selected = 0;
self.scroll_offset = 0;
}
pub fn select_last(&mut self) {
let len = self.flat_view().len();
if len > 0 {
self.selected = len - 1;
}
}
pub fn page_up(&mut self, page_size: usize) {
self.selected = self.selected.saturating_sub(page_size);
self.ensure_visible();
}
pub fn page_down(&mut self, page_size: usize) {
let len = self.flat_view().len();
self.selected = (self.selected + page_size).min(len.saturating_sub(1));
self.ensure_visible();
}
pub fn toggle_expand(&mut self) {
if let Some(entry) = self.selected_entry()
&& entry.is_dir
{
if self.expanded.contains(&entry.path) {
self.expanded.remove(&entry.path);
} else {
self.expanded.insert(entry.path);
}
self.cache_dirty = true;
}
}
pub fn expand(&mut self) {
if let Some(entry) = self.selected_entry()
&& entry.is_dir
&& !self.expanded.contains(&entry.path)
{
self.expanded.insert(entry.path);
self.cache_dirty = true;
}
}
pub fn collapse(&mut self) {
if let Some(entry) = self.selected_entry() {
if entry.is_dir && self.expanded.contains(&entry.path) {
self.expanded.remove(&entry.path);
self.cache_dirty = true;
} else if entry.depth > 0 {
let parent_path = entry.path.parent().map(Path::to_path_buf);
if let Some(parent) = parent_path {
self.expanded.remove(&parent);
self.cache_dirty = true;
let flat = self.flat_view();
for (i, e) in flat.iter().enumerate() {
if e.path == parent {
self.selected = i;
break;
}
}
}
}
}
}
pub fn expand_all(&mut self) {
self.expand_all_recursive(&self.root.clone());
self.cache_dirty = true;
}
fn expand_all_recursive(&mut self, nodes: &[TreeNode]) {
for node in nodes {
if node.is_dir {
self.expanded.insert(node.path.clone());
self.expand_all_recursive(&node.children);
}
}
}
pub fn collapse_all(&mut self) {
self.expanded.clear();
self.cache_dirty = true;
self.selected = 0;
}
pub fn expand_to_depth(&mut self, max_depth: usize) {
self.expand_to_depth_recursive(&self.root.clone(), 0, max_depth);
self.cache_dirty = true;
}
fn expand_to_depth_recursive(
&mut self,
nodes: &[TreeNode],
current_depth: usize,
max_depth: usize,
) {
if current_depth >= max_depth {
return;
}
for node in nodes {
if node.is_dir {
self.expanded.insert(node.path.clone());
self.expand_to_depth_recursive(&node.children, current_depth + 1, max_depth);
}
}
}
#[allow(clippy::unused_self)]
fn ensure_visible(&mut self) {
}
pub fn update_scroll(&mut self, visible_height: usize) {
if visible_height == 0 {
return;
}
if self.selected < self.scroll_offset {
self.scroll_offset = self.selected;
} else if self.selected >= self.scroll_offset + visible_height {
self.scroll_offset = self.selected - visible_height + 1;
}
}
#[must_use]
pub fn scroll_offset(&self) -> usize {
self.scroll_offset
}
#[must_use]
pub fn selected_index(&self) -> usize {
self.selected
}
pub fn select_by_row(&mut self, row: usize) -> bool {
let flat_len = self.flat_view().len();
let target_index = self.scroll_offset + row;
if target_index < flat_len && target_index != self.selected {
self.selected = target_index;
true
} else {
false
}
}
#[must_use]
pub fn is_row_selected(&self, row: usize) -> bool {
let target_index = self.scroll_offset + row;
target_index == self.selected
}
pub fn handle_click(&mut self, row: usize) -> (bool, bool) {
let flat_len = self.flat_view().len();
let target_index = self.scroll_offset + row;
if target_index >= flat_len {
return (false, false);
}
let was_selected = target_index == self.selected;
let is_dir = self.flat_cache.get(target_index).is_some_and(|e| e.is_dir);
if !was_selected {
self.selected = target_index;
}
(!was_selected, is_dir)
}
}
fn insert_path(
nodes: &mut Vec<TreeNode>,
components: &[std::path::Component<'_>],
depth: usize,
full_path: &Path,
status_map: &std::collections::HashMap<PathBuf, FileGitStatus>,
) {
if components.is_empty() {
return;
}
let name = components[0].as_os_str().to_string_lossy().to_string();
let is_last = components.len() == 1;
let current_path: PathBuf = full_path.components().take(depth + 1).collect();
let node_idx = nodes.iter().position(|n| n.name == name);
if is_last {
let status = status_map.get(full_path).copied().unwrap_or_default();
if node_idx.is_none() {
nodes.push(TreeNode::file(name, full_path, depth).with_status(status));
}
} else {
let idx = if let Some(idx) = node_idx {
idx
} else {
nodes.push(TreeNode::dir(name, current_path, depth));
nodes.len() - 1
};
insert_path(
&mut nodes[idx].children,
&components[1..],
depth + 1,
full_path,
status_map,
);
}
}
pub fn render_file_tree(
frame: &mut Frame,
area: Rect,
state: &mut FileTreeState,
title: &str,
focused: bool,
) {
let block = Block::default()
.title(format!(" {} ", title))
.borders(Borders::ALL)
.border_style(if focused {
theme::focused_border()
} else {
theme::unfocused_border()
});
let inner = block.inner(area);
frame.render_widget(block, area);
if inner.height == 0 || inner.width == 0 {
return;
}
let visible_height = inner.height as usize;
state.update_scroll(visible_height);
let scroll_offset = state.scroll_offset();
let selected = state.selected_index();
let flat = state.flat_view().to_vec(); let flat_len = flat.len();
let lines: Vec<Line> = flat
.iter()
.enumerate()
.skip(scroll_offset)
.take(visible_height)
.map(|(i, entry)| {
let is_selected = i == selected;
render_entry(entry, is_selected, inner.width as usize)
})
.collect();
let paragraph = Paragraph::new(lines);
frame.render_widget(paragraph, inner);
if flat_len > visible_height {
let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None);
let mut scrollbar_state = ScrollbarState::new(flat_len).position(scroll_offset);
frame.render_stateful_widget(
scrollbar,
area.inner(ratatui::layout::Margin {
vertical: 1,
horizontal: 0,
}),
&mut scrollbar_state,
);
}
}
fn render_entry(entry: &FlatEntry, is_selected: bool, width: usize) -> Line<'static> {
let indent = " ".repeat(entry.depth);
let icon = if entry.is_dir {
if entry.is_expanded { "▾" } else { "▸" }
} else {
get_file_icon(&entry.name)
};
let (status_indicator, status_style) = match entry.git_status {
FileGitStatus::Staged => ("▍", theme::git_staged().add_modifier(Modifier::BOLD)),
FileGitStatus::Modified => ("▍", theme::git_modified()),
FileGitStatus::Untracked => ("▍", theme::git_untracked()),
FileGitStatus::Deleted => ("▍", theme::git_deleted()),
FileGitStatus::Renamed => ("▍", theme::git_staged()),
FileGitStatus::Conflict => ("▍", theme::error().add_modifier(Modifier::BOLD)),
FileGitStatus::Normal => (" ", Style::default()),
};
let marker = if is_selected { "›" } else { " " };
let marker_style = if is_selected {
Style::default()
.fg(theme::accent_primary())
.add_modifier(Modifier::BOLD)
} else {
Style::default()
};
let name_style = if is_selected {
match entry.git_status {
FileGitStatus::Staged => theme::git_staged()
.bg(theme::bg_highlight_color())
.add_modifier(Modifier::BOLD),
FileGitStatus::Modified => theme::git_modified().bg(theme::bg_highlight_color()),
FileGitStatus::Deleted => theme::git_deleted()
.bg(theme::bg_highlight_color())
.add_modifier(Modifier::DIM),
FileGitStatus::Untracked => theme::git_untracked().bg(theme::bg_highlight_color()),
FileGitStatus::Conflict => theme::error()
.bg(theme::bg_highlight_color())
.add_modifier(Modifier::BOLD),
_ => theme::selected(),
}
} else if entry.is_dir {
Style::default()
.fg(theme::accent_secondary())
.add_modifier(Modifier::BOLD)
} else {
match entry.git_status {
FileGitStatus::Staged => theme::git_staged().add_modifier(Modifier::BOLD),
FileGitStatus::Modified => theme::git_modified(),
FileGitStatus::Deleted => theme::git_deleted().add_modifier(Modifier::DIM),
FileGitStatus::Untracked => theme::git_untracked(),
FileGitStatus::Renamed => theme::git_staged(),
FileGitStatus::Conflict => theme::error().add_modifier(Modifier::BOLD),
FileGitStatus::Normal => Style::default().fg(theme::text_primary_color()),
}
};
let icon_style = if entry.is_dir {
Style::default().fg(theme::accent_secondary())
} else {
match entry.git_status {
FileGitStatus::Staged => theme::git_staged(),
FileGitStatus::Modified => theme::git_modified(),
FileGitStatus::Deleted => theme::git_deleted(),
FileGitStatus::Untracked => theme::git_untracked(),
_ => Style::default().fg(theme::text_dim_color()),
}
};
let fixed_width = 1 + 1 + 1 + indent.width() + 1 + 1;
let max_name_width = width.saturating_sub(fixed_width);
let display_name = truncate_width(&entry.name, max_name_width);
Line::from(vec![
Span::styled(status_indicator, status_style),
Span::styled(marker, marker_style),
Span::raw(" "),
Span::raw(indent),
Span::styled(format!("{} ", icon), icon_style),
Span::styled(display_name, name_style),
])
}
fn get_file_icon(name: &str) -> &'static str {
let lower_name = name.to_lowercase();
if lower_name == "cargo.toml" || lower_name == "cargo.lock" {
return "◫";
}
if lower_name.starts_with("readme") {
return "◈";
}
if lower_name.starts_with("license") {
return "§";
}
if lower_name.starts_with(".git") {
return "⊙";
}
if lower_name == "dockerfile" || lower_name.starts_with("docker-compose") {
return "◲";
}
if lower_name == "makefile" {
return "⚙";
}
let ext = name.rsplit('.').next().unwrap_or("");
match ext.to_lowercase().as_str() {
"rs" => "●",
"toml" => "⚙",
"yaml" | "yml" => "⚙",
"json" => "◇",
"xml" => "◇",
"ini" | "cfg" | "conf" => "⚙",
"md" | "mdx" => "◈",
"txt" => "≡",
"pdf" => "▤",
"html" | "htm" => "◊",
"css" | "scss" | "sass" | "less" => "◊",
"js" | "mjs" | "cjs" => "◆",
"jsx" => "◆",
"ts" | "mts" | "cts" => "◇",
"tsx" => "◇",
"vue" => "◊",
"svelte" => "◊",
"py" | "pyi" => "◈",
"go" => "◈",
"rb" => "◈",
"java" | "class" | "jar" => "◈",
"kt" | "kts" => "◈",
"swift" => "◈",
"c" | "h" => "○",
"cpp" | "cc" | "cxx" | "hpp" | "hxx" => "○",
"cs" => "◈",
"php" => "◈",
"lua" => "◈",
"r" => "◈",
"sql" => "◫",
"sh" | "bash" | "zsh" | "fish" => "▷",
"ps1" | "psm1" => "▷",
"csv" => "◫",
"db" | "sqlite" | "sqlite3" => "◫",
"png" | "jpg" | "jpeg" | "gif" | "svg" | "ico" | "webp" => "◧",
"zip" | "tar" | "gz" | "rar" | "7z" => "▣",
"lock" => "◉",
"gitignore" | "gitattributes" | "gitmodules" => "⊙",
"env" | "env.local" | "env.development" | "env.production" => "◉",
_ => "◦",
}
}