1use anyhow::{Context, Result};
2use devicons::{icon_for_file, Theme as DevIconTheme};
3use ratatui::{
4 buffer::Buffer,
5 layout::Rect,
6 style::{Color, Modifier, Style},
7 text::{Line, Span},
8 widgets::{Block, StatefulWidget},
9};
10use std::fs;
11use std::path::{Path, PathBuf};
12
13use crate::tree_view::{TreeNode, TreeView, TreeViewState};
14
15#[derive(Debug, Clone)]
17pub struct FileSystemEntry {
18 pub name: String,
20 pub path: PathBuf,
22 pub is_dir: bool,
24 pub is_hidden: bool,
26}
27
28impl FileSystemEntry {
29 pub fn new(path: PathBuf) -> Result<Self> {
31 let name = path
32 .file_name()
33 .and_then(|n| n.to_str())
34 .unwrap_or("")
35 .to_string();
36
37 let is_dir = path.is_dir();
38 let is_hidden = name.starts_with('.');
39
40 Ok(Self {
41 name,
42 path,
43 is_dir,
44 is_hidden,
45 })
46 }
47}
48
49#[derive(Debug, Clone, Copy)]
51pub struct FileSystemTreeConfig {
52 pub show_hidden: bool,
54 pub use_dark_theme: bool,
56 pub dir_style: Style,
58 pub file_style: Style,
60 pub selected_style: Style,
62}
63
64impl Default for FileSystemTreeConfig {
65 fn default() -> Self {
66 Self {
67 show_hidden: false,
68 use_dark_theme: true,
69 dir_style: Style::default()
70 .fg(Color::Cyan)
71 .add_modifier(Modifier::BOLD),
72 file_style: Style::default().fg(Color::White),
73 selected_style: Style::default()
74 .bg(Color::Blue)
75 .fg(Color::White)
76 .add_modifier(Modifier::BOLD),
77 }
78 }
79}
80
81impl FileSystemTreeConfig {
82 pub fn new() -> Self {
83 Self::default()
84 }
85
86 pub fn with_show_hidden(mut self, show_hidden: bool) -> Self {
87 self.show_hidden = show_hidden;
88 self
89 }
90
91 pub fn with_dark_theme(mut self, use_dark: bool) -> Self {
92 self.use_dark_theme = use_dark;
93 self
94 }
95
96 pub fn with_dir_style(mut self, style: Style) -> Self {
97 self.dir_style = style;
98 self
99 }
100
101 pub fn with_file_style(mut self, style: Style) -> Self {
102 self.file_style = style;
103 self
104 }
105
106 pub fn with_selected_style(mut self, style: Style) -> Self {
107 self.selected_style = style;
108 self
109 }
110}
111
112#[derive(Clone)]
114pub struct FileSystemTree<'a> {
115 pub root_path: PathBuf,
117 pub nodes: Vec<TreeNode<FileSystemEntry>>,
119 pub(crate) config: FileSystemTreeConfig,
121 block: Option<Block<'a>>,
123}
124
125impl<'a> FileSystemTree<'a> {
126 pub fn new(root_path: PathBuf) -> Result<Self> {
128 let config = FileSystemTreeConfig::default();
129 let nodes = Self::load_directory(&root_path, &config)?;
130
131 Ok(Self {
132 root_path,
133 nodes,
134 config,
135 block: None,
136 })
137 }
138
139 pub fn with_config(root_path: PathBuf, config: FileSystemTreeConfig) -> Result<Self> {
141 let nodes = Self::load_directory(&root_path, &config)?;
142
143 Ok(Self {
144 root_path,
145 nodes,
146 config,
147 block: None,
148 })
149 }
150
151 pub fn block(mut self, block: Block<'a>) -> Self {
153 self.block = Some(block);
154 self
155 }
156
157 fn load_directory(
159 path: &Path,
160 config: &FileSystemTreeConfig,
161 ) -> Result<Vec<TreeNode<FileSystemEntry>>> {
162 let mut entries = Vec::new();
163
164 let read_dir = fs::read_dir(path).context("Failed to read directory")?;
165
166 for entry in read_dir {
167 let entry = entry.context("Failed to read directory entry")?;
168 let path = entry.path();
169
170 let fs_entry = FileSystemEntry::new(path.clone())?;
171
172 if fs_entry.is_hidden && !config.show_hidden {
174 continue;
175 }
176
177 let node = if fs_entry.is_dir {
179 TreeNode {
181 data: fs_entry,
182 children: Vec::new(),
183 expandable: true,
184 }
185 } else {
186 TreeNode::new(fs_entry)
187 };
188
189 entries.push(node);
190 }
191
192 entries.sort_by(|a, b| match (a.data.is_dir, b.data.is_dir) {
194 (true, false) => std::cmp::Ordering::Less,
195 (false, true) => std::cmp::Ordering::Greater,
196 _ => a.data.name.to_lowercase().cmp(&b.data.name.to_lowercase()),
197 });
198
199 Ok(entries)
200 }
201
202 pub fn expand_directory(&mut self, path: &[usize]) -> Result<()> {
204 fn find_and_expand(
205 nodes: &mut [TreeNode<FileSystemEntry>],
206 path: &[usize],
207 config: &FileSystemTreeConfig,
208 ) -> Result<()> {
209 if path.is_empty() {
210 return Ok(());
211 }
212
213 if path.len() == 1 {
214 if let Some(node) = nodes.get_mut(path[0]) {
215 if node.data.is_dir && node.children.is_empty() {
216 node.children = FileSystemTree::load_directory(&node.data.path, config)?;
218 }
219 }
220 return Ok(());
221 }
222
223 if let Some(node) = nodes.get_mut(path[0]) {
225 find_and_expand(&mut node.children, &path[1..], config)?;
226 }
227
228 Ok(())
229 }
230
231 find_and_expand(&mut self.nodes, path, &self.config)
232 }
233
234 pub fn get_selected_entry(&self, state: &TreeViewState) -> Option<FileSystemEntry> {
236 if let Some(path) = &state.selected_path {
237 self.get_entry_at_path(path)
238 } else {
239 None
240 }
241 }
242
243 fn get_entry_at_path(&self, path: &[usize]) -> Option<FileSystemEntry> {
245 fn find_entry(
246 nodes: &[TreeNode<FileSystemEntry>],
247 path: &[usize],
248 ) -> Option<FileSystemEntry> {
249 if path.is_empty() {
250 return None;
251 }
252
253 if path.len() == 1 {
254 return nodes.get(path[0]).map(|n| n.data.clone());
255 }
256
257 if let Some(node) = nodes.get(path[0]) {
258 return find_entry(&node.children, &path[1..]);
259 }
260
261 None
262 }
263
264 find_entry(&self.nodes, path)
265 }
266}
267
268impl<'a> StatefulWidget for FileSystemTree<'a> {
269 type State = TreeViewState;
270
271 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
272 let config = self.config;
273 let block = self.block;
274
275 let tree_view = TreeView::new(self.nodes)
277 .icons("", "") .render_fn(move |entry, node_state| {
279 let theme = if config.use_dark_theme {
280 DevIconTheme::Dark
281 } else {
282 DevIconTheme::Light
283 };
284
285 let (icon_glyph, icon_color) = if entry.is_dir {
287 if node_state.is_expanded {
289 ('\u{f07c}', Color::Rgb(31, 111, 136)) } else {
291 ('\u{f07b}', Color::Rgb(31, 111, 136)) }
293 } else {
294 let icon_char = if let Some((custom_icon, _)) = get_custom_icon(&entry.name) {
296 custom_icon
297 } else {
298 let file_icon = icon_for_file(&entry.name, &Some(theme));
299 file_icon.icon
300 };
301
302 let color = get_ayu_dark_color(&entry.name);
304 (icon_char, color)
305 };
306
307 let style = Style::default().fg(icon_color);
309
310 Line::from(vec![
311 Span::styled(format!("{} ", icon_glyph), Style::default().fg(icon_color)),
312 Span::styled(entry.name.clone(), style),
313 ])
314 })
315 .highlight_style(Style::default().bg(Color::Rgb(15, 25, 40))); let tree_view = if let Some(block) = block {
318 tree_view.block(block)
319 } else {
320 tree_view
321 };
322
323 tree_view.render(area, buf, state);
324 }
325}
326
327fn get_ayu_dark_color(filename: &str) -> Color {
329 let lower = filename.to_lowercase();
330
331 if lower.ends_with(".sh")
333 || lower.ends_with(".bash")
334 || lower.ends_with(".zsh")
335 || lower.ends_with(".fish")
336 || lower.ends_with(".py")
337 || lower.ends_with(".rb")
338 {
339 return Color::Rgb(126, 147, 80); }
341
342 if lower.ends_with(".png")
344 || lower.ends_with(".jpg")
345 || lower.ends_with(".jpeg")
346 || lower.ends_with(".gif")
347 || lower.ends_with(".svg")
348 || lower.ends_with(".ico")
349 || lower.ends_with(".webp")
350 || lower.ends_with(".bmp")
351 {
352 return Color::Rgb(194, 160, 92); }
354
355 if lower.ends_with(".mp3")
357 || lower.ends_with(".mp4")
358 || lower.ends_with(".wav")
359 || lower.ends_with(".avi")
360 || lower.ends_with(".mkv")
361 || lower.ends_with(".flac")
362 || lower.ends_with(".ogg")
363 || lower.ends_with(".webm")
364 {
365 return Color::Rgb(126, 147, 80); }
367
368 if lower.ends_with(".zip")
370 || lower.ends_with(".tar")
371 || lower.ends_with(".gz")
372 || lower.ends_with(".bz2")
373 || lower.ends_with(".xz")
374 || lower.ends_with(".7z")
375 || lower.ends_with(".rar")
376 {
377 return Color::Rgb(168, 83, 97); }
379
380 if lower.ends_with(".pdf")
382 || lower.ends_with(".doc")
383 || lower.ends_with(".docx")
384 || lower.ends_with(".rtf")
385 || lower.ends_with(".odt")
386 {
387 return Color::Rgb(31, 111, 136); }
389
390 if lower.ends_with(".json")
392 || lower.ends_with(".js")
393 || lower.ends_with(".ts")
394 || lower.ends_with(".jsx")
395 || lower.ends_with(".tsx")
396 {
397 return Color::Rgb(194, 160, 92); }
399
400 if lower.ends_with(".yml") || lower.ends_with(".yaml") {
401 return Color::Rgb(31, 111, 136); }
403
404 if lower.ends_with(".toml") {
405 return Color::Rgb(148, 100, 182); }
407
408 if lower.ends_with(".rs") {
410 return Color::Rgb(194, 160, 92); }
412
413 if lower.ends_with(".c")
415 || lower.ends_with(".cpp")
416 || lower.ends_with(".h")
417 || lower.ends_with(".hpp")
418 {
419 return Color::Rgb(31, 111, 136); }
421
422 if lower.ends_with(".go") {
424 return Color::Rgb(31, 111, 136); }
426
427 if lower.ends_with(".md") || lower.ends_with(".txt") || lower.ends_with(".log") {
429 return Color::Rgb(230, 225, 207); }
431
432 Color::Rgb(230, 225, 207) }
435
436fn get_custom_icon(filename: &str) -> Option<(char, Color)> {
439 let lower = filename.to_lowercase();
440
441 if lower.ends_with(".just") || lower == "justfile" || lower == ".justfile" {
445 return Some(('\u{e779}', Color::Rgb(194, 160, 92))); }
447
448 if lower == "makefile" || lower.starts_with("makefile.") || lower == "gnumakefile" {
450 return Some(('\u{e779}', Color::Rgb(109, 128, 134))); }
452
453 if lower == "gemfile" || lower == "gemfile.lock" {
457 return Some(('\u{e21e}', Color::Rgb(112, 21, 22))); }
459
460 if lower == ".env" || lower.starts_with(".env.") {
464 return Some(('\u{f462}', Color::Rgb(251, 192, 45))); }
466
467 if lower == "license"
471 || lower == "license.txt"
472 || lower == "license.md"
473 || lower == "licence"
474 || lower == "licence.txt"
475 || lower == "copying"
476 {
477 return Some(('\u{f48a}', Color::Rgb(216, 187, 98))); }
479
480 if lower == "jenkinsfile" || lower.starts_with("jenkinsfile.") {
484 return Some(('\u{e767}', Color::Rgb(217, 69, 57))); }
486
487 if lower == ".ds_store" {
491 return Some(('\u{f179}', Color::Rgb(126, 142, 168))); }
493
494 None
495}
496
497impl<'a> FileSystemTree<'a> {
499 fn get_visible_paths(&self, state: &TreeViewState) -> Vec<Vec<usize>> {
501 let mut paths = Vec::new();
502
503 fn traverse(
504 nodes: &[TreeNode<FileSystemEntry>],
505 current_path: Vec<usize>,
506 state: &TreeViewState,
507 paths: &mut Vec<Vec<usize>>,
508 ) {
509 for (idx, node) in nodes.iter().enumerate() {
510 let mut path = current_path.clone();
511 path.push(idx);
512 paths.push(path.clone());
513
514 if state.is_expanded(&path) && !node.children.is_empty() {
516 traverse(&node.children, path, state, paths);
517 }
518 }
519 }
520
521 traverse(&self.nodes, Vec::new(), state, &mut paths);
522 paths
523 }
524
525 pub fn select_previous(&self, state: &mut TreeViewState) {
527 let visible_paths = self.get_visible_paths(state);
528 if visible_paths.is_empty() {
529 return;
530 }
531
532 if let Some(current_path) = &state.selected_path {
533 if let Some(current_idx) = visible_paths.iter().position(|p| p == current_path) {
534 if current_idx > 0 {
535 state.select(visible_paths[current_idx - 1].clone());
536 }
537 }
538 } else {
539 state.select(visible_paths[0].clone());
541 }
542 }
543
544 pub fn select_next(&self, state: &mut TreeViewState) {
546 let visible_paths = self.get_visible_paths(state);
547 if visible_paths.is_empty() {
548 return;
549 }
550
551 if let Some(current_path) = &state.selected_path {
552 if let Some(current_idx) = visible_paths.iter().position(|p| p == current_path) {
553 if current_idx < visible_paths.len() - 1 {
554 state.select(visible_paths[current_idx + 1].clone());
555 }
556 }
557 } else {
558 state.select(visible_paths[0].clone());
560 }
561 }
562
563 pub fn toggle_selected(&mut self, state: &mut TreeViewState) -> Result<()> {
565 if let Some(path) = state.selected_path.clone() {
566 if let Some(entry) = self.get_entry_at_path(&path) {
567 if entry.is_dir {
568 if !state.is_expanded(&path) {
569 self.expand_directory(&path)?;
571 }
572 state.toggle_expansion(path);
574 }
575 }
576 }
577 Ok(())
578 }
579}