use std::cmp::Ordering;
use std::io;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use crate::runtime::Command;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum PickMode {
#[default]
Files,
Folders,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileEntry {
name: String,
folder: bool,
size: Option<u64>,
}
impl FileEntry {
#[must_use]
pub fn name(&self) -> &str {
&self.name
}
#[must_use]
pub fn is_folder(&self) -> bool {
self.folder
}
#[must_use]
pub fn size(&self) -> Option<u64> {
self.size
}
#[must_use]
pub fn is_hidden(&self) -> bool {
self.name.starts_with('.')
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ListingError {
PermissionDenied,
NotFound,
NotAFolder,
Other(String),
}
impl ListingError {
fn from_io(error: &io::Error) -> Self {
match error.kind() {
io::ErrorKind::PermissionDenied => Self::PermissionDenied,
io::ErrorKind::NotFound => Self::NotFound,
io::ErrorKind::NotADirectory => Self::NotAFolder,
_ => Self::Other(error.to_string()),
}
}
}
pub type Listing = Result<Arc<[FileEntry]>, ListingError>;
pub fn read_folder(folder: &Path) -> Listing {
let entries = std::fs::read_dir(folder).map_err(|error| ListingError::from_io(&error))?;
let mut out: Vec<FileEntry> = entries
.filter_map(Result::ok)
.map(|entry| {
let path = entry.path();
let metadata = std::fs::metadata(&path).or_else(|_| entry.metadata()).ok();
let folder = metadata.as_ref().is_some_and(std::fs::Metadata::is_dir);
FileEntry {
name: entry.file_name().to_string_lossy().into_owned(),
folder,
size: metadata.filter(|_| !folder).map(|m| m.len()),
}
})
.collect();
out.sort_by(|a, b| match (a.folder, b.folder) {
(true, false) => Ordering::Less,
(false, true) => Ordering::Greater,
_ => a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)),
});
Ok(out.into())
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FilePickerMsg {
Open(PathBuf),
Loaded(PathBuf, Listing),
Select(Option<String>),
Filter(String),
ShowHidden(bool),
Refresh,
Chosen(PathBuf),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum FolderState {
Unread,
Ready(Arc<[FileEntry]>),
Failed(ListingError),
}
#[derive(Debug, Clone)]
pub struct FileBrowser {
pub(crate) folder: PathBuf,
pub(crate) state: FolderState,
pub(crate) loading: Option<PathBuf>,
pub(crate) mode: PickMode,
pub(crate) extensions: Vec<String>,
pub(crate) show_hidden: bool,
pub(crate) filter: String,
pub(crate) selected: Option<String>,
}
impl FileBrowser {
#[must_use]
pub fn new(folder: impl Into<PathBuf>, mode: PickMode) -> Self {
Self {
folder: folder.into(),
state: FolderState::Unread,
loading: None,
mode,
extensions: Vec::new(),
show_hidden: false,
filter: String::new(),
selected: None,
}
}
#[must_use]
pub fn extensions(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.extensions = extensions.into_iter().map(|e| e.into().to_lowercase()).collect();
self
}
#[must_use]
pub fn folder(&self) -> &Path {
&self.folder
}
#[must_use]
pub fn loading(&self) -> Option<&Path> {
self.loading.as_deref()
}
#[must_use]
pub fn shows_hidden(&self) -> bool {
self.show_hidden
}
#[must_use]
pub fn selected_path(&self) -> Option<PathBuf> {
self.selected.as_ref().map(|name| self.folder.join(name))
}
pub fn open<Msg: Send + 'static>(
&mut self,
folder: impl Into<PathBuf>,
wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
) -> Command<Msg> {
let folder = folder.into();
self.loading = Some(folder.clone());
Command::perform(move || {
let listing = read_folder(&folder);
wrap(FilePickerMsg::Loaded(folder, listing))
})
}
pub fn update<Msg: Send + 'static>(
&mut self,
message: FilePickerMsg,
wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
) -> Command<Msg> {
match message {
FilePickerMsg::Open(folder) => return self.open(folder, wrap),
FilePickerMsg::Refresh => {
let folder = self.loading.clone().unwrap_or_else(|| self.folder.clone());
return self.open(folder, wrap);
}
FilePickerMsg::Loaded(folder, listing)
if self.loading.as_ref().map_or(folder == self.folder, |awaited| *awaited == folder) =>
{
self.loading = None;
if folder != self.folder {
self.selected = self.child_name_towards(&folder);
self.filter.clear();
self.folder = folder;
}
self.state = match listing {
Ok(entries) => FolderState::Ready(entries),
Err(error) => FolderState::Failed(error),
};
if self.selected.is_none() {
self.selected = self.visible().first().map(|entry| entry.name.clone());
}
}
FilePickerMsg::Loaded(..) | FilePickerMsg::Chosen(_) => {}
FilePickerMsg::Select(name) => self.selected = name,
FilePickerMsg::Filter(text) => {
self.filter = text;
let visible = self.visible();
if !visible.iter().any(|entry| Some(&entry.name) == self.selected.as_ref()) {
self.selected = visible.first().map(|entry| entry.name.clone());
}
}
FilePickerMsg::ShowHidden(on) => self.show_hidden = on,
}
Command::none()
}
pub(crate) fn visible(&self) -> Vec<&FileEntry> {
let FolderState::Ready(entries) = &self.state else {
return Vec::new();
};
let query = self.filter.to_lowercase();
entries
.iter()
.filter(|entry| self.show_hidden || !entry.is_hidden())
.filter(|entry| entry.folder || self.extensions.is_empty() || self.extension_matches(&entry.name))
.filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
.collect()
}
fn extension_matches(&self, name: &str) -> bool {
Path::new(name)
.extension()
.is_some_and(|ext| self.extensions.iter().any(|wanted| ext.to_string_lossy().to_lowercase() == *wanted))
}
fn child_name_towards(&self, target: &Path) -> Option<String> {
let rest = self.folder.strip_prefix(target).ok()?;
rest.components().next().map(|c| c.as_os_str().to_string_lossy().into_owned())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn scratch(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("quvyta-a8-browser-{name}-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(dir.join("src")).expect("scratch folder");
for file in ["Cargo.toml", "README.md", ".env", "build.rs"] {
std::fs::write(dir.join(file), "x").expect("scratch file");
}
dir
}
#[test]
fn reads_folders_first_and_filters() {
let dir = scratch("filters");
let mut browser = FileBrowser::new(&dir, PickMode::Files).extensions(["RS", "toml"]);
let command: Command<FilePickerMsg> = browser.open(dir.clone(), |m| m);
assert_eq!(command.actions.len(), 1);
let listing = read_folder(&dir);
let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), listing), |m| m);
let names: Vec<&str> = browser.visible().iter().map(|e| e.name()).collect();
assert_eq!(names, ["src", "build.rs", "Cargo.toml"]);
assert_eq!(browser.selected.as_deref(), Some("src"));
let _ = browser.update(FilePickerMsg::Filter("CARGO".into()), |m| m);
assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
let mut all = FileBrowser::new(&dir, PickMode::Files);
all.state = FolderState::Ready(read_folder(&dir).expect("readable"));
assert_eq!(all.visible().len(), 4);
let _ = all.update(FilePickerMsg::ShowHidden(true), |m| m);
assert_eq!(all.visible().len(), 5);
std::fs::remove_dir_all(dir).ok();
}
#[test]
fn errors_become_states_and_stale_answers_are_ignored() {
let missing = std::env::temp_dir().join("quvyta-a8-no-such-folder");
assert_eq!(read_folder(&missing), Err(ListingError::NotFound));
let mut browser = FileBrowser::new("/", PickMode::Folders);
let _ = browser.update(FilePickerMsg::Open(missing.clone()), |m| m);
let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/elsewhere"), Ok(Arc::from(Vec::new()))), |m| m);
assert_eq!(browser.state, FolderState::Unread);
assert_eq!(browser.loading(), Some(missing.as_path()));
let _ = browser.update(FilePickerMsg::Loaded(missing.clone(), read_folder(&missing)), |m| m);
assert_eq!(browser.state, FolderState::Failed(ListingError::NotFound));
assert_eq!((browser.folder(), browser.loading()), (missing.as_path(), None));
}
#[test]
fn going_up_keeps_the_folder_we_left_selected() {
let mut browser = FileBrowser::new("/home/ada/projects", PickMode::Files);
let _ = browser.update(FilePickerMsg::Open(PathBuf::from("/home")), |m| m);
let entries = ["ada", "guest"].map(|name| FileEntry { name: name.to_owned(), folder: true, size: None });
let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/home"), Ok(Arc::from(entries))), |m| m);
assert_eq!(browser.selected.as_deref(), Some("ada"));
}
#[test]
fn the_shown_folder_stays_until_the_new_one_is_read() {
let dir = scratch("stays");
let mut browser = FileBrowser::new(&dir, PickMode::Files);
let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), |m| m);
let _ = browser.update(FilePickerMsg::Filter("cargo".into()), |m| m);
let src = dir.join("src");
let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
assert_eq!(browser.folder(), dir.as_path());
assert_eq!(browser.loading(), Some(src.as_path()));
assert_eq!(browser.filter, "cargo");
assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
assert_eq!(browser.visible().len(), 1);
let _ = browser.update(FilePickerMsg::Open(dir.clone()), |m| m);
let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
assert_eq!(browser.folder(), dir.as_path());
let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
assert_eq!((browser.folder(), browser.loading()), (src.as_path(), None));
assert_eq!(browser.filter, "");
std::fs::remove_dir_all(dir).ok();
}
}