1use std::path::{Component, Path, PathBuf};
4use std::rc::Rc;
5
6use crate::event::{Event, MouseButton, MouseKind};
7use crate::geometry::{Rect, Size};
8use crate::style::CellStyle;
9use crate::text;
10use crate::theme::State;
11use crate::widget::{Align, EventCx, Length, MeasureCx, NodeMut, PaintCx, View, Widget};
12
13use super::cells;
14use super::click::Click;
15use super::delayed::DelayedIndicator;
16use super::file_browser::{FileBrowser, FilePickerMsg, FolderState, ListingError, PickMode};
17use super::{Button, List, ListItem, SpinnerStyle, Switch, Text, TextInput};
18
19pub struct FilePicker<'a, Msg> {
72 browser: &'a FileBrowser,
73 wrap: Wrap<Msg>,
74 open_on: Click,
75}
76
77type Wrap<Msg> = Rc<dyn Fn(FilePickerMsg) -> Msg>;
79
80impl<'a, Msg: Clone + Send + 'static> FilePicker<'a, Msg> {
81 #[must_use]
88 pub fn new(browser: &'a FileBrowser, wrap: impl Fn(FilePickerMsg) -> Msg + 'static) -> Self {
89 Self { browser, wrap: Rc::new(wrap), open_on: Click::Double }
90 }
91
92 #[must_use]
97 pub fn open_on(mut self, click: Click) -> Self {
98 self.open_on = click;
99 self
100 }
101
102 pub fn show<'v>(self, ui: &'v mut View<'_, Msg>) -> NodeMut<'v, Msg> {
104 let browser = self.browser;
105 let wrap = self.wrap;
106 let open_on = self.open_on;
107 ui.column(|ui| {
108 let failed = matches!(browser.state, FolderState::Failed(_));
109 let busy = browser.loading.is_some();
110 ui.add(PathBar { folder: browser.folder.clone(), busy, failed, wrap: Rc::clone(&wrap) }).fill_width();
111 let filter = Rc::clone(&wrap);
112 ui.add(
113 TextInput::new(&browser.filter)
114 .placeholder(crate::t!("quvyta.file-picker.filter"))
115 .on_change(move |text| filter(FilePickerMsg::Filter(text))),
116 )
117 .fill_width();
118 match &browser.state {
119 FolderState::Unread => {
122 ui.add(Text::new("")).height(Length::Fill(1));
123 }
124 FolderState::Failed(error) => Self::failure(ui, error, &browser.folder),
125 FolderState::Ready(_) => Self::entries(ui, browser, &wrap, open_on),
126 }
127 Self::footer(ui, browser, &wrap);
128 })
129 .gap(1)
130 }
131
132 fn failure(ui: &mut View<'_, Msg>, error: &ListingError, folder: &Path) {
133 let (key, detail) = match error {
134 ListingError::PermissionDenied => ("permission-denied", None),
135 ListingError::NotFound => ("not-found", None),
136 ListingError::NotAFolder => ("not-a-folder", None),
137 ListingError::Other(message) => ("unreadable", Some(message.clone())),
138 };
139 ui.column(|ui| {
140 let glyph = ui.env().icons().glyph("error").into_owned();
141 ui.add(
142 Text::rich([
143 super::Span::new(format!("{glyph} ")).color("danger"),
144 super::Span::new(crate::t!(&format!("quvyta.file-picker.{key}"))).role("body"),
145 ])
146 .no_wrap(),
147 );
148 ui.add(Text::new(folder.display().to_string()).role("faint"));
149 if let Some(detail) = detail {
150 ui.add(Text::new(detail).role("faint"));
151 }
152 })
153 .height(Length::Fill(1))
154 .fill_width();
155 }
156
157 fn entries(ui: &mut View<'_, Msg>, browser: &FileBrowser, wrap: &Wrap<Msg>, open_on: Click) {
158 let parent = browser.folder.parent().map(Path::to_path_buf);
159 let visible = browser.visible();
160 let mut items = Vec::new();
161 let mut rows: Vec<(Option<String>, Option<FilePickerMsg>)> = Vec::new();
163 if let Some(parent) = &parent {
164 items.push(ListItem::new(crate::t!("quvyta.file-picker.parent")).icon("arrow-up", Some("muted")));
165 rows.push((None, Some(FilePickerMsg::Open(parent.clone()))));
166 }
167 for entry in &visible {
168 let path = browser.folder.join(entry.name());
169 let (item, action) = if entry.is_folder() {
170 (ListItem::new(entry.name()).icon("folder", Some("accent")), Some(FilePickerMsg::Open(path)))
171 } else {
172 let item = ListItem::new(entry.name())
173 .icon("file", Some("muted"))
174 .detail(entry.size().map(format_size).unwrap_or_default())
175 .faint(browser.mode == PickMode::Folders);
176 let action = (browser.mode == PickMode::Files).then_some(FilePickerMsg::Chosen(path));
177 (item, action)
178 };
179 items.push(item);
180 rows.push((Some(entry.name().to_owned()), action));
181 }
182 let selected = match &browser.selected {
183 Some(name) => rows.iter().position(|(row, _)| row.as_ref() == Some(name)),
184 None => None,
185 };
186 let empty = if browser.filter.is_empty() { "empty" } else { "no-match" };
187 let nothing = visible.is_empty();
188 let names: Vec<Option<String>> = rows.iter().map(|(name, _)| name.clone()).collect();
189 let actions: Vec<FilePickerMsg> = rows
190 .into_iter()
191 .map(|(name, action)| action.unwrap_or(FilePickerMsg::Select(name)))
193 .collect();
194 let (select, activate) = (Rc::clone(wrap), Rc::clone(wrap));
195 let list = List::new(items)
196 .selected(selected)
197 .activate_on(open_on)
198 .on_select(move |index| select(FilePickerMsg::Select(names.get(index).cloned().flatten())))
199 .on_activate(move |index| activate(actions.get(index).cloned().unwrap_or(FilePickerMsg::Refresh)));
200 if nothing {
201 ui.add(list).fill_width();
203 ui.add(Text::new(crate::t!(&format!("quvyta.file-picker.{empty}"))).role("faint"))
204 .height(Length::Fill(1))
205 .padding(crate::geometry::Padding { top: 0, right: 0, bottom: 0, left: 2 });
206 return;
207 }
208 ui.add(list).width(Length::Fill(1)).height(Length::Fill(1));
209 }
210
211 fn footer(ui: &mut View<'_, Msg>, browser: &FileBrowser, wrap: &Wrap<Msg>) {
212 let wants_folder = browser.mode == PickMode::Folders;
216 let selected = browser.selected.as_ref().filter(|_| !wants_folder || browser.picked).filter(|name| {
217 browser.visible().iter().any(|entry| entry.name() == name.as_str() && entry.is_folder() == wants_folder)
218 });
219 let chosen = match browser.mode {
220 PickMode::Files => selected.map(|name| browser.folder.join(name)),
221 PickMode::Folders => {
223 Some(selected.map_or_else(|| browser.folder.clone(), |name| browser.folder.join(name)))
224 }
225 };
226 let ready = matches!(browser.state, FolderState::Ready(_));
227 ui.row(|ui| {
228 let toggle = Rc::clone(wrap);
229 ui.add(
230 Switch::new(browser.show_hidden)
231 .label(crate::t!("quvyta.file-picker.hidden"))
232 .on_toggle(move |on| toggle(FilePickerMsg::ShowHidden(on))),
233 );
234 if !browser.extensions.is_empty() {
235 ui.add(Text::new(browser.extensions.join(", ")).role("faint").no_wrap());
236 }
237 ui.spacer();
238 let label = match browser.mode {
239 PickMode::Files => crate::t!("quvyta.file-picker.choose-file"),
240 PickMode::Folders => crate::t!("quvyta.file-picker.choose-folder"),
241 };
242 let mut button = Button::new(label).variant("primary").disabled(!ready || chosen.is_none());
243 if let Some(path) = chosen {
244 button = button.on_press(wrap(FilePickerMsg::Chosen(path)));
245 }
246 ui.add(button);
247 })
248 .gap(2)
249 .align(Align::Center)
250 .fill_width();
251 }
252}
253
254fn format_size(bytes: u64) -> String {
256 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
257 if bytes < 1024 {
258 return format!("{bytes} B");
259 }
260 let mut value = bytes as f64 / 1024.0;
261 let mut unit = 0;
262 while value >= 1024.0 && unit + 1 < UNITS.len() {
263 value /= 1024.0;
264 unit += 1;
265 }
266 format!("{} {}", crate::i18n::number(value, 1), UNITS[unit])
267}
268
269const INDICATOR_CELLS: u16 = 2;
272
273struct PathBar<Msg> {
276 folder: PathBuf,
277 busy: bool,
279 failed: bool,
281 wrap: Wrap<Msg>,
282}
283
284impl<Msg> PathBar<Msg> {
285 fn segments(&self) -> Vec<(String, PathBuf)> {
287 let mut out = Vec::new();
288 let mut path = PathBuf::new();
289 for component in self.folder.components() {
290 path.push(component.as_os_str());
291 let label = match component {
292 Component::RootDir => std::path::MAIN_SEPARATOR.to_string(),
293 other => other.as_os_str().to_string_lossy().into_owned(),
294 };
295 if matches!(component, Component::Prefix(_)) {
296 continue;
297 }
298 out.push((label, path.clone()));
299 }
300 out
301 }
302
303 fn segments_area(area: Rect) -> Rect {
305 Rect::new(area.x, area.y, area.width.saturating_sub(INDICATOR_CELLS), area.height)
306 }
307
308 fn paint_indicator(&self, cx: &mut PaintCx<'_>, area: Rect, end: i32) {
311 let now = cx.now();
312 let indicator = cx.memory::<DelayedIndicator>();
313 if self.failed && !self.busy {
314 indicator.cancel();
315 }
316 let shown = indicator.update(self.busy, now);
317 let next = indicator.next_change(self.busy, now);
318 if let Some(delay) = next {
319 cx.request_frame_in(delay);
320 }
321 if !shown || area.width <= INDICATOR_CELLS {
322 return;
323 }
324 let x = end + 1;
327 let style = cx.style("spinner", None, &[]).text();
328 let cell = cx.animation(SpinnerStyle::default().animation(), style, Some(std::time::Duration::ZERO));
329 cx.text(x, area.y, &cell.glyph, cell.style, 1);
330 let label = crate::t!("quvyta.file-picker.loading");
331 let width = text::width(&label);
332 let label_x = x + 2;
333 if label_x + i32::from(width) <= area.right() {
334 let style = cx.style("spinner-label", None, &[]).text();
335 cx.text(label_x, area.y, &label, style, width);
336 }
337 }
338
339 fn layout(&self, separator_width: u16, area: Rect) -> Vec<(Option<usize>, Rect)> {
342 let segments = self.segments();
343 let widths: Vec<u16> = segments.iter().map(|(label, _)| text::width(label).saturating_add(2)).collect();
344 let step = separator_width + 2;
345 let mut first = 0;
346 let total = |from: usize| -> u16 {
347 let ellipsis = if from > 0 { 3 + step } else { 0 };
348 widths[from..].iter().sum::<u16>()
349 + step * u16::try_from(widths.len() - from).unwrap_or(0).saturating_sub(1)
350 + ellipsis
351 };
352 while first + 1 < segments.len() && total(first) > area.width {
353 first += 1;
354 }
355 let mut out = Vec::new();
356 let mut x = area.x;
357 if first > 0 {
358 out.push((None, Rect::new(x, area.y, 3, 1)));
359 x += 3 + i32::from(step);
360 }
361 for (index, width) in widths.iter().enumerate().skip(first) {
362 out.push((Some(index), Rect::new(x, area.y, *width, 1)));
363 x += i32::from(*width + step);
364 }
365 out
366 }
367}
368
369impl<Msg: 'static> Widget<Msg> for PathBar<Msg> {
370 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
371 let width = cells::sum(self.segments().iter().map(|(label, _)| text::width(label).saturating_add(5)));
372 Size::new(width.saturating_add(INDICATOR_CELLS), 1).min(available)
373 }
374
375 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
376 cx.register_hit(area);
377 let separator = cx.env().icons().glyph("path-separator").into_owned();
378 let separator_width = text::width(&separator);
379 let segments = self.segments();
380 let pointer = cx.pointer();
381 let last = segments.len().saturating_sub(1);
382 let separator_style = cx.style("path-separator", None, &[]).text();
383 let spans = self.layout(separator_width, Self::segments_area(area));
384 for (position, (index, rect)) in spans.iter().enumerate() {
385 let mut states = Vec::new();
386 if pointer.is_some_and(|(x, y)| rect.contains(x, y)) && *index != Some(last) && index.is_some() {
387 states.push(State::Hover);
388 }
389 if *index == Some(last) {
390 states.push(State::Selected);
391 }
392 let style = cx.style("path-segment", None, &states).text();
393 if let Some(bg) = style.bg {
394 cx.fill(*rect, bg);
395 }
396 let label = index.map_or_else(|| text::ELLIPSIS.to_owned(), |i| segments[i].0.clone());
397 cx.text(rect.x + 1, rect.y, &label, CellStyle { bg: None, ..style }, rect.width.saturating_sub(2));
398 if position + 1 < spans.len() {
399 cx.text(rect.right() + 1, rect.y, &separator, separator_style, separator_width);
400 }
401 }
402 let end = spans.last().map_or(area.x, |(_, rect)| rect.right());
403 self.paint_indicator(cx, area, end);
404 }
405
406 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
407 let Event::Mouse(mouse) = event else { return false };
408 if mouse.kind != MouseKind::Down(MouseButton::Left) {
409 return false;
410 }
411 let separator = text::width(&cx.env().icons().glyph("path-separator"));
412 let segments = self.segments();
413 let hit = self
414 .layout(separator, Self::segments_area(cx.area()))
415 .into_iter()
416 .find(|(_, rect)| rect.contains(mouse.x, mouse.y));
417 match hit {
418 Some((Some(index), _)) if index + 1 < segments.len() => {
419 cx.emit((self.wrap)(FilePickerMsg::Open(segments[index].1.clone())));
420 true
421 }
422 _ => false,
423 }
424 }
425}
426
427#[cfg(test)]
428mod tests {
429 use super::*;
430 use crate::icons::GlyphMode;
431 use crate::runtime::{App, Command, Harness};
432 use crate::widgets::read_folder;
433
434 struct Demo {
435 browser: FileBrowser,
436 chosen: Option<PathBuf>,
437 }
438
439 #[derive(Debug, Clone)]
440 enum Msg {
441 Picker(FilePickerMsg),
442 }
443
444 impl App for Demo {
445 type Msg = Msg;
446 fn update(&mut self, msg: Msg) -> Command<Msg> {
447 match msg {
448 Msg::Picker(FilePickerMsg::Chosen(path)) => self.chosen = Some(path),
449 Msg::Picker(message) => return self.browser.update(message, Msg::Picker),
450 }
451 Command::none()
452 }
453 fn view(&self, ui: &mut View<'_, Msg>) {
454 FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
455 }
456 }
457
458 fn scratch(name: &str) -> PathBuf {
459 let dir = std::env::temp_dir().join(format!("quvyta-a8-picker-{name}-{}", std::process::id()));
460 let _ = std::fs::remove_dir_all(&dir);
461 std::fs::create_dir_all(dir.join("deploy/scripts")).expect("scratch folders");
462 std::fs::write(dir.join("compose.yaml"), "services:\n").expect("scratch file");
463 std::fs::write(dir.join("deploy/release.sh"), vec![b'#'; 2048]).expect("scratch file");
464 dir
465 }
466
467 fn harness(dir: &Path, mode: PickMode) -> Harness<Demo> {
468 let browser = FileBrowser::new(dir, mode);
469 let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
470 h.set_glyph_mode(GlyphMode::Unicode);
471 let folder = dir.to_path_buf();
472 h.send(Msg::Picker(FilePickerMsg::Open(folder)));
473 h
474 }
475
476 #[test]
477 fn browses_into_folders_and_chooses_a_file() {
478 let dir = scratch("choose");
479 let mut h = harness(&dir, PickMode::Files);
480 let screen = h.screen();
481 assert!(screen.contains("■ deploy") && screen.contains("compose.yaml"), "{screen}");
482 assert!(screen.contains("Parent folder"), "{screen}");
483 h.click_text("deploy").click_text("deploy");
484 let screen = h.screen();
485 assert!(screen.contains("release.sh") && screen.contains("2.0 KiB"), "{screen}");
486 assert!(screen.contains("scripts"), "{screen}");
487 h.click_text("release.sh").click_text("release.sh");
488 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy/release.sh").as_path()));
489 let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
490 h.click_text(&name);
491 assert_eq!(h.app().browser.folder(), dir.as_path());
492 std::fs::remove_dir_all(dir).ok();
493 }
494
495 #[test]
496 fn folder_mode_chooses_the_folder_entered_rather_than_its_first_child() {
497 let dir = scratch("entered");
501 let mut h = harness(&dir, PickMode::Folders);
502 h.click_text("deploy").click_text("deploy");
503 assert!(h.screen().contains("scripts"), "{}", h.screen());
504 h.click_text("Choose folder");
505 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
506 std::fs::remove_dir_all(dir).ok();
507 }
508
509 #[test]
510 fn folder_mode_chooses_the_folder_shown_until_one_inside_is_pointed_at() {
511 let dir = scratch("folders");
512 let mut h = harness(&dir, PickMode::Folders);
513 h.click_text("Choose folder");
514 assert_eq!(h.app().chosen.as_deref(), Some(dir.as_path()), "nothing was pointed at");
515 let (x, y) = h.find("compose.yaml").expect("the listing");
518 h.click(x, y).press("up").press("down").press("up");
519 h.click_text("Choose folder");
520 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
521 h.send(Msg::Picker(FilePickerMsg::Filter("zzz".into())));
522 assert!(h.screen().contains("Nothing matches"), "{}", h.screen());
523 std::fs::remove_dir_all(dir).ok();
524 }
525
526 #[test]
527 fn a_click_selects_and_a_double_click_or_enter_chooses() {
528 let dir = scratch("clicks");
529 let mut h = harness(&dir, PickMode::Files);
530 h.click_text("compose.yaml");
531 assert_eq!(h.app().chosen, None, "one click chooses nothing");
532 assert_eq!(h.app().browser.selected.as_deref(), Some("compose.yaml"), "it selects");
533 h.advance(Click::INTERVAL).click_text("compose.yaml");
534 assert_eq!(h.app().chosen, None, "a second click after the interval is another single click");
535 h.click_text("compose.yaml");
536 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()), "a double click chooses");
537
538 let mut h = harness(&dir, PickMode::Files);
539 h.click_text("deploy");
540 assert!(!h.screen().contains("release.sh"), "one click does not open a folder:\n{}", h.screen());
541 h.click_text("compose.yaml").advance(Click::INTERVAL).press("enter");
543 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()), "Enter chooses");
544 std::fs::remove_dir_all(dir).ok();
545 }
546
547 #[test]
548 fn a_click_on_the_entry_a_folder_selected_by_itself_points_at_it() {
549 let dir = scratch("pointed");
552 let mut h = harness(&dir, PickMode::Folders);
553 assert_eq!(h.app().browser.selected.as_deref(), Some("deploy"), "{}", h.screen());
554 h.click_text("deploy");
555 assert!(!h.screen().contains("scripts"), "the click did not open it:\n{}", h.screen());
556 h.click_text("Choose folder");
557 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
558 std::fs::remove_dir_all(dir).ok();
559 }
560
561 #[test]
562 fn open_on_single_opens_and_chooses_with_one_click() {
563 struct Quick(Demo);
564 impl App for Quick {
565 type Msg = Msg;
566 fn update(&mut self, msg: Msg) -> Command<Msg> {
567 self.0.update(msg)
568 }
569 fn view(&self, ui: &mut View<'_, Msg>) {
570 FilePicker::new(&self.0.browser, Msg::Picker).open_on(Click::Single).show(ui).fill();
571 }
572 }
573 let dir = scratch("single");
574 let browser = FileBrowser::new(&dir, PickMode::Files);
575 let mut h = Harness::new(Quick(Demo { browser, chosen: None }), 60, 14);
576 h.set_glyph_mode(GlyphMode::Unicode);
577 h.send(Msg::Picker(FilePickerMsg::Open(dir.clone())));
578 h.click_text("deploy");
579 assert!(h.screen().contains("release.sh"), "{}", h.screen());
580 h.click_text("release.sh");
581 assert_eq!(h.app().0.chosen.as_deref(), Some(dir.join("deploy/release.sh").as_path()));
582 std::fs::remove_dir_all(dir).ok();
583 }
584
585 #[test]
586 fn unreadable_folders_show_a_state() {
587 let missing = std::env::temp_dir().join("quvyta-a8-picker-missing");
588 let h = harness(&missing, PickMode::Files);
589 let screen = h.screen();
590 assert!(screen.contains("✕ This folder does not exist"), "{screen}");
591 assert!(!screen.contains('['));
592 }
593
594 struct Tabbed {
597 tab: usize,
598 browser: FileBrowser,
599 chosen: Vec<(usize, PathBuf)>,
600 }
601
602 #[derive(Debug, Clone)]
603 enum TabMsg {
604 Picker(usize, FilePickerMsg),
605 }
606
607 impl App for Tabbed {
608 type Msg = TabMsg;
609 fn update(&mut self, msg: TabMsg) -> Command<TabMsg> {
610 let TabMsg::Picker(tab, message) = msg;
611 if let FilePickerMsg::Chosen(path) = message {
612 self.chosen.push((tab, path));
613 return Command::none();
614 }
615 self.browser.update(message, move |message| TabMsg::Picker(tab, message))
616 }
617 fn view(&self, ui: &mut View<'_, TabMsg>) {
618 let tab = self.tab;
619 FilePicker::new(&self.browser, move |message| TabMsg::Picker(tab, message)).show(ui).fill();
620 }
621 }
622
623 #[test]
624 fn a_closure_capturing_state_wraps_every_picker_message() {
625 let dir = scratch("closure");
626 let browser = FileBrowser::new(&dir, PickMode::Files);
627 let mut h = Harness::new(Tabbed { tab: 3, browser, chosen: Vec::new() }, 60, 14);
628 h.set_glyph_mode(GlyphMode::Unicode);
629 h.send(TabMsg::Picker(3, FilePickerMsg::Open(dir.clone())));
631 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
632 h.click_text("deploy").click_text("deploy").click_text("release.sh").click_text("release.sh");
633 assert_eq!(h.app().chosen, [(3, dir.join("deploy/release.sh"))]);
634 std::fs::remove_dir_all(dir).ok();
635 }
636
637 impl From<FilePickerMsg> for Msg {
638 fn from(message: FilePickerMsg) -> Self {
639 Msg::Picker(message)
640 }
641 }
642
643 #[test]
646 fn plain_functions_still_wrap() {
647 fn wrap(message: FilePickerMsg) -> Msg {
648 Msg::Picker(message)
649 }
650 fn picked<M: From<FilePickerMsg>>(message: FilePickerMsg) -> M {
651 M::from(message)
652 }
653 let dir = scratch("plain");
654 let pointer: fn(FilePickerMsg) -> Msg = wrap;
655 let mut browser = FileBrowser::new(&dir, PickMode::Files);
656 let command: Command<Msg> = browser.open(dir.clone(), pointer);
657 assert_eq!(command.actions.len(), 1);
658 let command: Command<Msg> = browser.update(FilePickerMsg::Refresh, picked::<Msg>);
659 assert_eq!(command.actions.len(), 1);
660 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), wrap);
661 let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
662 h.set_glyph_mode(GlyphMode::Unicode);
663 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
664 let _ = FilePicker::new(&h.app().browser, pointer);
666 h.click_text("compose.yaml").click_text("compose.yaml");
667 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()));
668 std::fs::remove_dir_all(dir).ok();
669 }
670
671 #[test]
672 fn sizes_read_naturally() {
673 assert_eq!(format_size(812), "812 B");
674 assert_eq!(format_size(12_700), "12.4 KiB");
675 assert_eq!(format_size(3 * 1024 * 1024 * 1024 + 1024 * 1024 * 100), "3.1 GiB");
676 }
677
678 struct Held {
680 browser: FileBrowser,
681 }
682
683 impl App for Held {
684 type Msg = Msg;
685 fn update(&mut self, msg: Msg) -> Command<Msg> {
686 let Msg::Picker(message) = msg;
687 let _ = self.browser.update(message, Msg::Picker);
689 Command::none()
690 }
691 fn view(&self, ui: &mut View<'_, Msg>) {
692 FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
693 }
694 }
695
696 fn held(dir: &Path) -> Harness<Held> {
698 let mut browser = FileBrowser::new(dir, PickMode::Files);
699 let _ = browser.update(FilePickerMsg::Loaded(dir.to_path_buf(), read_folder(dir)), |m| m);
700 let mut h = Harness::new(Held { browser }, 60, 14);
701 h.set_glyph_mode(GlyphMode::Unicode);
702 h.press("tab").press("tab");
703 h
704 }
705
706 fn ms(value: u64) -> std::time::Duration {
707 std::time::Duration::from_millis(value)
708 }
709
710 fn spinning(h: &Harness<Held>) -> bool {
712 let screen = h.screen();
713 screen.lines().next().is_some_and(|line| line.contains(|c| "◜◠◝◞◡◟".contains(c)))
714 }
715
716 fn deliver(h: &mut Harness<Held>, folder: &Path) {
717 h.send(Msg::Picker(FilePickerMsg::Loaded(folder.to_path_buf(), read_folder(folder))));
718 }
719
720 #[test]
724 fn opening_a_folder_keeps_the_listing_until_it_is_read() {
725 let dir = scratch("flash");
726 let mut h = held(&dir);
727 let before = h.screen();
728 h.press("enter");
729 assert_eq!(h.app().browser.loading(), Some(dir.join("deploy").as_path()));
730 for step in [0, 40, 120, 139] {
731 h.advance(ms(step));
732 assert_eq!(h.screen(), before, "at +{step} ms");
733 }
734 deliver(&mut h, &dir.join("deploy"));
735 let screen = h.screen();
736 assert!(screen.contains("› deploy") && screen.contains("release.sh"), "{screen}");
737 assert!(!spinning(&h), "{screen}");
738 h.press("down");
740 assert!(h.screen().contains("▌ ▪ release.sh"), "{}", h.screen());
741 std::fs::remove_dir_all(dir).ok();
742 }
743
744 #[test]
745 fn a_slow_read_shows_the_indicator_after_the_delay_for_at_least_its_minimum() {
746 let dir = scratch("slow");
747 let mut h = held(&dir);
748 h.press("enter").advance(ms(299));
749 assert!(!spinning(&h), "{}", h.screen());
750 h.advance(ms(1));
751 let first = h.screen();
752 assert!(spinning(&h), "{first}");
753 assert!(first.contains("▌ ■ deploy") && first.contains("compose.yaml"), "{first}");
755 h.advance(ms(50));
757 deliver(&mut h, &dir.join("deploy"));
758 assert!(h.screen().contains("release.sh") && spinning(&h), "{}", h.screen());
759 h.advance(ms(449));
760 assert!(spinning(&h), "{}", h.screen());
761 h.advance(ms(1));
762 assert!(!spinning(&h), "{}", h.screen());
763 std::fs::remove_dir_all(dir).ok();
764 }
765
766 #[test]
767 fn a_folder_that_cannot_be_read_shows_its_error_at_once() {
768 let dir = scratch("error");
769 let mut h = held(&dir);
770 let missing = dir.join("gone");
771 h.send(Msg::Picker(FilePickerMsg::Open(missing.clone()))).advance(ms(320));
772 assert!(spinning(&h), "{}", h.screen());
773 deliver(&mut h, &missing);
774 let screen = h.screen();
775 assert!(screen.contains("This folder does not exist"), "{screen}");
776 assert!(!spinning(&h), "{screen}");
777 std::fs::remove_dir_all(dir).ok();
778 }
779
780 #[test]
781 fn a_first_read_leaves_the_space_empty_until_it_is_slow() {
782 let dir = scratch("first");
783 let mut h = Harness::new(Held { browser: FileBrowser::new(&dir, PickMode::Files) }, 90, 12);
785 h.set_glyph_mode(GlyphMode::Unicode);
786 h.send(Msg::Picker(FilePickerMsg::Open(dir.clone())));
787 assert!(!spinning(&h) && !h.screen().contains("deploy"), "{}", h.screen());
788 h.advance(ms(300));
789 assert!(spinning(&h), "{}", h.screen());
790 assert!(
791 h.screen().lines().next().is_some_and(|line| line.trim_end().ends_with(" Reading folder…")),
792 "{}",
793 h.screen()
794 );
795 deliver(&mut h, &dir);
796 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
797 std::fs::remove_dir_all(dir).ok();
798 }
799}