Skip to main content

ratatui_toolkit/
file_system_tree.rs

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/// Represents a file system entry (file or directory)
16#[derive(Debug, Clone)]
17pub struct FileSystemEntry {
18    /// Name of the file/directory
19    pub name: String,
20    /// Full path
21    pub path: PathBuf,
22    /// Whether this is a directory
23    pub is_dir: bool,
24    /// Whether this entry is hidden (starts with .)
25    pub is_hidden: bool,
26}
27
28impl FileSystemEntry {
29    /// Create a new file system entry
30    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/// Configuration for the file system tree
50#[derive(Debug, Clone, Copy)]
51pub struct FileSystemTreeConfig {
52    /// Show hidden files (starting with .)
53    pub show_hidden: bool,
54    /// Use dark theme for icons (true = dark, false = light)
55    pub use_dark_theme: bool,
56    /// Style for directories
57    pub dir_style: Style,
58    /// Style for files
59    pub file_style: Style,
60    /// Style for selected items
61    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/// File system tree browser widget
113#[derive(Clone)]
114pub struct FileSystemTree<'a> {
115    /// Root directory to browse
116    pub root_path: PathBuf,
117    /// Tree nodes built from file system
118    pub nodes: Vec<TreeNode<FileSystemEntry>>,
119    /// Configuration
120    pub(crate) config: FileSystemTreeConfig,
121    /// Optional block wrapper
122    block: Option<Block<'a>>,
123}
124
125impl<'a> FileSystemTree<'a> {
126    /// Create a new file system tree starting at the given path
127    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    /// Create with custom configuration
140    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    /// Set the block wrapper
152    pub fn block(mut self, block: Block<'a>) -> Self {
153        self.block = Some(block);
154        self
155    }
156
157    /// Load a directory and return tree nodes
158    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            // Skip hidden files if configured
173            if fs_entry.is_hidden && !config.show_hidden {
174                continue;
175            }
176
177            // Create tree node
178            let node = if fs_entry.is_dir {
179                // For directories, mark as expandable but don't load children yet
180                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        // Sort: directories first, then files, both alphabetically
193        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    /// Expand a directory node by loading its children
203    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                        // Load children for this directory
217                        node.children = FileSystemTree::load_directory(&node.data.path, config)?;
218                    }
219                }
220                return Ok(());
221            }
222
223            // Recurse deeper
224            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    /// Get the entry at the currently selected path
235    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    /// Get entry at a specific path
244    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        // Create the tree view with custom render function
276        let tree_view = TreeView::new(self.nodes)
277            .icons("", "") // No expand/collapse arrows
278            .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                // Use custom folder icons and Ayu Dark theme colors
286                let (icon_glyph, icon_color) = if entry.is_dir {
287                    // Ayu Dark folder color: #1f6f88 (teal)
288                    if node_state.is_expanded {
289                        ('\u{f07c}', Color::Rgb(31, 111, 136)) // Open folder  - Ayu Dark teal
290                    } else {
291                        ('\u{f07b}', Color::Rgb(31, 111, 136)) // Closed folder  - Ayu Dark teal
292                    }
293                } else {
294                    // Get icon from devicons or custom icons
295                    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                    // But use Ayu Dark colors instead of devicons colors
303                    let color = get_ayu_dark_color(&entry.name);
304                    (icon_char, color)
305                };
306
307                // Always use icon color for the filename (full-row highlight handles selection)
308                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))); // Darker blue selection bg: #0f1928
316
317        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
327/// Map file to Ayu Dark theme color based on file type
328fn get_ayu_dark_color(filename: &str) -> Color {
329    let lower = filename.to_lowercase();
330
331    // Check if executable (simplified - just check common script extensions)
332    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); // Green for executables/scripts (#7e9350)
340    }
341
342    // Images
343    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); // Yellow/gold (#c2a05c)
353    }
354
355    // Media (audio/video)
356    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); // Green (#7e9350)
366    }
367
368    // Archives
369    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); // Red (#a85361)
378    }
379
380    // Documents
381    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); // Teal (#1f6f88)
388    }
389
390    // Config/data files
391    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); // Yellow (#c2a05c)
398    }
399
400    if lower.ends_with(".yml") || lower.ends_with(".yaml") {
401        return Color::Rgb(31, 111, 136); // Teal (#1f6f88)
402    }
403
404    if lower.ends_with(".toml") {
405        return Color::Rgb(148, 100, 182); // Purple (#9464b6)
406    }
407
408    // Rust files
409    if lower.ends_with(".rs") {
410        return Color::Rgb(194, 160, 92); // Yellow (#c2a05c)
411    }
412
413    // C/C++
414    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); // Teal (#1f6f88)
420    }
421
422    // Go
423    if lower.ends_with(".go") {
424        return Color::Rgb(31, 111, 136); // Teal (#1f6f88)
425    }
426
427    // Markdown/text (keep these slightly dimmed)
428    if lower.ends_with(".md") || lower.ends_with(".txt") || lower.ends_with(".log") {
429        return Color::Rgb(230, 225, 207); // Ayu Dark foreground (#e6e1cf)
430    }
431
432    // Default: Ayu Dark foreground color (warm white/beige) for regular files
433    Color::Rgb(230, 225, 207) // #e6e1cf
434}
435
436/// Get custom icon for files that devicons might not recognize
437/// Returns (icon_char, color) if a custom icon is available
438fn get_custom_icon(filename: &str) -> Option<(char, Color)> {
439    let lower = filename.to_lowercase();
440
441    // === BUILD TOOLS ===
442
443    // .just files (justfile build tool)
444    if lower.ends_with(".just") || lower == "justfile" || lower == ".justfile" {
445        return Some(('\u{e779}', Color::Rgb(194, 160, 92))); //  - makefile/build icon in yellow
446    }
447
448    // Makefile
449    if lower == "makefile" || lower.starts_with("makefile.") || lower == "gnumakefile" {
450        return Some(('\u{e779}', Color::Rgb(109, 128, 134))); //  - makefile icon
451    }
452
453    // === RUBY ===
454
455    // Gemfile
456    if lower == "gemfile" || lower == "gemfile.lock" {
457        return Some(('\u{e21e}', Color::Rgb(112, 21, 22))); //  - ruby red
458    }
459
460    // === ENVIRONMENT/CONFIG ===
461
462    // .env files (all variants)
463    if lower == ".env" || lower.starts_with(".env.") {
464        return Some(('\u{f462}', Color::Rgb(251, 192, 45))); //  - env yellow
465    }
466
467    // === LICENSE ===
468
469    // License files
470    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))); //  - license yellow
478    }
479
480    // === CI/CD ===
481
482    // Jenkins
483    if lower == "jenkinsfile" || lower.starts_with("jenkinsfile.") {
484        return Some(('\u{e767}', Color::Rgb(217, 69, 57))); //  - jenkins red
485    }
486
487    // === macOS ===
488
489    // .DS_Store
490    if lower == ".ds_store" {
491        return Some(('\u{f179}', Color::Rgb(126, 142, 168))); //  - apple icon gray
492    }
493
494    None
495}
496
497/// Helper methods for keyboard navigation
498impl<'a> FileSystemTree<'a> {
499    /// Get all visible paths (flattened tree with expansion state)
500    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 expanded, recurse into children
515                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    /// Move selection up
526    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            // Select first item
540            state.select(visible_paths[0].clone());
541        }
542    }
543
544    /// Move selection down
545    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            // Select first item
559            state.select(visible_paths[0].clone());
560        }
561    }
562
563    /// Toggle expansion of selected directory
564    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                        // Expand: load directory contents
570                        self.expand_directory(&path)?;
571                    }
572                    // Toggle expansion state
573                    state.toggle_expansion(path);
574                }
575            }
576        }
577        Ok(())
578    }
579}