1use std::cmp::Ordering;
5use std::io;
6use std::path::{Path, PathBuf};
7use std::sync::Arc;
8
9use crate::runtime::Command;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
13pub enum PickMode {
14 #[default]
16 Files,
17 Folders,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct FileEntry {
25 name: String,
26 folder: bool,
27 size: Option<u64>,
28}
29
30impl FileEntry {
31 #[must_use]
33 pub fn name(&self) -> &str {
34 &self.name
35 }
36
37 #[must_use]
39 pub fn is_folder(&self) -> bool {
40 self.folder
41 }
42
43 #[must_use]
45 pub fn size(&self) -> Option<u64> {
46 self.size
47 }
48
49 #[must_use]
51 pub fn is_hidden(&self) -> bool {
52 self.name.starts_with('.')
53 }
54}
55
56#[derive(Debug, Clone, PartialEq, Eq)]
58pub enum ListingError {
59 PermissionDenied,
61 NotFound,
63 NotAFolder,
65 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
80pub type Listing = Result<Arc<[FileEntry]>, ListingError>;
82
83pub 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 .filter_map(Result::ok)
97 .map(|entry| {
98 let path = entry.path();
99 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#[derive(Debug, Clone, PartialEq, Eq)]
120pub enum FilePickerMsg {
121 Open(PathBuf),
123 Loaded(PathBuf, Listing),
125 Select(Option<String>),
127 Filter(String),
129 ShowHidden(bool),
131 Refresh,
133 Chosen(PathBuf),
135}
136
137#[derive(Debug, Clone, PartialEq, Eq)]
139pub(crate) enum FolderState {
140 Unread,
142 Ready(Arc<[FileEntry]>),
143 Failed(ListingError),
144}
145
146#[derive(Debug, Clone)]
156pub struct FileBrowser {
157 pub(crate) folder: PathBuf,
158 pub(crate) state: FolderState,
159 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 pub(crate) picked: bool,
170}
171
172impl FileBrowser {
173 #[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 #[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 #[must_use]
199 pub fn folder(&self) -> &Path {
200 &self.folder
201 }
202
203 #[must_use]
205 pub fn loading(&self) -> Option<&Path> {
206 self.loading.as_deref()
207 }
208
209 #[must_use]
211 pub fn shows_hidden(&self) -> bool {
212 self.show_hidden
213 }
214
215 #[must_use]
217 pub fn selected_path(&self) -> Option<PathBuf> {
218 self.selected.as_ref().map(|name| self.folder.join(name))
219 }
220
221 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 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 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 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 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 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 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 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}