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