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,
19}
20
21#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct FileEntry {
24 name: String,
25 folder: bool,
26 size: Option<u64>,
27}
28
29impl FileEntry {
30 #[must_use]
32 pub fn name(&self) -> &str {
33 &self.name
34 }
35
36 #[must_use]
38 pub fn is_folder(&self) -> bool {
39 self.folder
40 }
41
42 #[must_use]
44 pub fn size(&self) -> Option<u64> {
45 self.size
46 }
47
48 #[must_use]
50 pub fn is_hidden(&self) -> bool {
51 self.name.starts_with('.')
52 }
53}
54
55#[derive(Debug, Clone, PartialEq, Eq)]
57pub enum ListingError {
58 PermissionDenied,
60 NotFound,
62 NotAFolder,
64 Other(String),
66}
67
68impl ListingError {
69 fn from_io(error: &io::Error) -> Self {
70 match error.kind() {
71 io::ErrorKind::PermissionDenied => Self::PermissionDenied,
72 io::ErrorKind::NotFound => Self::NotFound,
73 io::ErrorKind::NotADirectory => Self::NotAFolder,
74 _ => Self::Other(error.to_string()),
75 }
76 }
77}
78
79pub type Listing = Result<Arc<[FileEntry]>, ListingError>;
81
82pub fn read_folder(folder: &Path) -> Listing {
89 let entries = std::fs::read_dir(folder).map_err(|error| ListingError::from_io(&error))?;
90 let mut out: Vec<FileEntry> = entries
91 .filter_map(Result::ok)
96 .map(|entry| {
97 let path = entry.path();
98 let metadata = std::fs::metadata(&path).or_else(|_| entry.metadata()).ok();
100 let folder = metadata.as_ref().is_some_and(std::fs::Metadata::is_dir);
101 FileEntry {
102 name: entry.file_name().to_string_lossy().into_owned(),
103 folder,
104 size: metadata.filter(|_| !folder).map(|m| m.len()),
105 }
106 })
107 .collect();
108 out.sort_by(|a, b| match (a.folder, b.folder) {
109 (true, false) => Ordering::Less,
110 (false, true) => Ordering::Greater,
111 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)),
112 });
113 Ok(out.into())
114}
115
116#[derive(Debug, Clone, PartialEq, Eq)]
119pub enum FilePickerMsg {
120 Open(PathBuf),
122 Loaded(PathBuf, Listing),
124 Select(Option<String>),
126 Filter(String),
128 ShowHidden(bool),
130 Refresh,
132 Chosen(PathBuf),
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub(crate) enum FolderState {
139 Unread,
141 Ready(Arc<[FileEntry]>),
142 Failed(ListingError),
143}
144
145#[derive(Debug, Clone)]
155pub struct FileBrowser {
156 pub(crate) folder: PathBuf,
157 pub(crate) state: FolderState,
158 pub(crate) loading: Option<PathBuf>,
160 pub(crate) mode: PickMode,
161 pub(crate) extensions: Vec<String>,
162 pub(crate) show_hidden: bool,
163 pub(crate) filter: String,
164 pub(crate) selected: Option<String>,
165}
166
167impl FileBrowser {
168 #[must_use]
170 pub fn new(folder: impl Into<PathBuf>, mode: PickMode) -> Self {
171 Self {
172 folder: folder.into(),
173 state: FolderState::Unread,
174 loading: None,
175 mode,
176 extensions: Vec::new(),
177 show_hidden: false,
178 filter: String::new(),
179 selected: None,
180 }
181 }
182
183 #[must_use]
185 pub fn extensions(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
186 self.extensions = extensions.into_iter().map(|e| e.into().to_lowercase()).collect();
187 self
188 }
189
190 #[must_use]
193 pub fn folder(&self) -> &Path {
194 &self.folder
195 }
196
197 #[must_use]
199 pub fn loading(&self) -> Option<&Path> {
200 self.loading.as_deref()
201 }
202
203 #[must_use]
205 pub fn shows_hidden(&self) -> bool {
206 self.show_hidden
207 }
208
209 #[must_use]
211 pub fn selected_path(&self) -> Option<PathBuf> {
212 self.selected.as_ref().map(|name| self.folder.join(name))
213 }
214
215 pub fn open<Msg: Send + 'static>(
223 &mut self,
224 folder: impl Into<PathBuf>,
225 wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
226 ) -> Command<Msg> {
227 let folder = folder.into();
228 self.loading = Some(folder.clone());
229 Command::perform(move || {
230 let listing = read_folder(&folder);
231 wrap(FilePickerMsg::Loaded(folder, listing))
232 })
233 }
234
235 pub fn update<Msg: Send + 'static>(
238 &mut self,
239 message: FilePickerMsg,
240 wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
241 ) -> Command<Msg> {
242 match message {
243 FilePickerMsg::Open(folder) => return self.open(folder, wrap),
244 FilePickerMsg::Refresh => {
245 let folder = self.loading.clone().unwrap_or_else(|| self.folder.clone());
246 return self.open(folder, wrap);
247 }
248 FilePickerMsg::Loaded(folder, listing)
251 if self.loading.as_ref().map_or(folder == self.folder, |awaited| *awaited == folder) =>
252 {
253 self.loading = None;
254 if folder != self.folder {
255 self.selected = self.child_name_towards(&folder);
256 self.filter.clear();
257 self.folder = folder;
258 }
259 self.state = match listing {
260 Ok(entries) => FolderState::Ready(entries),
261 Err(error) => FolderState::Failed(error),
262 };
263 if self.selected.is_none() {
264 self.selected = self.visible().first().map(|entry| entry.name.clone());
265 }
266 }
267 FilePickerMsg::Loaded(..) | FilePickerMsg::Chosen(_) => {}
269 FilePickerMsg::Select(name) => self.selected = name,
270 FilePickerMsg::Filter(text) => {
271 self.filter = text;
272 let visible = self.visible();
273 if !visible.iter().any(|entry| Some(&entry.name) == self.selected.as_ref()) {
274 self.selected = visible.first().map(|entry| entry.name.clone());
275 }
276 }
277 FilePickerMsg::ShowHidden(on) => self.show_hidden = on,
278 }
279 Command::none()
280 }
281
282 pub(crate) fn visible(&self) -> Vec<&FileEntry> {
284 let FolderState::Ready(entries) = &self.state else {
285 return Vec::new();
286 };
287 let query = self.filter.to_lowercase();
288 entries
289 .iter()
290 .filter(|entry| self.show_hidden || !entry.is_hidden())
291 .filter(|entry| entry.folder || self.extensions.is_empty() || self.extension_matches(&entry.name))
292 .filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
293 .collect()
294 }
295
296 fn extension_matches(&self, name: &str) -> bool {
297 Path::new(name)
298 .extension()
299 .is_some_and(|ext| self.extensions.iter().any(|wanted| ext.to_string_lossy().to_lowercase() == *wanted))
300 }
301
302 fn child_name_towards(&self, target: &Path) -> Option<String> {
304 let rest = self.folder.strip_prefix(target).ok()?;
305 rest.components().next().map(|c| c.as_os_str().to_string_lossy().into_owned())
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312
313 fn scratch(name: &str) -> PathBuf {
314 let dir = std::env::temp_dir().join(format!("quvyta-a8-browser-{name}-{}", std::process::id()));
315 let _ = std::fs::remove_dir_all(&dir);
316 std::fs::create_dir_all(dir.join("src")).expect("scratch folder");
317 for file in ["Cargo.toml", "README.md", ".env", "build.rs"] {
318 std::fs::write(dir.join(file), "x").expect("scratch file");
319 }
320 dir
321 }
322
323 #[test]
324 fn reads_folders_first_and_filters() {
325 let dir = scratch("filters");
326 let mut browser = FileBrowser::new(&dir, PickMode::Files).extensions(["RS", "toml"]);
327 let command: Command<FilePickerMsg> = browser.open(dir.clone(), |m| m);
328 assert_eq!(command.actions.len(), 1);
329 let listing = read_folder(&dir);
330 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), listing), |m| m);
331 let names: Vec<&str> = browser.visible().iter().map(|e| e.name()).collect();
332 assert_eq!(names, ["src", "build.rs", "Cargo.toml"]);
333 assert_eq!(browser.selected.as_deref(), Some("src"));
334 let _ = browser.update(FilePickerMsg::Filter("CARGO".into()), |m| m);
335 assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
336 let mut all = FileBrowser::new(&dir, PickMode::Files);
337 all.state = FolderState::Ready(read_folder(&dir).expect("readable"));
338 assert_eq!(all.visible().len(), 4);
339 let _ = all.update(FilePickerMsg::ShowHidden(true), |m| m);
340 assert_eq!(all.visible().len(), 5);
341 std::fs::remove_dir_all(dir).ok();
342 }
343
344 #[test]
345 fn errors_become_states_and_stale_answers_are_ignored() {
346 let missing = std::env::temp_dir().join("quvyta-a8-no-such-folder");
347 assert_eq!(read_folder(&missing), Err(ListingError::NotFound));
348 let mut browser = FileBrowser::new("/", PickMode::Folders);
349 let _ = browser.update(FilePickerMsg::Open(missing.clone()), |m| m);
350 let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/elsewhere"), Ok(Arc::from(Vec::new()))), |m| m);
351 assert_eq!(browser.state, FolderState::Unread);
352 assert_eq!(browser.loading(), Some(missing.as_path()));
353 let _ = browser.update(FilePickerMsg::Loaded(missing.clone(), read_folder(&missing)), |m| m);
354 assert_eq!(browser.state, FolderState::Failed(ListingError::NotFound));
355 assert_eq!((browser.folder(), browser.loading()), (missing.as_path(), None));
356 }
357
358 #[test]
359 fn going_up_keeps_the_folder_we_left_selected() {
360 let mut browser = FileBrowser::new("/home/ada/projects", PickMode::Files);
361 let _ = browser.update(FilePickerMsg::Open(PathBuf::from("/home")), |m| m);
362 let entries = ["ada", "guest"].map(|name| FileEntry { name: name.to_owned(), folder: true, size: None });
363 let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/home"), Ok(Arc::from(entries))), |m| m);
364 assert_eq!(browser.selected.as_deref(), Some("ada"));
365 }
366
367 #[test]
368 fn the_shown_folder_stays_until_the_new_one_is_read() {
369 let dir = scratch("stays");
370 let mut browser = FileBrowser::new(&dir, PickMode::Files);
371 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), |m| m);
372 let _ = browser.update(FilePickerMsg::Filter("cargo".into()), |m| m);
373 let src = dir.join("src");
374 let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
375 assert_eq!(browser.folder(), dir.as_path());
377 assert_eq!(browser.loading(), Some(src.as_path()));
378 assert_eq!(browser.filter, "cargo");
379 assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
380 assert_eq!(browser.visible().len(), 1);
381 let _ = browser.update(FilePickerMsg::Open(dir.clone()), |m| m);
383 let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
384 assert_eq!(browser.folder(), dir.as_path());
385 let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
386 let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
387 assert_eq!((browser.folder(), browser.loading()), (src.as_path(), None));
388 assert_eq!(browser.filter, "");
389 std::fs::remove_dir_all(dir).ok();
390 }
391}