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;
196 let selected = browser.selected.as_ref().filter(|name| {
197 browser.visible().iter().any(|entry| entry.name() == name.as_str() && entry.is_folder() == wants_folder)
198 });
199 let chosen = match browser.mode {
200 PickMode::Files => selected.map(|name| browser.folder.join(name)),
201 PickMode::Folders => {
203 Some(selected.map_or_else(|| browser.folder.clone(), |name| browser.folder.join(name)))
204 }
205 };
206 let ready = matches!(browser.state, FolderState::Ready(_));
207 ui.row(|ui| {
208 let toggle = Rc::clone(wrap);
209 ui.add(
210 Switch::new(browser.show_hidden)
211 .label(crate::t!("quvyta.file-picker.hidden"))
212 .on_toggle(move |on| toggle(FilePickerMsg::ShowHidden(on))),
213 );
214 if !browser.extensions.is_empty() {
215 ui.add(Text::new(browser.extensions.join(", ")).role("faint").no_wrap());
216 }
217 ui.spacer();
218 let label = match browser.mode {
219 PickMode::Files => crate::t!("quvyta.file-picker.choose-file"),
220 PickMode::Folders => crate::t!("quvyta.file-picker.choose-folder"),
221 };
222 let mut button = Button::new(label).variant("primary").disabled(!ready || chosen.is_none());
223 if let Some(path) = chosen {
224 button = button.on_press(wrap(FilePickerMsg::Chosen(path)));
225 }
226 ui.add(button);
227 })
228 .gap(2)
229 .align(Align::Center)
230 .fill_width();
231 }
232}
233
234fn format_size(bytes: u64) -> String {
236 const UNITS: [&str; 5] = ["KiB", "MiB", "GiB", "TiB", "PiB"];
237 if bytes < 1024 {
238 return format!("{bytes} B");
239 }
240 let mut value = bytes as f64 / 1024.0;
241 let mut unit = 0;
242 while value >= 1024.0 && unit + 1 < UNITS.len() {
243 value /= 1024.0;
244 unit += 1;
245 }
246 format!("{} {}", crate::i18n::number(value, 1), UNITS[unit])
247}
248
249const INDICATOR_CELLS: u16 = 2;
252
253struct PathBar<Msg> {
256 folder: PathBuf,
257 busy: bool,
259 failed: bool,
261 wrap: Wrap<Msg>,
262}
263
264impl<Msg> PathBar<Msg> {
265 fn segments(&self) -> Vec<(String, PathBuf)> {
267 let mut out = Vec::new();
268 let mut path = PathBuf::new();
269 for component in self.folder.components() {
270 path.push(component.as_os_str());
271 let label = match component {
272 Component::RootDir => std::path::MAIN_SEPARATOR.to_string(),
273 other => other.as_os_str().to_string_lossy().into_owned(),
274 };
275 if matches!(component, Component::Prefix(_)) {
276 continue;
277 }
278 out.push((label, path.clone()));
279 }
280 out
281 }
282
283 fn segments_area(area: Rect) -> Rect {
285 Rect::new(area.x, area.y, area.width.saturating_sub(INDICATOR_CELLS), area.height)
286 }
287
288 fn paint_indicator(&self, cx: &mut PaintCx<'_>, area: Rect, end: i32) {
291 let now = cx.now();
292 let indicator = cx.memory::<DelayedIndicator>();
293 if self.failed && !self.busy {
294 indicator.cancel();
295 }
296 let shown = indicator.update(self.busy, now);
297 let next = indicator.next_change(self.busy, now);
298 if let Some(delay) = next {
299 cx.request_frame_in(delay);
300 }
301 if !shown || area.width <= INDICATOR_CELLS {
302 return;
303 }
304 let x = end + 1;
307 let style = cx.style("spinner", None, &[]).text();
308 let cell = cx.animation(SpinnerStyle::default().animation(), style, Some(std::time::Duration::ZERO));
309 cx.text(x, area.y, &cell.glyph, cell.style, 1);
310 let label = crate::t!("quvyta.file-picker.loading");
311 let width = text::width(&label);
312 let label_x = x + 2;
313 if label_x + i32::from(width) <= area.right() {
314 let style = cx.style("spinner-label", None, &[]).text();
315 cx.text(label_x, area.y, &label, style, width);
316 }
317 }
318
319 fn layout(&self, separator_width: u16, area: Rect) -> Vec<(Option<usize>, Rect)> {
322 let segments = self.segments();
323 let widths: Vec<u16> = segments.iter().map(|(label, _)| text::width(label).saturating_add(2)).collect();
324 let step = separator_width + 2;
325 let mut first = 0;
326 let total = |from: usize| -> u16 {
327 let ellipsis = if from > 0 { 3 + step } else { 0 };
328 widths[from..].iter().sum::<u16>()
329 + step * u16::try_from(widths.len() - from).unwrap_or(0).saturating_sub(1)
330 + ellipsis
331 };
332 while first + 1 < segments.len() && total(first) > area.width {
333 first += 1;
334 }
335 let mut out = Vec::new();
336 let mut x = area.x;
337 if first > 0 {
338 out.push((None, Rect::new(x, area.y, 3, 1)));
339 x += 3 + i32::from(step);
340 }
341 for (index, width) in widths.iter().enumerate().skip(first) {
342 out.push((Some(index), Rect::new(x, area.y, *width, 1)));
343 x += i32::from(*width + step);
344 }
345 out
346 }
347}
348
349impl<Msg: 'static> Widget<Msg> for PathBar<Msg> {
350 fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
351 let width = cells::sum(self.segments().iter().map(|(label, _)| text::width(label).saturating_add(5)));
352 Size::new(width.saturating_add(INDICATOR_CELLS), 1).min(available)
353 }
354
355 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
356 cx.register_hit(area);
357 let separator = cx.env().icons().glyph("path-separator").into_owned();
358 let separator_width = text::width(&separator);
359 let segments = self.segments();
360 let pointer = cx.pointer();
361 let last = segments.len().saturating_sub(1);
362 let separator_style = cx.style("path-separator", None, &[]).text();
363 let spans = self.layout(separator_width, Self::segments_area(area));
364 for (position, (index, rect)) in spans.iter().enumerate() {
365 let mut states = Vec::new();
366 if pointer.is_some_and(|(x, y)| rect.contains(x, y)) && *index != Some(last) && index.is_some() {
367 states.push(State::Hover);
368 }
369 if *index == Some(last) {
370 states.push(State::Selected);
371 }
372 let style = cx.style("path-segment", None, &states).text();
373 if let Some(bg) = style.bg {
374 cx.fill(*rect, bg);
375 }
376 let label = index.map_or_else(|| text::ELLIPSIS.to_owned(), |i| segments[i].0.clone());
377 cx.text(rect.x + 1, rect.y, &label, CellStyle { bg: None, ..style }, rect.width.saturating_sub(2));
378 if position + 1 < spans.len() {
379 cx.text(rect.right() + 1, rect.y, &separator, separator_style, separator_width);
380 }
381 }
382 let end = spans.last().map_or(area.x, |(_, rect)| rect.right());
383 self.paint_indicator(cx, area, end);
384 }
385
386 fn event(&self, cx: &mut EventCx<'_, Msg>, event: &Event) -> bool {
387 let Event::Mouse(mouse) = event else { return false };
388 if mouse.kind != MouseKind::Down(MouseButton::Left) {
389 return false;
390 }
391 let separator = text::width(&cx.env().icons().glyph("path-separator"));
392 let segments = self.segments();
393 let hit = self
394 .layout(separator, Self::segments_area(cx.area()))
395 .into_iter()
396 .find(|(_, rect)| rect.contains(mouse.x, mouse.y));
397 match hit {
398 Some((Some(index), _)) if index + 1 < segments.len() => {
399 cx.emit((self.wrap)(FilePickerMsg::Open(segments[index].1.clone())));
400 true
401 }
402 _ => false,
403 }
404 }
405}
406
407#[cfg(test)]
408mod tests {
409 use super::*;
410 use crate::icons::GlyphMode;
411 use crate::runtime::{App, Command, Harness};
412 use crate::widgets::read_folder;
413
414 struct Demo {
415 browser: FileBrowser,
416 chosen: Option<PathBuf>,
417 }
418
419 #[derive(Debug, Clone)]
420 enum Msg {
421 Picker(FilePickerMsg),
422 }
423
424 impl App for Demo {
425 type Msg = Msg;
426 fn update(&mut self, msg: Msg) -> Command<Msg> {
427 match msg {
428 Msg::Picker(FilePickerMsg::Chosen(path)) => self.chosen = Some(path),
429 Msg::Picker(message) => return self.browser.update(message, Msg::Picker),
430 }
431 Command::none()
432 }
433 fn view(&self, ui: &mut View<'_, Msg>) {
434 FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
435 }
436 }
437
438 fn scratch(name: &str) -> PathBuf {
439 let dir = std::env::temp_dir().join(format!("quvyta-a8-picker-{name}-{}", std::process::id()));
440 let _ = std::fs::remove_dir_all(&dir);
441 std::fs::create_dir_all(dir.join("deploy/scripts")).expect("scratch folders");
442 std::fs::write(dir.join("compose.yaml"), "services:\n").expect("scratch file");
443 std::fs::write(dir.join("deploy/release.sh"), vec![b'#'; 2048]).expect("scratch file");
444 dir
445 }
446
447 fn harness(dir: &Path, mode: PickMode) -> Harness<Demo> {
448 let browser = FileBrowser::new(dir, mode);
449 let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
450 h.set_glyph_mode(GlyphMode::Unicode);
451 let folder = dir.to_path_buf();
452 h.send(Msg::Picker(FilePickerMsg::Open(folder)));
453 h
454 }
455
456 #[test]
457 fn browses_into_folders_and_chooses_a_file() {
458 let dir = scratch("choose");
459 let mut h = harness(&dir, PickMode::Files);
460 let screen = h.screen();
461 assert!(screen.contains("■ deploy") && screen.contains("compose.yaml"), "{screen}");
462 assert!(screen.contains("Parent folder"), "{screen}");
463 h.click_text("deploy");
464 let screen = h.screen();
465 assert!(screen.contains("release.sh") && screen.contains("2.0 KiB"), "{screen}");
466 assert!(screen.contains("scripts"), "{screen}");
467 h.click_text("release.sh");
468 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy/release.sh").as_path()));
469 let name = dir.file_name().map(|n| n.to_string_lossy().into_owned()).unwrap_or_default();
470 h.click_text(&name);
471 assert_eq!(h.app().browser.folder(), dir.as_path());
472 std::fs::remove_dir_all(dir).ok();
473 }
474
475 #[test]
476 fn folder_mode_chooses_the_selected_folder_and_filters() {
477 let dir = scratch("folders");
478 let mut h = harness(&dir, PickMode::Folders);
479 h.click_text("Choose folder");
480 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("deploy").as_path()));
481 h.send(Msg::Picker(FilePickerMsg::Filter("zzz".into())));
482 assert!(h.screen().contains("Nothing matches"), "{}", h.screen());
483 std::fs::remove_dir_all(dir).ok();
484 }
485
486 #[test]
487 fn unreadable_folders_show_a_state() {
488 let missing = std::env::temp_dir().join("quvyta-a8-picker-missing");
489 let h = harness(&missing, PickMode::Files);
490 let screen = h.screen();
491 assert!(screen.contains("✕ This folder does not exist"), "{screen}");
492 assert!(!screen.contains('['));
493 }
494
495 struct Tabbed {
498 tab: usize,
499 browser: FileBrowser,
500 chosen: Vec<(usize, PathBuf)>,
501 }
502
503 #[derive(Debug, Clone)]
504 enum TabMsg {
505 Picker(usize, FilePickerMsg),
506 }
507
508 impl App for Tabbed {
509 type Msg = TabMsg;
510 fn update(&mut self, msg: TabMsg) -> Command<TabMsg> {
511 let TabMsg::Picker(tab, message) = msg;
512 if let FilePickerMsg::Chosen(path) = message {
513 self.chosen.push((tab, path));
514 return Command::none();
515 }
516 self.browser.update(message, move |message| TabMsg::Picker(tab, message))
517 }
518 fn view(&self, ui: &mut View<'_, TabMsg>) {
519 let tab = self.tab;
520 FilePicker::new(&self.browser, move |message| TabMsg::Picker(tab, message)).show(ui).fill();
521 }
522 }
523
524 #[test]
525 fn a_closure_capturing_state_wraps_every_picker_message() {
526 let dir = scratch("closure");
527 let browser = FileBrowser::new(&dir, PickMode::Files);
528 let mut h = Harness::new(Tabbed { tab: 3, browser, chosen: Vec::new() }, 60, 14);
529 h.set_glyph_mode(GlyphMode::Unicode);
530 h.send(TabMsg::Picker(3, FilePickerMsg::Open(dir.clone())));
532 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
533 h.click_text("deploy").click_text("release.sh");
534 assert_eq!(h.app().chosen, [(3, dir.join("deploy/release.sh"))]);
535 std::fs::remove_dir_all(dir).ok();
536 }
537
538 impl From<FilePickerMsg> for Msg {
539 fn from(message: FilePickerMsg) -> Self {
540 Msg::Picker(message)
541 }
542 }
543
544 #[test]
547 fn plain_functions_still_wrap() {
548 fn wrap(message: FilePickerMsg) -> Msg {
549 Msg::Picker(message)
550 }
551 fn picked<M: From<FilePickerMsg>>(message: FilePickerMsg) -> M {
552 M::from(message)
553 }
554 let dir = scratch("plain");
555 let pointer: fn(FilePickerMsg) -> Msg = wrap;
556 let mut browser = FileBrowser::new(&dir, PickMode::Files);
557 let command: Command<Msg> = browser.open(dir.clone(), pointer);
558 assert_eq!(command.actions.len(), 1);
559 let command: Command<Msg> = browser.update(FilePickerMsg::Refresh, picked::<Msg>);
560 assert_eq!(command.actions.len(), 1);
561 let _ = browser.update(FilePickerMsg::Loaded(dir.clone(), read_folder(&dir)), wrap);
562 let mut h = Harness::new(Demo { browser, chosen: None }, 60, 14);
563 h.set_glyph_mode(GlyphMode::Unicode);
564 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
565 let _ = FilePicker::new(&h.app().browser, pointer);
567 h.click_text("compose.yaml");
568 assert_eq!(h.app().chosen.as_deref(), Some(dir.join("compose.yaml").as_path()));
569 std::fs::remove_dir_all(dir).ok();
570 }
571
572 #[test]
573 fn sizes_read_naturally() {
574 assert_eq!(format_size(812), "812 B");
575 assert_eq!(format_size(12_700), "12.4 KiB");
576 assert_eq!(format_size(3 * 1024 * 1024 * 1024 + 1024 * 1024 * 100), "3.1 GiB");
577 }
578
579 struct Held {
581 browser: FileBrowser,
582 }
583
584 impl App for Held {
585 type Msg = Msg;
586 fn update(&mut self, msg: Msg) -> Command<Msg> {
587 let Msg::Picker(message) = msg;
588 let _ = self.browser.update(message, Msg::Picker);
590 Command::none()
591 }
592 fn view(&self, ui: &mut View<'_, Msg>) {
593 FilePicker::new(&self.browser, Msg::Picker).show(ui).fill();
594 }
595 }
596
597 fn held(dir: &Path) -> Harness<Held> {
599 let mut browser = FileBrowser::new(dir, PickMode::Files);
600 let _ = browser.update(FilePickerMsg::Loaded(dir.to_path_buf(), read_folder(dir)), |m| m);
601 let mut h = Harness::new(Held { browser }, 60, 14);
602 h.set_glyph_mode(GlyphMode::Unicode);
603 h.press("tab").press("tab");
604 h
605 }
606
607 fn ms(value: u64) -> std::time::Duration {
608 std::time::Duration::from_millis(value)
609 }
610
611 fn spinning(h: &Harness<Held>) -> bool {
613 let screen = h.screen();
614 screen.lines().next().is_some_and(|line| line.contains(|c| "◜◠◝◞◡◟".contains(c)))
615 }
616
617 fn deliver(h: &mut Harness<Held>, folder: &Path) {
618 h.send(Msg::Picker(FilePickerMsg::Loaded(folder.to_path_buf(), read_folder(folder))));
619 }
620
621 #[test]
625 fn opening_a_folder_keeps_the_listing_until_it_is_read() {
626 let dir = scratch("flash");
627 let mut h = held(&dir);
628 let before = h.screen();
629 h.press("enter");
630 assert_eq!(h.app().browser.loading(), Some(dir.join("deploy").as_path()));
631 for step in [0, 40, 120, 139] {
632 h.advance(ms(step));
633 assert_eq!(h.screen(), before, "at +{step} ms");
634 }
635 deliver(&mut h, &dir.join("deploy"));
636 let screen = h.screen();
637 assert!(screen.contains("› deploy") && screen.contains("release.sh"), "{screen}");
638 assert!(!spinning(&h), "{screen}");
639 h.press("down");
641 assert!(h.screen().contains("▌ ▪ release.sh"), "{}", h.screen());
642 std::fs::remove_dir_all(dir).ok();
643 }
644
645 #[test]
646 fn a_slow_read_shows_the_indicator_after_the_delay_for_at_least_its_minimum() {
647 let dir = scratch("slow");
648 let mut h = held(&dir);
649 h.press("enter").advance(ms(299));
650 assert!(!spinning(&h), "{}", h.screen());
651 h.advance(ms(1));
652 let first = h.screen();
653 assert!(spinning(&h), "{first}");
654 assert!(first.contains("▌ ■ deploy") && first.contains("compose.yaml"), "{first}");
656 h.advance(ms(50));
658 deliver(&mut h, &dir.join("deploy"));
659 assert!(h.screen().contains("release.sh") && spinning(&h), "{}", h.screen());
660 h.advance(ms(449));
661 assert!(spinning(&h), "{}", h.screen());
662 h.advance(ms(1));
663 assert!(!spinning(&h), "{}", h.screen());
664 std::fs::remove_dir_all(dir).ok();
665 }
666
667 #[test]
668 fn a_folder_that_cannot_be_read_shows_its_error_at_once() {
669 let dir = scratch("error");
670 let mut h = held(&dir);
671 let missing = dir.join("gone");
672 h.send(Msg::Picker(FilePickerMsg::Open(missing.clone()))).advance(ms(320));
673 assert!(spinning(&h), "{}", h.screen());
674 deliver(&mut h, &missing);
675 let screen = h.screen();
676 assert!(screen.contains("This folder does not exist"), "{screen}");
677 assert!(!spinning(&h), "{screen}");
678 std::fs::remove_dir_all(dir).ok();
679 }
680
681 #[test]
682 fn a_first_read_leaves_the_space_empty_until_it_is_slow() {
683 let dir = scratch("first");
684 let mut h = Harness::new(Held { browser: FileBrowser::new(&dir, PickMode::Files) }, 90, 12);
686 h.set_glyph_mode(GlyphMode::Unicode);
687 h.send(Msg::Picker(FilePickerMsg::Open(dir.clone())));
688 assert!(!spinning(&h) && !h.screen().contains("deploy"), "{}", h.screen());
689 h.advance(ms(300));
690 assert!(spinning(&h), "{}", h.screen());
691 assert!(
692 h.screen().lines().next().is_some_and(|line| line.trim_end().ends_with(" Reading folder…")),
693 "{}",
694 h.screen()
695 );
696 deliver(&mut h, &dir);
697 assert!(h.screen().contains("compose.yaml"), "{}", h.screen());
698 std::fs::remove_dir_all(dir).ok();
699 }
700}