Skip to main content

tui_file_explorer/
filesystem.rs

1//! Filesystem backends: the [`FileSystem`] trait the explorer browses through,
2//! the local and trash-backed implementations, and [`VirtualEntry`] for
3//! virtual backends.
4
5use std::{
6    fmt, fs, io,
7    path::{Path, PathBuf},
8    sync::Arc,
9};
10
11use crate::{FsEntry, types::DiskUsage};
12
13/// Filesystem backend used by explorer widgets.
14///
15/// Implement this trait to browse an in-memory, remote, archive, or otherwise
16/// virtual filesystem while keeping the same state machine and renderer.
17pub trait FileSystem: fmt::Debug + Send + Sync {
18    /// List the entries of the directory at `path`.
19    fn read_dir(&self, path: &Path) -> io::Result<Vec<FsEntry>>;
20    /// Create the directory at `path` and any missing parents.
21    fn create_dir_all(&self, path: &Path) -> io::Result<()>;
22    /// Create an empty file at `path`.
23    fn create_file(&self, path: &Path) -> io::Result<()>;
24    /// Rename or move `source` to `destination`.
25    fn rename(&self, source: &Path, destination: &Path) -> io::Result<()>;
26    /// Copy the file `source` to `destination`.
27    fn copy_file(&self, source: &Path, destination: &Path) -> io::Result<()>;
28    /// Copy the directory `source` and everything under it to `destination`.
29    fn copy_dir(&self, source: &Path, destination: &Path) -> io::Result<()>;
30    /// Remove the file at `path`.
31    fn remove_file(&self, path: &Path) -> io::Result<()>;
32    /// Remove the directory at `path` and everything under it.
33    fn remove_dir_all(&self, path: &Path) -> io::Result<()>;
34    /// `true` when something exists at `path`.
35    fn exists(&self, path: &Path) -> bool;
36    /// `true` when `path` is a directory.
37    fn is_dir(&self, path: &Path) -> bool;
38    /// Space on the volume holding `path`, or `None` when unknown.
39    fn disk_usage(&self, path: &Path) -> Option<DiskUsage>;
40    /// Total size in bytes of the files under `path`, and whether the walk
41    /// stopped early (then the size is a lower bound).
42    fn dir_size(&self, path: &Path) -> (u64, bool);
43
44    /// Number of immediate children of the directory at `path`.
45    ///
46    /// Called lazily for the rows that are actually visible, so
47    /// [`read_dir`](Self::read_dir) can leave
48    /// [`FsEntry::item_count`] unset and stay fast on huge directories.
49    /// The default reports "unknown".
50    fn item_count(&self, _path: &Path) -> Option<usize> {
51        None
52    }
53
54    /// Bring a previously trashed `path` back to where it was deleted from.
55    ///
56    /// Only trash-backed filesystems on platforms with a restorable trash
57    /// (Linux, Windows) support this; the default reports
58    /// [`io::ErrorKind::Unsupported`].
59    fn restore_deleted(&self, _path: &Path) -> io::Result<()> {
60        Err(io::Error::new(
61            io::ErrorKind::Unsupported,
62            "restoring deleted items is not supported here",
63        ))
64    }
65
66    /// `true` when paths are real OS paths (symlink targets, `git status` and
67    /// similar OS-level lookups are meaningful). Virtual backends keep the
68    /// default `false`.
69    fn is_local(&self) -> bool {
70        false
71    }
72}
73
74/// A [`FileSystem`] shared between panes and threads.
75pub type SharedFileSystem = Arc<dyn FileSystem>;
76
77/// Native local-filesystem backend.
78#[derive(Debug, Clone, Copy, Default)]
79pub struct StdFileSystem;
80
81impl FileSystem for StdFileSystem {
82    fn read_dir(&self, path: &Path) -> io::Result<Vec<FsEntry>> {
83        let mut entries = Vec::new();
84        for entry in fs::read_dir(path)?.flatten() {
85            let path = entry.path();
86            let name = entry.file_name().to_string_lossy().into_owned();
87            let Ok(metadata) = entry.metadata() else {
88                continue;
89            };
90            let is_dir = metadata.is_dir();
91            let extension = if is_dir {
92                String::new()
93            } else {
94                path.extension()
95                    .map(|value| value.to_string_lossy().to_lowercase())
96                    .unwrap_or_default()
97            };
98            entries.push(FsEntry {
99                name,
100                path,
101                is_dir,
102                size: (!is_dir).then_some(metadata.len()),
103                // Filled lazily for visible rows (see `FileSystem::item_count`).
104                item_count: None,
105                extension,
106            });
107        }
108        Ok(entries)
109    }
110
111    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
112        fs::create_dir_all(path)
113    }
114
115    fn create_file(&self, path: &Path) -> io::Result<()> {
116        if let Some(parent) = path.parent() {
117            fs::create_dir_all(parent)?;
118        }
119        fs::OpenOptions::new()
120            .write(true)
121            .create(true)
122            .truncate(false)
123            .open(path)
124            .map(|_| ())
125    }
126
127    fn rename(&self, source: &Path, destination: &Path) -> io::Result<()> {
128        fs::rename(source, destination)
129    }
130
131    fn copy_file(&self, source: &Path, destination: &Path) -> io::Result<()> {
132        fs::copy(source, destination).map(|_| ())
133    }
134
135    fn copy_dir(&self, source: &Path, destination: &Path) -> io::Result<()> {
136        crate::fs_util::copy_dir_all(source, destination)
137    }
138
139    fn remove_file(&self, path: &Path) -> io::Result<()> {
140        fs::remove_file(path)
141    }
142
143    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
144        fs::remove_dir_all(path)
145    }
146
147    fn exists(&self, path: &Path) -> bool {
148        path.exists()
149    }
150
151    fn is_dir(&self, path: &Path) -> bool {
152        path.is_dir()
153    }
154
155    fn disk_usage(&self, path: &Path) -> Option<DiskUsage> {
156        crate::fs_util::disk_usage(path)
157    }
158
159    fn dir_size(&self, path: &Path) -> (u64, bool) {
160        crate::fs_util::dir_size(path)
161    }
162
163    fn item_count(&self, path: &Path) -> Option<usize> {
164        fs::read_dir(path).ok().map(|items| items.flatten().count())
165    }
166
167    fn is_local(&self) -> bool {
168        true
169    }
170}
171
172/// Local filesystem that sends removals to the OS trash / recycle bin.
173///
174/// Everything except [`remove_file`](FileSystem::remove_file) and
175/// [`remove_dir_all`](FileSystem::remove_dir_all) behaves exactly like
176/// [`StdFileSystem`]. If the trash is unavailable (e.g. a headless Linux
177/// box without a trash directory) the removal **fails** rather than silently
178/// falling back to permanent deletion.
179#[cfg(feature = "trash")]
180#[derive(Debug, Clone, Copy, Default)]
181pub struct TrashFileSystem;
182
183#[cfg(feature = "trash")]
184impl FileSystem for TrashFileSystem {
185    fn read_dir(&self, path: &Path) -> io::Result<Vec<FsEntry>> {
186        StdFileSystem.read_dir(path)
187    }
188    fn create_dir_all(&self, path: &Path) -> io::Result<()> {
189        StdFileSystem.create_dir_all(path)
190    }
191    fn create_file(&self, path: &Path) -> io::Result<()> {
192        StdFileSystem.create_file(path)
193    }
194    fn rename(&self, source: &Path, destination: &Path) -> io::Result<()> {
195        StdFileSystem.rename(source, destination)
196    }
197    fn copy_file(&self, source: &Path, destination: &Path) -> io::Result<()> {
198        StdFileSystem.copy_file(source, destination)
199    }
200    fn copy_dir(&self, source: &Path, destination: &Path) -> io::Result<()> {
201        StdFileSystem.copy_dir(source, destination)
202    }
203    fn remove_file(&self, path: &Path) -> io::Result<()> {
204        trash::delete(path).map_err(io::Error::other)
205    }
206    fn remove_dir_all(&self, path: &Path) -> io::Result<()> {
207        trash::delete(path).map_err(io::Error::other)
208    }
209    fn exists(&self, path: &Path) -> bool {
210        StdFileSystem.exists(path)
211    }
212    fn is_dir(&self, path: &Path) -> bool {
213        StdFileSystem.is_dir(path)
214    }
215    fn disk_usage(&self, path: &Path) -> Option<DiskUsage> {
216        StdFileSystem.disk_usage(path)
217    }
218    fn dir_size(&self, path: &Path) -> (u64, bool) {
219        StdFileSystem.dir_size(path)
220    }
221    fn item_count(&self, path: &Path) -> Option<usize> {
222        StdFileSystem.item_count(path)
223    }
224    fn is_local(&self) -> bool {
225        true
226    }
227
228    #[cfg(any(
229        target_os = "windows",
230        all(
231            unix,
232            not(target_os = "macos"),
233            not(target_os = "ios"),
234            not(target_os = "android")
235        )
236    ))]
237    fn restore_deleted(&self, path: &Path) -> io::Result<()> {
238        // Newest trashed item that came from `path`.
239        let item = trash::os_limited::list()
240            .map_err(io::Error::other)?
241            .into_iter()
242            .filter(|i| i.original_path() == path)
243            .max_by_key(|i| i.time_deleted)
244            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "not found in the trash"))?;
245        trash::os_limited::restore_all([item]).map_err(io::Error::other)
246    }
247}
248
249/// `true` when this build can send deletions to the OS trash.
250pub const TRASH_SUPPORTED: bool = cfg!(feature = "trash");
251
252/// Local filesystem backend, trash-backed when `use_trash` is requested *and*
253/// the `trash` feature is compiled in; plain [`StdFileSystem`] otherwise.
254#[must_use]
255pub fn local_filesystem(use_trash: bool) -> SharedFileSystem {
256    #[cfg(feature = "trash")]
257    if use_trash {
258        return Arc::new(TrashFileSystem);
259    }
260    let _ = use_trash;
261    Arc::new(StdFileSystem)
262}
263
264/// The local filesystem; removals delete for good.
265#[must_use]
266pub fn std_filesystem() -> SharedFileSystem {
267    Arc::new(StdFileSystem)
268}
269
270/// Convenience type for virtual backends that need to construct entries.
271#[derive(Debug, Clone, PartialEq, Eq)]
272pub struct VirtualEntry {
273    /// File name shown in the list.
274    pub name: String,
275    /// Full path of the entry.
276    pub path: PathBuf,
277    /// `true` for directories.
278    pub is_dir: bool,
279    /// Size in bytes; `None` for directories or when unknown.
280    pub size: Option<u64>,
281    /// Number of direct children for directories, if known.
282    pub item_count: Option<usize>,
283    /// Lower-case extension without the dot; empty when there is none.
284    pub extension: String,
285}
286
287impl From<VirtualEntry> for FsEntry {
288    fn from(entry: VirtualEntry) -> Self {
289        Self {
290            name: entry.name,
291            path: entry.path,
292            is_dir: entry.is_dir,
293            size: entry.size,
294            item_count: entry.item_count,
295            extension: entry.extension,
296        }
297    }
298}
299
300#[cfg(test)]
301mod tests {
302    use super::*;
303    use crate::FileExplorer;
304
305    #[derive(Debug)]
306    struct MemoryFileSystem;
307
308    impl FileSystem for MemoryFileSystem {
309        fn read_dir(&self, path: &Path) -> io::Result<Vec<FsEntry>> {
310            Ok(vec![
311                VirtualEntry {
312                    name: "virtual.txt".into(),
313                    path: path.join("virtual.txt"),
314                    is_dir: false,
315                    size: Some(7),
316                    item_count: None,
317                    extension: "txt".into(),
318                }
319                .into(),
320            ])
321        }
322
323        fn create_dir_all(&self, _: &Path) -> io::Result<()> {
324            Ok(())
325        }
326        fn create_file(&self, _: &Path) -> io::Result<()> {
327            Ok(())
328        }
329        fn rename(&self, _: &Path, _: &Path) -> io::Result<()> {
330            Ok(())
331        }
332        fn copy_file(&self, _: &Path, _: &Path) -> io::Result<()> {
333            Ok(())
334        }
335        fn copy_dir(&self, _: &Path, _: &Path) -> io::Result<()> {
336            Ok(())
337        }
338        fn remove_file(&self, _: &Path) -> io::Result<()> {
339            Ok(())
340        }
341        fn remove_dir_all(&self, _: &Path) -> io::Result<()> {
342            Ok(())
343        }
344        fn exists(&self, _: &Path) -> bool {
345            true
346        }
347        fn is_dir(&self, _: &Path) -> bool {
348            false
349        }
350        fn disk_usage(&self, _: &Path) -> Option<DiskUsage> {
351            None
352        }
353        fn dir_size(&self, _: &Path) -> (u64, bool) {
354            (0, false)
355        }
356    }
357
358    #[test]
359    fn explorer_reads_from_custom_filesystem() {
360        let explorer = FileExplorer::builder(PathBuf::from("virtual:/"))
361            .filesystem(Arc::new(MemoryFileSystem))
362            .try_build()
363            .unwrap();
364        assert_eq!(explorer.entries.len(), 1);
365        assert_eq!(explorer.entries[0].name, "virtual.txt");
366    }
367}