Skip to main content

tui_lipan/widgets/file_tree/
fs.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3use std::sync::Arc;
4
5use crate::style::Span;
6use crate::utils::file_icons::{directory_icon, file_icon};
7
8/// Icon style for file tree items.
9#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Default)]
10pub enum FileIconStyle {
11    /// Text labels with bracketed prefixes (e.g. `'[F]'`, `'[D]'`, `'[L]'`).
12    #[default]
13    Text,
14    /// Nerd font icons without colors
15    NerdFont,
16    /// Nerd font icons with semantic colors (like mini.icons)
17    NerdFontColored,
18}
19
20/// Filesystem entry kind used by `FileTree` events.
21#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
22pub enum FileKind {
23    /// Directory entry.
24    Directory,
25    /// Regular file entry.
26    File,
27    /// Symlink entry.
28    Symlink,
29    /// Any other filesystem type.
30    Other,
31}
32
33impl FileKind {
34    pub(crate) fn from_file_type(file_type: &fs::FileType) -> Self {
35        if file_type.is_dir() {
36            Self::Directory
37        } else if file_type.is_file() {
38            Self::File
39        } else if file_type.is_symlink() {
40            Self::Symlink
41        } else {
42            Self::Other
43        }
44    }
45
46    pub(crate) fn icon(
47        self,
48        path: &str,
49        expanded: bool,
50        is_root: bool,
51        props: &super::mod_private::FileTreeProps,
52    ) -> Span {
53        let palette = &props.icon_palette;
54
55        match self {
56            Self::Directory => {
57                // Check if there's a custom override for this directory name
58                let path_obj = Path::new(path);
59                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
60                    && let Some(override_icon) = props.icon_overrides.get(name)
61                {
62                    let mut span = Span::new(override_icon.icon.clone());
63                    if let Some(color) = override_icon.color {
64                        span = span.fg(color);
65                    }
66                    return span;
67                }
68
69                let base = if expanded {
70                    &props.opened_directory_icon
71                } else {
72                    &props.directory_icon
73                };
74
75                match props.icon_style {
76                    FileIconStyle::Text => Span::new(base.as_ref()),
77                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
78                        let (folder_glyph, folder_color) = directory_icon(expanded, palette);
79                        let icon = if is_root || !props.show_arrows {
80                            folder_glyph.to_string()
81                        } else {
82                            let arrow = if expanded {
83                                "\u{f47c} " // 
84                            } else {
85                                "\u{f460} " // 
86                            };
87                            format!("{arrow}{folder_glyph}")
88                        };
89
90                        let mut span = Span::new(icon);
91                        if props.icon_style == FileIconStyle::NerdFontColored
92                            && let Some(c) = folder_color
93                        {
94                            span = span.fg(c);
95                        }
96                        span
97                    }
98                }
99            }
100            Self::File => {
101                // Check if there's a custom override for this file
102                let path_obj = Path::new(path);
103
104                // Check by full filename first
105                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
106                    && let Some(override_icon) = props.icon_overrides.get(name)
107                {
108                    let mut span = Span::new(override_icon.icon.clone());
109                    if let Some(color) = override_icon.color {
110                        span = span.fg(color);
111                    }
112                    return span;
113                }
114
115                // Then check by extension
116                if let Some(ext) = path_obj.extension().and_then(|e| e.to_str())
117                    && let Some(override_icon) = props.icon_overrides.get(ext)
118                {
119                    let mut span = Span::new(override_icon.icon.clone());
120                    if let Some(color) = override_icon.color {
121                        span = span.fg(color);
122                    }
123                    return span;
124                }
125
126                match props.icon_style {
127                    FileIconStyle::Text => Span::new(props.file_icon.clone()),
128                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
129                        let (icon, color) = file_icon(path, &props.icon_palette);
130                        let mut span = Span::new(icon);
131                        if props.icon_style == FileIconStyle::NerdFontColored
132                            && let Some(c) = color
133                        {
134                            span = span.fg(c);
135                        }
136                        span
137                    }
138                }
139            }
140            Self::Symlink => {
141                // Check if there's a custom override for symlinks
142                let path_obj = Path::new(path);
143                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
144                    && let Some(override_icon) = props.icon_overrides.get(name)
145                {
146                    let mut span = Span::new(override_icon.icon.clone());
147                    if let Some(color) = override_icon.color {
148                        span = span.fg(color);
149                    }
150                    return span;
151                }
152
153                match props.icon_style {
154                    FileIconStyle::Text => Span::new(props.symlink_icon.clone()),
155                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
156                        let mut span = Span::new("󰁔");
157                        if props.icon_style == FileIconStyle::NerdFontColored {
158                            // Symlinks are typically cyan in mini.icons
159                            span = span.fg(palette.cyan);
160                        }
161                        span
162                    }
163                }
164            }
165            Self::Other => {
166                // Check if there's a custom override
167                let path_obj = Path::new(path);
168                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
169                    && let Some(override_icon) = props.icon_overrides.get(name)
170                {
171                    let mut span = Span::new(override_icon.icon.clone());
172                    if let Some(color) = override_icon.color {
173                        span = span.fg(color);
174                    }
175                    return span;
176                }
177
178                match props.icon_style {
179                    FileIconStyle::Text => Span::new(props.other_icon.clone()),
180                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
181                        let mut span = Span::new("󰈔");
182                        if props.icon_style == FileIconStyle::NerdFontColored {
183                            span = span.fg(palette.grey);
184                        }
185                        span
186                    }
187                }
188            }
189        }
190    }
191}
192
193pub(crate) fn path_to_display(path: &str) -> String {
194    let home = std::env::var("HOME").unwrap_or_default();
195    if !home.is_empty() && path.starts_with(&home) {
196        return path.replacen(&home, "~", 1);
197    }
198    path.to_string()
199}
200
201#[derive(Clone, Debug)]
202pub(crate) struct FsNode {
203    pub(crate) name: Arc<str>,
204    pub(crate) path: Arc<str>,
205    pub(crate) kind: FileKind,
206    pub(crate) loaded: bool,
207    pub(crate) loading: bool,
208    pub(crate) error: Option<Arc<str>>,
209    pub(crate) children: Vec<FsNode>,
210}
211
212impl FsNode {
213    pub(crate) fn is_dir(&self) -> bool {
214        matches!(self.kind, FileKind::Directory)
215    }
216}
217
218#[derive(Clone, Debug)]
219pub(crate) struct LoadedEntry {
220    pub(crate) name: Arc<str>,
221    pub(crate) path: Arc<str>,
222    pub(crate) kind: FileKind,
223}
224
225#[derive(Clone, Debug)]
226pub(crate) struct DirectoryLoadResult {
227    pub(crate) entries: Vec<LoadedEntry>,
228    pub(crate) omitted: usize,
229    pub(crate) error: Option<Arc<str>>,
230}
231
232pub(crate) fn read_directory(
233    path: &str,
234    show_hidden: bool,
235    max_entries_per_dir: usize,
236) -> DirectoryLoadResult {
237    let mut entries = Vec::new();
238    let mut omitted = 0usize;
239    let root = PathBuf::from(path);
240
241    let read_dir = match fs::read_dir(&root) {
242        Ok(read_dir) => read_dir,
243        Err(err) => {
244            return DirectoryLoadResult {
245                entries,
246                omitted,
247                error: Some(err.to_string().into()),
248            };
249        }
250    };
251
252    for child in read_dir {
253        let Ok(child) = child else {
254            continue;
255        };
256        let name = child.file_name();
257        let Some(name_str) = name.to_str() else {
258            continue;
259        };
260        if !show_hidden && is_hidden_name(name_str) {
261            continue;
262        }
263
264        let Ok(file_type) = child.file_type() else {
265            continue;
266        };
267
268        if entries.len() >= max_entries_per_dir {
269            omitted = omitted.saturating_add(1);
270            continue;
271        }
272
273        let kind = FileKind::from_file_type(&file_type);
274        // The parent path is already canonical. Construct child path directly
275        // to avoid an `fs::canonicalize` syscall per entry. Only resolve
276        // symlinks where the canonical target matters for consistency.
277        let child_path = if matches!(kind, FileKind::Symlink) {
278            normalize_path(&child.path())
279        } else {
280            Arc::from(root.join(name_str).to_string_lossy().as_ref())
281        };
282
283        entries.push(LoadedEntry {
284            name: Arc::from(name_str),
285            path: child_path,
286            kind,
287        });
288    }
289
290    entries.sort_by(|left, right| {
291        let left_dir = matches!(left.kind, FileKind::Directory);
292        let right_dir = matches!(right.kind, FileKind::Directory);
293        right_dir
294            .cmp(&left_dir)
295            .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
296            .then_with(|| left.name.cmp(&right.name))
297    });
298
299    DirectoryLoadResult {
300        entries,
301        omitted,
302        error: None,
303    }
304}
305
306pub(crate) fn normalize_path(path: &Path) -> Arc<str> {
307    if let Ok(canonical) = fs::canonicalize(path) {
308        return Arc::<str>::from(canonical.to_string_lossy().as_ref());
309    }
310    Arc::<str>::from(path.to_string_lossy().as_ref())
311}
312
313fn is_hidden_name(name: &str) -> bool {
314    name.starts_with('.') && name != "." && name != ".."
315}
316
317pub(crate) fn root_node(root: &Arc<str>) -> FsNode {
318    let path = PathBuf::from(root.as_ref());
319    let name = display_name(&path);
320
321    match fs::symlink_metadata(&path) {
322        Ok(meta) => {
323            let kind = FileKind::from_file_type(&meta.file_type());
324            FsNode {
325                name,
326                path: normalize_path(&path),
327                kind,
328                loaded: !matches!(kind, FileKind::Directory),
329                loading: false,
330                error: None,
331                children: Vec::new(),
332            }
333        }
334        Err(err) => FsNode {
335            name,
336            path: normalize_path(&path),
337            kind: FileKind::Other,
338            loaded: true,
339            loading: false,
340            error: Some(err.to_string().into()),
341            children: Vec::new(),
342        },
343    }
344}
345
346fn display_name(path: &Path) -> Arc<str> {
347    path.file_name()
348        .and_then(|name| name.to_str())
349        .filter(|name| !name.is_empty())
350        .map(Arc::from)
351        .unwrap_or_else(|| Arc::<str>::from(path.to_string_lossy().as_ref()))
352}