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)
92 .map(|entry| {
93 let path = entry.path();
94 let metadata = std::fs::metadata(&path).or_else(|_| entry.metadata()).ok();
96 let folder = metadata.as_ref().is_some_and(std::fs::Metadata::is_dir);
97 FileEntry {
98 name: entry.file_name().to_string_lossy().into_owned(),
99 folder,
100 size: metadata.filter(|_| !folder).map(|m| m.len()),
101 }
102 })
103 .collect();
104 out.sort_by(|a, b| match (a.folder, b.folder) {
105 (true, false) => Ordering::Less,
106 (false, true) => Ordering::Greater,
107 _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()).then_with(|| a.name.cmp(&b.name)),
108 });
109 Ok(out.into())
110}
111
112#[derive(Debug, Clone, PartialEq, Eq)]
115pub enum FilePickerMsg {
116 Open(PathBuf),
118 Loaded(PathBuf, Listing),
120 Select(Option<String>),
122 Filter(String),
124 ShowHidden(bool),
126 Refresh,
128 Chosen(PathBuf),
130}
131
132#[derive(Debug, Clone, PartialEq, Eq)]
134pub(crate) enum FolderState {
135 Unread,
137 Ready(Arc<[FileEntry]>),
138 Failed(ListingError),
139}
140
141#[derive(Debug, Clone)]
151pub struct FileBrowser {
152 pub(crate) folder: PathBuf,
153 pub(crate) state: FolderState,
154 pub(crate) loading: Option<PathBuf>,
156 pub(crate) mode: PickMode,
157 pub(crate) extensions: Vec<String>,
158 pub(crate) show_hidden: bool,
159 pub(crate) filter: String,
160 pub(crate) selected: Option<String>,
161}
162
163impl FileBrowser {
164 #[must_use]
166 pub fn new(folder: impl Into<PathBuf>, mode: PickMode) -> Self {
167 Self {
168 folder: folder.into(),
169 state: FolderState::Unread,
170 loading: None,
171 mode,
172 extensions: Vec::new(),
173 show_hidden: false,
174 filter: String::new(),
175 selected: None,
176 }
177 }
178
179 #[must_use]
181 pub fn extensions(mut self, extensions: impl IntoIterator<Item = impl Into<String>>) -> Self {
182 self.extensions = extensions.into_iter().map(|e| e.into().to_lowercase()).collect();
183 self
184 }
185
186 #[must_use]
189 pub fn folder(&self) -> &Path {
190 &self.folder
191 }
192
193 #[must_use]
195 pub fn loading(&self) -> Option<&Path> {
196 self.loading.as_deref()
197 }
198
199 #[must_use]
201 pub fn shows_hidden(&self) -> bool {
202 self.show_hidden
203 }
204
205 #[must_use]
207 pub fn selected_path(&self) -> Option<PathBuf> {
208 self.selected.as_ref().map(|name| self.folder.join(name))
209 }
210
211 pub fn open<Msg: Send + 'static>(
219 &mut self,
220 folder: impl Into<PathBuf>,
221 wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
222 ) -> Command<Msg> {
223 let folder = folder.into();
224 self.loading = Some(folder.clone());
225 Command::perform(move || {
226 let listing = read_folder(&folder);
227 wrap(FilePickerMsg::Loaded(folder, listing))
228 })
229 }
230
231 pub fn update<Msg: Send + 'static>(
234 &mut self,
235 message: FilePickerMsg,
236 wrap: impl FnOnce(FilePickerMsg) -> Msg + Send + 'static,
237 ) -> Command<Msg> {
238 match message {
239 FilePickerMsg::Open(folder) => return self.open(folder, wrap),
240 FilePickerMsg::Refresh => {
241 let folder = self.loading.clone().unwrap_or_else(|| self.folder.clone());
242 return self.open(folder, wrap);
243 }
244 FilePickerMsg::Loaded(folder, listing)
247 if self.loading.as_ref().map_or(folder == self.folder, |awaited| *awaited == folder) =>
248 {
249 self.loading = None;
250 if folder != self.folder {
251 self.selected = self.child_name_towards(&folder);
252 self.filter.clear();
253 self.folder = folder;
254 }
255 self.state = match listing {
256 Ok(entries) => FolderState::Ready(entries),
257 Err(error) => FolderState::Failed(error),
258 };
259 if self.selected.is_none() {
260 self.selected = self.visible().first().map(|entry| entry.name.clone());
261 }
262 }
263 FilePickerMsg::Loaded(..) | FilePickerMsg::Chosen(_) => {}
265 FilePickerMsg::Select(name) => self.selected = name,
266 FilePickerMsg::Filter(text) => {
267 self.filter = text;
268 let visible = self.visible();
269 if !visible.iter().any(|entry| Some(&entry.name) == self.selected.as_ref()) {
270 self.selected = visible.first().map(|entry| entry.name.clone());
271 }
272 }
273 FilePickerMsg::ShowHidden(on) => self.show_hidden = on,
274 }
275 Command::none()
276 }
277
278 pub(crate) fn visible(&self) -> Vec<&FileEntry> {
280 let FolderState::Ready(entries) = &self.state else {
281 return Vec::new();
282 };
283 let query = self.filter.to_lowercase();
284 entries
285 .iter()
286 .filter(|entry| self.show_hidden || !entry.is_hidden())
287 .filter(|entry| entry.folder || self.extensions.is_empty() || self.extension_matches(&entry.name))
288 .filter(|entry| query.is_empty() || entry.name.to_lowercase().contains(&query))
289 .collect()
290 }
291
292 fn extension_matches(&self, name: &str) -> bool {
293 Path::new(name)
294 .extension()
295 .is_some_and(|ext| self.extensions.iter().any(|wanted| ext.to_string_lossy().to_lowercase() == *wanted))
296 }
297
298 fn child_name_towards(&self, target: &Path) -> Option<String> {
300 let rest = self.folder.strip_prefix(target).ok()?;
301 rest.components().next().map(|c| c.as_os_str().to_string_lossy().into_owned())
302 }
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 fn scratch(name: &str) -> PathBuf {
310 let dir = std::env::temp_dir().join(format!("quvyta-a8-browser-{name}-{}", std::process::id()));
311 let _ = std::fs::remove_dir_all(&dir);
312 std::fs::create_dir_all(dir.join("src")).expect("scratch folder");
313 for file in ["Cargo.toml", "README.md", ".env", "build.rs"] {
314 std::fs::write(dir.join(file), "x").expect("scratch file");
315 }
316 dir
317 }
318
319 #[test]
320 fn reads_folders_first_and_filters() {
321 let dir = scratch("filters");
322 let mut browser = FileBrowser::new(&dir, PickMode::Files).extensions(["RS", "toml"]);
323 let command: Command<FilePickerMsg> = browser.open(dir.clone(), |m| m);
324 assert_eq!(command.actions.len(), 1);
325 let listing = read_folder(&dir);
326 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), listing), |m| m);
327 let names: Vec<&str> = browser.visible().iter().map(|e| e.name()).collect();
328 assert_eq!(names, ["src", "build.rs", "Cargo.toml"]);
329 assert_eq!(browser.selected.as_deref(), Some("src"));
330 let _ = browser.update(FilePickerMsg::Filter("CARGO".into()), |m| m);
331 assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
332 let mut all = FileBrowser::new(&dir, PickMode::Files);
333 all.state = FolderState::Ready(read_folder(&dir).expect("readable"));
334 assert_eq!(all.visible().len(), 4);
335 let _ = all.update(FilePickerMsg::ShowHidden(true), |m| m);
336 assert_eq!(all.visible().len(), 5);
337 std::fs::remove_dir_all(dir).ok();
338 }
339
340 #[test]
341 fn errors_become_states_and_stale_answers_are_ignored() {
342 let missing = std::env::temp_dir().join("quvyta-a8-no-such-folder");
343 assert_eq!(read_folder(&missing), Err(ListingError::NotFound));
344 let mut browser = FileBrowser::new("/", PickMode::Folders);
345 let _ = browser.update(FilePickerMsg::Open(missing.clone()), |m| m);
346 let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/elsewhere"), Ok(Arc::from(Vec::new()))), |m| m);
347 assert_eq!(browser.state, FolderState::Unread);
348 assert_eq!(browser.loading(), Some(missing.as_path()));
349 let _ = browser.update(FilePickerMsg::Loaded(missing.clone(), read_folder(&missing)), |m| m);
350 assert_eq!(browser.state, FolderState::Failed(ListingError::NotFound));
351 assert_eq!((browser.folder(), browser.loading()), (missing.as_path(), None));
352 }
353
354 #[test]
355 fn going_up_keeps_the_folder_we_left_selected() {
356 let mut browser = FileBrowser::new("/home/ada/projects", PickMode::Files);
357 let _ = browser.update(FilePickerMsg::Open(PathBuf::from("/home")), |m| m);
358 let entries = ["ada", "guest"].map(|name| FileEntry { name: name.to_owned(), folder: true, size: None });
359 let _ = browser.update(FilePickerMsg::Loaded(PathBuf::from("/home"), Ok(Arc::from(entries))), |m| m);
360 assert_eq!(browser.selected.as_deref(), Some("ada"));
361 }
362
363 #[test]
364 fn the_shown_folder_stays_until_the_new_one_is_read() {
365 let dir = scratch("stays");
366 let mut browser = FileBrowser::new(&dir, PickMode::Files);
367 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), |m| m);
368 let _ = browser.update(FilePickerMsg::Filter("cargo".into()), |m| m);
369 let src = dir.join("src");
370 let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
371 assert_eq!(browser.folder(), dir.as_path());
373 assert_eq!(browser.loading(), Some(src.as_path()));
374 assert_eq!(browser.filter, "cargo");
375 assert_eq!(browser.selected.as_deref(), Some("Cargo.toml"));
376 assert_eq!(browser.visible().len(), 1);
377 let _ = browser.update(FilePickerMsg::Open(dir.clone()), |m| m);
379 let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
380 assert_eq!(browser.folder(), dir.as_path());
381 let _ = browser.update(FilePickerMsg::Open(src.clone()), |m| m);
382 let _ = browser.update(FilePickerMsg::Loaded(src.clone(), read_folder(&src)), |m| m);
383 assert_eq!((browser.folder(), browser.loading()), (src.as_path(), None));
384 assert_eq!(browser.filter, "");
385 std::fs::remove_dir_all(dir).ok();
386 }
387}