use std::{
fs,
path::{Path, PathBuf},
};
use ratatui::{
Frame,
buffer::Buffer,
layout::Rect,
style::{Color, Modifier, Style},
text::{Line, Span},
widgets::ListItem,
};
use crate::prelude::*;
use widgets::tree::{FlatNode, TreeAction, TreeNode, TreeWidget};
struct FileEntry {
path: PathBuf,
is_dir: bool,
}
fn load_dir_shallow(dir: &Path) -> Vec<TreeNode<PathBuf>> {
let Ok(rd) = fs::read_dir(dir) else {
return vec![];
};
let mut entries: Vec<FileEntry> = rd
.filter_map(|e| e.ok())
.map(|e| e.path())
.filter(|p| {
p.file_name()
.and_then(|n| n.to_str())
.map(|n| !utils::fs::is_noise(n))
.unwrap_or(false)
})
.map(|path| {
let is_dir = path.is_dir();
FileEntry { path, is_dir }
})
.collect();
entries.sort_by(|a, b| b.is_dir.cmp(&a.is_dir).then_with(|| a.path.cmp(&b.path)));
entries
.into_iter()
.map(|entry| {
if entry.is_dir {
TreeNode::unloaded(entry.path)
} else {
TreeNode::leaf(entry.path)
}
})
.collect()
}
#[derive(Debug)]
pub struct FileTreeView {
inner: TreeWidget<PathBuf>,
}
impl FileTreeView {
pub fn from_dir(dir: &Path) -> Self {
let roots = load_dir_shallow(dir);
let inner = TreeWidget::new(roots);
Self { inner }
}
pub fn from_registry(registry: &crate::file_index::ProjectFileRegistry, project_root: &Path) -> Self {
let roots: Vec<TreeNode<PathBuf>> = registry
.root_children()
.iter()
.map(|d| {
let abs = project_root.join(&d.path);
if d.is_dir {
TreeNode::unloaded(abs)
} else {
TreeNode::leaf(abs)
}
})
.collect();
let inner = TreeWidget::new(roots);
Self { inner }
}
pub fn key_up(&mut self) {
self.inner.move_up();
}
pub fn key_down(&mut self) {
self.inner.move_down();
}
pub fn key_left(&mut self) {
self.inner.collapse_or_leave();
}
pub fn key_right(&mut self) -> Option<PathBuf> {
match self.inner.expand_or_enter() {
TreeAction::NeedsLoad => self
.inner
.selected()
.map(|flat| flat.node.data.clone()),
TreeAction::Done => None,
}
}
pub fn toggle_selected(&mut self) -> Option<PathBuf> {
match self.inner.toggle_selected() {
TreeAction::NeedsLoad => self
.inner
.selected()
.map(|flat| flat.node.data.clone()),
TreeAction::Done => None,
}
}
pub fn inject_children(&mut self, abs_path: &Path, entries: Vec<(PathBuf, bool)>) {
log::debug!("file_tree.inject_children: path={} entries={}", abs_path.display(), entries.len());
let children: Vec<TreeNode<PathBuf>> = entries
.into_iter()
.map(|(p, is_dir)| {
if is_dir {
TreeNode::unloaded(p)
} else {
TreeNode::leaf(p)
}
})
.collect();
if let Some(idx_path) = find_path(self.inner.roots(), abs_path) {
log::debug!("file_tree.inject_children: found idx_path={:?} for {}", idx_path, abs_path.display());
self.inner.inject_children(&idx_path, children);
self.inner.sync_flat_idx();
log::debug!("file_tree.inject_children: injected children, flat_len={:?}", ());
} else {
log::debug!("file_tree.inject_children: no matching node found for {}", abs_path.display());
}
}
pub fn set_cursor_to(&mut self, abs_path: &Path) -> bool {
log::debug!("file_tree.set_cursor_to: trying {}", abs_path.display());
if let Some(idx_path) = find_path(self.inner.roots(), abs_path) {
log::debug!("file_tree.set_cursor_to: found idx_path={:?}", idx_path);
self.inner.set_cursor_path(&idx_path);
return true;
}
log::debug!("file_tree.set_cursor_to: not found {}", abs_path.display());
false
}
pub fn selected_file(&self) -> Option<PathBuf> {
let flat = self.inner.selected()?;
if flat.node.is_leaf() {
Some(flat.node.data.clone())
} else {
None
}
}
#[allow(clippy::too_many_arguments)]
pub fn render_into(
&self,
area: Rect,
buf: &mut Buffer,
is_active: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
dir_fg: Color,
fg_dim: Color,
) {
self.inner.render(area, buf, |flat, selected| {
render_node(flat, selected, is_active, pane_bg, sel_bg, sel_fg, dir_fg, fg_dim)
});
}
#[allow(clippy::too_many_arguments)]
pub fn render(
&self,
frame: &mut Frame,
area: Rect,
is_active: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
dir_fg: Color,
fg_dim: Color,
) {
self.render_into(area, frame.buffer_mut(), is_active, pane_bg, sel_bg, sel_fg, dir_fg, fg_dim);
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn path_list_item(
path: &Path,
selected: bool,
dirty: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
fg_dim: Color,
max_width: usize,
) -> ListItem<'static> {
use ratatui::style::Style as RStyle;
let bg = if selected { sel_bg } else { pane_bg };
let base_bg = RStyle::default().bg(bg);
let filename_style = base_bg.fg(if selected { sel_fg } else { Color::Gray })
.add_modifier(if selected { Modifier::BOLD } else { Modifier::empty() });
let path_style = base_bg.fg(if selected { sel_fg } else { fg_dim });
let dim_style = base_bg.fg(fg_dim).add_modifier(Modifier::DIM);
let sep_style = base_bg.fg(fg_dim).add_modifier(Modifier::DIM);
let cfg = crate::path_format::StyleConfig {
filename: filename_style,
path: path_style,
dim: dim_style,
separator: sep_style,
};
let mut spans = crate::path_format::format_path_spans_with_min_tail(path, max_width, cfg, 2);
if dirty {
spans.push(Span::styled(" [+]".to_string(), base_bg.fg(Color::Yellow)));
}
ListItem::new(Line::from(spans))
}
fn find_path(roots: &[TreeNode<PathBuf>], target: &Path) -> Option<Vec<usize>> {
for (i, root) in roots.iter().enumerate() {
if let Some(mut path) = find_in_node(root, target) {
path.insert(0, i);
return Some(path);
}
}
None
}
fn find_in_node(node: &TreeNode<PathBuf>, target: &Path) -> Option<Vec<usize>> {
if node.data == target {
return Some(vec![]);
}
if let widgets::tree::ChildrenState::Loaded(children) = &node.children {
for (i, child) in children.iter().enumerate() {
if let Some(mut path) = find_in_node(child, target) {
path.insert(0, i);
return Some(path);
}
}
}
None
}
#[allow(clippy::too_many_arguments)]
fn render_node(
flat: &FlatNode<'_, PathBuf>,
selected: bool,
is_active: bool,
pane_bg: Color,
sel_bg: Color,
sel_fg: Color,
dir_fg: Color,
fg_dim: Color,
) -> Line<'static> {
let node = flat.node;
let indent = " ".repeat(flat.depth);
let icon: &str = if node.is_leaf() {
" "
} else if node.expanded {
"▾ "
} else {
"▸ "
};
let bg = if selected && is_active { sel_bg } else { pane_bg };
let prefix_style = Style::default()
.fg(if node.is_leaf() { fg_dim } else { dir_fg })
.bg(bg);
let indent_icon = format!("{}{}", indent, icon);
let filename_style = Style::default()
.bg(bg)
.fg(if selected && is_active { sel_fg } else { Color::Gray })
.add_modifier(if selected && is_active { Modifier::BOLD } else { Modifier::empty() });
let filename_text = node
.data
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| node.data.display().to_string());
let out: Vec<Span<'static>> = vec![
Span::styled(indent_icon, prefix_style),
Span::styled(filename_text, filename_style)];
Line::from(out)
}