1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//! The FilePicker's entry seam (backlog first-app/0273): [`FileSource`]
//! and [`FileEntry`] keep the WIDGET pure (hermetic tests, custom
//! backends — archives, remote listings), and [`StdFileSource`] is the
//! `std::fs` implementation beside it so apps get the one-liner.
//!
//! I/O contract: the picker calls [`FileSource::read_dir`] once per
//! NAVIGATION (mount, descend, parent) — never per frame, never from a
//! draw closure. `StdFileSource` reads synchronously on that
//! navigation turn: fine on a local filesystem; a network mount that
//! can stall is the app's risk, and the source seam is the escape
//! hatch (pre-list into a fake source, or debounce app-side).
//!
//! OWNER: REACT.
use std::rc::Rc;
/// One directory entry as the picker renders it. Author-written by
/// sources (ADR-0003 §2 class): plain struct with `Default` — custom
/// sources should construct via the [`FileEntry::dir`] /
/// [`FileEntry::file`] helpers or FRU over `Default`
/// (`FileEntry { name, ..Default::default() }`) so future field
/// additions stay non-breaking.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct FileEntry {
/// Entry name (no path separators). Non-UTF-8 names arrive
/// lossily converted by `StdFileSource` (the picker speaks
/// `String` — the terminal can only render text anyway).
pub name: String,
/// Directories descend on activation; files pick.
pub is_dir: bool,
/// Size in bytes for the optional size column; `None` hides it
/// for this entry (directories conventionally pass `None`).
pub size: Option<u64>,
}
impl FileEntry {
/// A directory entry.
pub fn dir(name: impl Into<String>) -> FileEntry {
FileEntry {
name: name.into(),
is_dir: true,
size: None,
}
}
/// A file entry with an optional size.
pub fn file(name: impl Into<String>, size: Option<u64>) -> FileEntry {
FileEntry {
name: name.into(),
is_dir: false,
size,
}
}
}
/// Where the picker's entries come from. Implementations list ONE
/// directory per call; errors are plain strings the picker renders
/// honestly in the list area (an unreadable directory is a fact, not
/// a panic). Entries render in the order returned — sorting is the
/// source's policy (see [`StdFileSource`] for the standard one).
pub trait FileSource {
/// List `path` (the picker passes the paths it navigates —
/// `start_in` joined with picked directory names, `..` never).
fn read_dir(&self, path: &str) -> Result<Vec<FileEntry>, String>;
}
// Rc<dyn FileSource> is what the picker holds; a blanket impl lets
// helpers take either.
impl FileSource for Rc<dyn FileSource> {
fn read_dir(&self, path: &str) -> Result<Vec<FileEntry>, String> {
(**self).read_dir(path)
}
}
/// The `std::fs` source: dirs first, case-insensitive name order
/// within each group, hidden entries (dot-prefixed) skipped unless
/// [`show_hidden`](StdFileSource::show_hidden). Symlinks resolve
/// through `fs::metadata`, so a symlink to a directory descends (the
/// file-manager convention); broken symlinks list as files without a
/// size.
#[derive(Clone, Debug, Default)]
pub struct StdFileSource {
show_hidden: bool,
}
impl StdFileSource {
/// Fresh source with hidden entries skipped.
pub fn new() -> StdFileSource {
StdFileSource::default()
}
/// Include dot-prefixed entries.
pub fn show_hidden(mut self, on: bool) -> StdFileSource {
self.show_hidden = on;
self
}
}
impl FileSource for StdFileSource {
fn read_dir(&self, path: &str) -> Result<Vec<FileEntry>, String> {
let iter = std::fs::read_dir(path).map_err(|e| e.to_string())?;
let mut out = Vec::new();
for entry in iter {
let entry = entry.map_err(|e| e.to_string())?;
let name = entry.file_name().to_string_lossy().into_owned();
if !self.show_hidden && name.starts_with('.') {
continue;
}
// metadata() FOLLOWS symlinks (a link to a dir descends);
// broken links degrade to a sizeless file entry rather
// than failing the whole listing.
match std::fs::metadata(entry.path()) {
Ok(meta) if meta.is_dir() => out.push(FileEntry::dir(name)),
Ok(meta) => out.push(FileEntry::file(name, Some(meta.len()))),
Err(_) => out.push(FileEntry::file(name, None)),
}
}
out.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
Ok(out)
}
}
#[cfg(test)]
#[path = "file_picker_source_tests.rs"]
mod tests;