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::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_icon = if expanded { " " } else { " " };
79                        let icon = if is_root || !props.show_arrows {
80                            // Remove the arrow for root or when arrows are disabled
81                            folder_icon.chars().skip(2).collect::<String>()
82                        } else {
83                            folder_icon.to_string()
84                        };
85
86                        let mut span = Span::new(icon);
87                        if props.icon_style == FileIconStyle::NerdFontColored {
88                            // Directories are typically blue in mini.icons
89                            span = span.fg(palette.blue);
90                        }
91                        span
92                    }
93                }
94            }
95            Self::File => {
96                // Check if there's a custom override for this file
97                let path_obj = Path::new(path);
98
99                // Check by full filename first
100                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
101                    && let Some(override_icon) = props.icon_overrides.get(name)
102                {
103                    let mut span = Span::new(override_icon.icon.clone());
104                    if let Some(color) = override_icon.color {
105                        span = span.fg(color);
106                    }
107                    return span;
108                }
109
110                // Then check by extension
111                if let Some(ext) = path_obj.extension().and_then(|e| e.to_str())
112                    && let Some(override_icon) = props.icon_overrides.get(ext)
113                {
114                    let mut span = Span::new(override_icon.icon.clone());
115                    if let Some(color) = override_icon.color {
116                        span = span.fg(color);
117                    }
118                    return span;
119                }
120
121                match props.icon_style {
122                    FileIconStyle::Text => Span::new(props.file_icon.clone()),
123                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
124                        let (icon, color) = file_icon(path, &props.icon_palette);
125                        let mut span = Span::new(icon);
126                        if props.icon_style == FileIconStyle::NerdFontColored
127                            && let Some(c) = color
128                        {
129                            span = span.fg(c);
130                        }
131                        span
132                    }
133                }
134            }
135            Self::Symlink => {
136                // Check if there's a custom override for symlinks
137                let path_obj = Path::new(path);
138                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
139                    && let Some(override_icon) = props.icon_overrides.get(name)
140                {
141                    let mut span = Span::new(override_icon.icon.clone());
142                    if let Some(color) = override_icon.color {
143                        span = span.fg(color);
144                    }
145                    return span;
146                }
147
148                match props.icon_style {
149                    FileIconStyle::Text => Span::new(props.symlink_icon.clone()),
150                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
151                        let mut span = Span::new("󰁔");
152                        if props.icon_style == FileIconStyle::NerdFontColored {
153                            // Symlinks are typically cyan in mini.icons
154                            span = span.fg(palette.cyan);
155                        }
156                        span
157                    }
158                }
159            }
160            Self::Other => {
161                // Check if there's a custom override
162                let path_obj = Path::new(path);
163                if let Some(name) = path_obj.file_name().and_then(|n| n.to_str())
164                    && let Some(override_icon) = props.icon_overrides.get(name)
165                {
166                    let mut span = Span::new(override_icon.icon.clone());
167                    if let Some(color) = override_icon.color {
168                        span = span.fg(color);
169                    }
170                    return span;
171                }
172
173                match props.icon_style {
174                    FileIconStyle::Text => Span::new(props.other_icon.clone()),
175                    FileIconStyle::NerdFont | FileIconStyle::NerdFontColored => {
176                        let mut span = Span::new("󰈔");
177                        if props.icon_style == FileIconStyle::NerdFontColored {
178                            span = span.fg(palette.grey);
179                        }
180                        span
181                    }
182                }
183            }
184        }
185    }
186}
187
188pub(crate) fn path_to_display(path: &str) -> String {
189    let home = std::env::var("HOME").unwrap_or_default();
190    if !home.is_empty() && path.starts_with(&home) {
191        return path.replacen(&home, "~", 1);
192    }
193    path.to_string()
194}
195
196#[derive(Clone, Debug)]
197pub(crate) struct FsNode {
198    pub(crate) name: Arc<str>,
199    pub(crate) path: Arc<str>,
200    pub(crate) kind: FileKind,
201    pub(crate) loaded: bool,
202    pub(crate) loading: bool,
203    pub(crate) error: Option<Arc<str>>,
204    pub(crate) children: Vec<FsNode>,
205}
206
207impl FsNode {
208    pub(crate) fn is_dir(&self) -> bool {
209        matches!(self.kind, FileKind::Directory)
210    }
211}
212
213#[derive(Clone, Debug)]
214pub(crate) struct LoadedEntry {
215    pub(crate) name: Arc<str>,
216    pub(crate) path: Arc<str>,
217    pub(crate) kind: FileKind,
218}
219
220#[derive(Clone, Debug)]
221pub(crate) struct DirectoryLoadResult {
222    pub(crate) entries: Vec<LoadedEntry>,
223    pub(crate) omitted: usize,
224    pub(crate) error: Option<Arc<str>>,
225}
226
227pub(crate) fn read_directory(
228    path: &str,
229    show_hidden: bool,
230    max_entries_per_dir: usize,
231) -> DirectoryLoadResult {
232    let mut entries = Vec::new();
233    let mut omitted = 0usize;
234    let root = PathBuf::from(path);
235
236    let read_dir = match fs::read_dir(&root) {
237        Ok(read_dir) => read_dir,
238        Err(err) => {
239            return DirectoryLoadResult {
240                entries,
241                omitted,
242                error: Some(err.to_string().into()),
243            };
244        }
245    };
246
247    for child in read_dir {
248        let Ok(child) = child else {
249            continue;
250        };
251        let name = child.file_name();
252        let Some(name_str) = name.to_str() else {
253            continue;
254        };
255        if !show_hidden && is_hidden_name(name_str) {
256            continue;
257        }
258
259        let Ok(file_type) = child.file_type() else {
260            continue;
261        };
262
263        if entries.len() >= max_entries_per_dir {
264            omitted = omitted.saturating_add(1);
265            continue;
266        }
267
268        let kind = FileKind::from_file_type(&file_type);
269        // The parent path is already canonical. Construct child path directly
270        // to avoid an `fs::canonicalize` syscall per entry. Only resolve
271        // symlinks where the canonical target matters for consistency.
272        let child_path = if matches!(kind, FileKind::Symlink) {
273            normalize_path(&child.path())
274        } else {
275            Arc::from(root.join(name_str).to_string_lossy().as_ref())
276        };
277
278        entries.push(LoadedEntry {
279            name: Arc::from(name_str),
280            path: child_path,
281            kind,
282        });
283    }
284
285    entries.sort_by(|left, right| {
286        let left_dir = matches!(left.kind, FileKind::Directory);
287        let right_dir = matches!(right.kind, FileKind::Directory);
288        right_dir
289            .cmp(&left_dir)
290            .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase()))
291            .then_with(|| left.name.cmp(&right.name))
292    });
293
294    DirectoryLoadResult {
295        entries,
296        omitted,
297        error: None,
298    }
299}
300
301pub(crate) fn normalize_path(path: &Path) -> Arc<str> {
302    if let Ok(canonical) = fs::canonicalize(path) {
303        return Arc::<str>::from(canonical.to_string_lossy().as_ref());
304    }
305    Arc::<str>::from(path.to_string_lossy().as_ref())
306}
307
308fn is_hidden_name(name: &str) -> bool {
309    name.starts_with('.') && name != "." && name != ".."
310}
311
312pub(crate) fn root_node(root: &Arc<str>) -> FsNode {
313    let path = PathBuf::from(root.as_ref());
314    let name = display_name(&path);
315
316    match fs::symlink_metadata(&path) {
317        Ok(meta) => {
318            let kind = FileKind::from_file_type(&meta.file_type());
319            FsNode {
320                name,
321                path: normalize_path(&path),
322                kind,
323                loaded: !matches!(kind, FileKind::Directory),
324                loading: false,
325                error: None,
326                children: Vec::new(),
327            }
328        }
329        Err(err) => FsNode {
330            name,
331            path: normalize_path(&path),
332            kind: FileKind::Other,
333            loaded: true,
334            loading: false,
335            error: Some(err.to_string().into()),
336            children: Vec::new(),
337        },
338    }
339}
340
341fn display_name(path: &Path) -> Arc<str> {
342    path.file_name()
343        .and_then(|name| name.to_str())
344        .filter(|name| !name.is_empty())
345        .map(Arc::from)
346        .unwrap_or_else(|| Arc::<str>::from(path.to_string_lossy().as_ref()))
347}