Skip to main content

qframe/widgets/
file_browser.rs

1//! The state behind [`FilePicker`](super::FilePicker): where it is, what the folder holds,
2//! filters and the selection, with folder reading done off the render path.
3
4use std::cmp::Ordering;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use crate::runtime::Command;
10
11/// Whether a picker chooses files or folders.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum PickMode {
14    /// Choose a file; folders are opened.
15    #[default]
16    Files,
17    /// Choose a folder; files are shown faint. The button chooses the folder shown unless the
18    /// user moved the cursor to a folder inside it.
19    Folders,
20}
21
22/// One entry of a folder.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FileEntry {
25    name: String,
26    folder: bool,
27    size: Option<u64>,
28}
29
30impl FileEntry {
31    /// The file or folder name.
32    #[must_use]
33    pub fn name(&self) -> &str {
34        &self.name
35    }
36
37    /// Whether the entry is a folder (links to folders count).
38    #[must_use]
39    pub fn is_folder(&self) -> bool {
40        self.folder
41    }
42
43    /// The size of a file in bytes, when it could be read.
44    #[must_use]
45    pub fn size(&self) -> Option<u64> {
46        self.size
47    }
48
49    /// Whether the name starts with a dot.
50    #[must_use]
51    pub fn is_hidden(&self) -> bool {
52        self.name.starts_with('.')
53    }
54}
55
56/// Why a folder could not be read.
57#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ListingError {
59    /// The user may not read it.
60    PermissionDenied,
61    /// It does not exist (any more).
62    NotFound,
63    /// The path is not a folder.
64    NotAFolder,
65    /// Anything else, with the system's message.
66    Other(String),
67}
68
69impl ListingError {
70    fn from_io(error: &io::Error) -> Self {
71        match error.kind() {
72            io::ErrorKind::PermissionDenied => Self::PermissionDenied,
73            io::ErrorKind::NotFound => Self::NotFound,
74            io::ErrorKind::NotADirectory => Self::NotAFolder,
75            _ => Self::Other(error.to_string()),
76        }
77    }
78}
79
80/// What reading a folder gave.
81pub type Listing = Result<Arc<[FileEntry]>, ListingError>;
82
83/// Reads the entries of `folder`, folders first, then by name ignoring case. Blocks on the file
84/// system: call it from [`Command::perform`], never from `view`.
85///
86/// # Errors
87///
88/// Returns why the folder could not be read.
89pub fn read_folder(folder: &Path) -> Listing {
90    let entries = std::fs::read_dir(folder).map_err(|error| ListingError::from_io(&error))?;
91    let mut out: Vec<FileEntry> = entries
92        // An entry the folder stream could not hand over is skipped rather than failing the whole
93        // folder: the rest of the folder is what the person is looking at, and a folder that
94        // refuses one name is not a folder that cannot be read. The reason a folder as a whole
95        // could not be read is carried up as `ListingError` above.
96        .filter_map(Result::ok)
97        .map(|entry| {
98            let path = entry.path();
99            // `fs::metadata` follows links, so a link to a folder opens like a folder.
100            let metadata = std::fs::metadata(&path).or_else(|_| entry.metadata()).ok();
101            let folder = metadata.as_ref().is_some_and(std::fs::Metadata::is_dir);
102            FileEntry {
103                name: entry.file_name().to_string_lossy().into_owned(),
104                folder,
105                size: metadata.filter(|_| !folder).map(|m| m.len()),
106            }
107        })
108        .collect();
109    out.sort_by(|a, b| match (a.folder, b.folder) {
110        (true, false) => Ordering::Less,
111        (false, true) => Ordering::Greater,
112        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)),
113    });
114    Ok(out.into())
115}
116
117/// Something that happened in a [`FilePicker`](super::FilePicker). Hand every message to
118/// [`FileBrowser::update`], except [`FilePickerMsg::Chosen`], which is the result.
119#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum FilePickerMsg {
121    /// Go to a folder.
122    Open(PathBuf),
123    /// A folder was read.
124    Loaded(PathBuf, Listing),
125    /// The selection moved to the entry with this name; `None` is the parent row.
126    Select(Option<String>),
127    /// The filter text changed.
128    Filter(String),
129    /// Hidden entries were shown or hidden.
130    ShowHidden(bool),
131    /// Read the current folder again.
132    Refresh,
133    /// The user chose this path.
134    Chosen(PathBuf),
135}
136
137/// What the shown folder holds.
138#[derive(Debug, Clone, PartialEq, Eq)]
139pub(crate) enum FolderState {
140    /// No read has answered yet.
141    Unread,
142    Ready(Arc<[FileEntry]>),
143    Failed(ListingError),
144}
145
146/// The application-owned state of a [`FilePicker`](super::FilePicker).
147///
148/// Create it with a start folder, start reading with [`FileBrowser::open`] and pass every
149/// [`FilePickerMsg`] to [`FileBrowser::update`]; both return the command that reads folders on
150/// a background thread.
151///
152/// While a folder is being read, everything shown stays as it was: the folder, its entries, the
153/// filter and the selection change together only when the answer arrives, so quick reads never
154/// flash an empty or loading state. [`FileBrowser::loading`] tells which folder is on its way.
155#[derive(Debug, Clone)]
156pub struct FileBrowser {
157    pub(crate) folder: PathBuf,
158    pub(crate) state: FolderState,
159    /// The folder being read, until its answer arrives.
160    pub(crate) loading: Option<PathBuf>,
161    pub(crate) mode: PickMode,
162    pub(crate) extensions: Vec<String>,
163    pub(crate) show_hidden: bool,
164    pub(crate) filter: String,
165    pub(crate) selected: Option<String>,
166    /// The person put the selection where it is. A folder that opens puts it on an entry by
167    /// itself so the keys have somewhere to start, and that is not a choice: in folder mode it
168    /// would choose a folder the person never pointed at.
169    pub(crate) picked: bool,
170}
171
172impl FileBrowser {
173    /// A browser at `folder` choosing in `mode`. Nothing is read until [`FileBrowser::open`].
174    #[must_use]
175    pub fn new(folder: impl Into<PathBuf>, mode: PickMode) -> Self {
176        Self {
177            folder: folder.into(),
178            state: FolderState::Unread,
179            loading: None,
180            mode,
181            extensions: Vec::new(),
182            show_hidden: false,
183            filter: String::new(),
184            selected: None,
185            picked: false,
186        }
187    }
188
189    /// Shows only files with these extensions (without the dot, any case); folders always show.
190    #[must_use]
191    pub fn extensions(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
192        self.extensions = extensions.into_iter().map(|e| e.into().to_lowercase()).collect();
193        self
194    }
195
196    /// The folder shown. While another folder is read it stays the one shown until the answer
197    /// arrives.
198    #[must_use]
199    pub fn folder(&self) -> &Path {
200        &self.folder
201    }
202
203    /// The folder being read, from [`FileBrowser::open`] until its answer arrives.
204    #[must_use]
205    pub fn loading(&self) -> Option<&Path> {
206        self.loading.as_deref()
207    }
208
209    /// Whether hidden entries are shown.
210    #[must_use]
211    pub fn shows_hidden(&self) -> bool {
212        self.show_hidden
213    }
214
215    /// The path of the selected entry, if any.
216    #[must_use]
217    pub fn selected_path(&self) -> Option<PathBuf> {
218        self.selected.as_ref().map(|name| self.folder.join(name))
219    }
220
221    /// Reads `folder` in the background and goes there when it has been read; `wrap` turns
222    /// picker messages into yours. The folder shown stays until then, and a later `open` makes
223    /// the answer of this one stale.
224    ///
225    /// `wrap` is a function such as `Msg::Picker` or a closure that captures what it needs. It
226    /// runs once, on the background thread that read the folder, so it must be `Send`; it is
227    /// never copied, so it need not be `Clone`.
228    pub fn open<Msg: Send + 'static>(
229        &mut self,
230        folder: impl Into<PathBuf>,
231        wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
232    ) -> Command<Msg> {
233        let folder = folder.into();
234        self.loading = Some(folder.clone());
235        Command::perform(move || {
236            let listing = read_folder(&folder);
237            wrap(FilePickerMsg::Loaded(folder, listing))
238        })
239    }
240
241    /// Applies a picker message. [`FilePickerMsg::Chosen`] changes nothing; act on it yourself.
242    /// `wrap` is used as by [`open`](Self::open), for the folder a message asks to read.
243    pub fn update<Msg: Send + 'static>(
244        &mut self,
245        message: FilePickerMsg,
246        wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
247    ) -> Command<Msg> {
248        match message {
249            FilePickerMsg::Open(folder) => return self.open(folder, wrap),
250            FilePickerMsg::Refresh => {
251                let folder = self.loading.clone().unwrap_or_else(|| self.folder.clone());
252                return self.open(folder, wrap);
253            }
254            // The answer awaited, or an answer for the folder shown when nothing else is awaited
255            // (a folder read before the first view, for example).
256            FilePickerMsg::Loaded(folder, listing)
257                if self.loading.as_ref().map_or(folder == self.folder, |awaited| *awaited == folder) =>
258            {
259                self.loading = None;
260                if folder != self.folder {
261                    self.selected = self.child_name_towards(&folder);
262                    self.picked = false;
263                    self.filter.clear();
264                    self.folder = folder;
265                }
266                self.state = match listing {
267                    Ok(entries) => FolderState::Ready(entries),
268                    Err(error) => FolderState::Failed(error),
269                };
270                if self.selected.is_none() {
271                    self.selected = self.visible().first().map(|entry| entry.name.clone());
272                }
273            }
274            // An answer for a folder the user no longer waits for.
275            FilePickerMsg::Loaded(..) | FilePickerMsg::Chosen(_) => {}
276            FilePickerMsg::Select(name) => {
277                self.picked = name.is_some();
278                self.selected = name;
279            }
280            FilePickerMsg::Filter(text) => {
281                self.filter = text;
282                let visible = self.visible();
283                if !visible.iter().any(|entry| Some(&entry.name) == self.selected.as_ref()) {
284                    self.selected = visible.first().map(|entry| entry.name.clone());
285                    self.picked = false;
286                }
287            }
288            FilePickerMsg::ShowHidden(on) => self.show_hidden = on,
289        }
290        Command::none()
291    }
292
293    /// The entries that pass the hidden, extension and name filters.
294    pub(crate) fn visible(&self) -> Vec<&FileEntry> {
295        let FolderState::Ready(entries) = &self.state else {
296            return Vec::new();
297        };
298        let query = self.filter.to_lowercase();
299        entries
300            .iter()
301            .filter(|entry| self.show_hidden || !entry.is_hidden())
302            .filter(|entry| entry.folder || self.extensions.is_empty() || self.extension_matches(&entry.name))
303            .filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
304            .collect()
305    }
306
307    fn extension_matches(&self, name: &str) -> bool {
308        Path::new(name)
309            .extension()
310            .is_some_and(|ext| self.extensions.iter().any(|wanted| ext.to_string_lossy().to_lowercase() == *wanted))
311    }
312
313    /// When going up, the folder we came from stays selected.
314    fn child_name_towards(&self, target: &Path) -> Option<String> {
315        let rest = self.folder.strip_prefix(target).ok()?;
316        rest.components().next().map(|c| c.as_os_str().to_string_lossy().into_owned())
317    }
318}
319
320#[cfg(test)]
321mod tests {
322    use super::*;
323
324    fn scratch(name: &str) -> PathBuf {
325        let dir = std::env::temp_dir().join(format!("quvyta-a8-browser-{name}-{}", std::process::id()));
326        let _ = std::fs::remove_dir_all(&dir);
327        std::fs::create_dir_all(dir.join("src")).expect("scratch folder");
328        for file in ["Cargo.toml", "README.md", ".env", "build.rs"] {
329            std::fs::write(dir.join(file), "x").expect("scratch file");
330        }
331        dir
332    }
333
334    #[test]
335    fn reads_folders_first_and_filters() {
336        let dir = scratch("filters");
337        let mut browser = FileBrowser::new(&dir, PickMode::Files).extensions(["RS", "toml"]);
338        let command: Command<FilePickerMsg> = browser.open(dir.clone(), |m| m);
339        assert_eq!(command.actions.len(), 1);
340        let listing = read_folder(&dir);
341        let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), listing), |m| m);
342        let names: Vec<&str> = browser.visible().iter().map(|e| e.name()).collect();
343        assert_eq!(names, ["src", "build.rs", "Cargo.toml"]);
344        assert_eq!(browser.selected.as_deref(), Some("src"));
345        let _ = browser.update(FilePickerMsg::Filter("CARGO".into()), |m| m);
346        assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
347        let mut all = FileBrowser::new(&dir, PickMode::Files);
348        all.state = FolderState::Ready(read_folder(&dir).expect("readable"));
349        assert_eq!(all.visible().len(), 4);
350        let _ = all.update(FilePickerMsg::ShowHidden(true), |m| m);
351        assert_eq!(all.visible().len(), 5);
352        std::fs::remove_dir_all(dir).ok();
353    }
354
355    #[test]
356    fn errors_become_states_and_stale_answers_are_ignored() {
357        let missing = std::env::temp_dir().join("quvyta-a8-no-such-folder");
358        assert_eq!(read_folder(&missing), Err(ListingError::NotFound));
359        let mut browser = FileBrowser::new("/", PickMode::Folders);
360        let _ = browser.update(FilePickerMsg::Open(missing.clone()), |m| m);
361        let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/elsewhere"), Ok(Arc::from(Vec::new()))), |m| m);
362        assert_eq!(browser.state, FolderState::Unread);
363        assert_eq!(browser.loading(), Some(missing.as_path()));
364        let _ = browser.update(FilePickerMsg::Loaded(missing.clone(), read_folder(&missing)), |m| m);
365        assert_eq!(browser.state, FolderState::Failed(ListingError::NotFound));
366        assert_eq!((browser.folder(), browser.loading()), (missing.as_path(), None));
367    }
368
369    #[test]
370    fn going_up_keeps_the_folder_we_left_selected() {
371        let mut browser = FileBrowser::new("/home/ada/projects", PickMode::Files);
372        let _ = browser.update(FilePickerMsg::Open(PathBuf::from("/home")), |m| m);
373        let entries = ["ada", "guest"].map(|name| FileEntry { name: name.to_owned(), folder: true, size: None });
374        let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/home"), Ok(Arc::from(entries))), |m| m);
375        assert_eq!(browser.selected.as_deref(), Some("ada"));
376    }
377
378    #[test]
379    fn the_shown_folder_stays_until_the_new_one_is_read() {
380        let dir = scratch("stays");
381        let mut browser = FileBrowser::new(&dir, PickMode::Files);
382        let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), |m| m);
383        let _ = browser.update(FilePickerMsg::Filter("cargo".into()), |m| m);
384        let src = dir.join("src");
385        let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
386        // Nothing shown changes while `src` is read: folder, entries, filter and selection.
387        assert_eq!(browser.folder(), dir.as_path());
388        assert_eq!(browser.loading(), Some(src.as_path()));
389        assert_eq!(browser.filter, "cargo");
390        assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
391        assert_eq!(browser.visible().len(), 1);
392        // A newer open makes the older answer stale.
393        let _ = browser.update(FilePickerMsg::Open(dir.clone()), |m| m);
394        let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
395        assert_eq!(browser.folder(), dir.as_path());
396        let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
397        let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
398        assert_eq!((browser.folder(), browser.loading()), (src.as_path(), None));
399        assert_eq!(browser.filter, "");
400        std::fs::remove_dir_all(dir).ok();
401    }
402}