1use crate::store::{BrowserEntry, BrowserPlace, ModelStore, PlaceKind, PINNED_KEY};
14use eframe::egui;
15use egui_extras::{Column, TableBuilder};
16use std::cmp::Ordering;
17
18#[derive(Clone, Copy)]
19pub struct FileExplorerOptions<'a> {
20 pub hit_prefix: &'a str,
21 pub empty_label: &'a str,
22 pub row_icon: &'a str,
23 pub current: Option<&'a str>,
24 pub allow_delete: bool,
25 pub allow_import: bool,
26 pub import_label: &'a str,
27 pub import_hit: &'a str,
28 pub show_cancel: bool,
29 pub confirm_label: Option<&'a str>,
35 pub extensions: &'a [&'a str],
36}
37
38impl<'a> FileExplorerOptions<'a> {
39 pub fn open(current: Option<&'a str>) -> Self {
40 Self {
41 hit_prefix: "open",
42 empty_label: "(no saved models)",
43 row_icon: "\u{1F5CE}",
44 current,
45 allow_delete: true,
46 allow_import: false,
47 import_label: "Upload\u{2026}",
48 import_hit: "upload",
49 show_cancel: true,
50 confirm_label: Some("Open"),
51 extensions: &["BREP.json", "json"],
52 }
53 }
54}
55
56#[derive(Default)]
57pub struct FileExplorerOutput {
58 pub activated: Option<String>,
61 pub selected: Option<String>,
66 pub picked: Option<String>,
70 pub remove: Option<String>,
71 pub import: bool,
72 pub cancel: bool,
73 pub hits: Vec<(String, egui::Rect)>,
74}
75
76#[derive(Clone, Copy, PartialEq, Eq, Default)]
78enum SortKey {
79 #[default]
80 Name,
81 Kind,
82 Size,
83 Date,
84}
85
86#[derive(Default)]
89pub struct FileExplorer {
90 query: String,
91 new_folder: String,
92 error: String,
93 selected: Option<String>,
98 selected_at: String,
101 history: Vec<String>,
103 forward: Vec<String>,
104 editing_path: Option<String>,
106 path_focus: bool,
108 nav_files: Vec<String>,
111 sort_key: SortKey,
113 sort_desc: bool,
114 show_hidden: bool,
117}
118
119impl FileExplorer {
120 pub fn new() -> Self {
121 Self::default()
122 }
123
124 pub fn selected(&self) -> Option<&str> {
128 self.selected.as_deref()
129 }
130
131 pub fn show_store(
133 &mut self,
134 ui: &mut egui::Ui,
135 store: &dyn ModelStore,
136 options: FileExplorerOptions<'_>,
137 ) -> FileExplorerOutput {
138 let entries = store.browser_entries(options.extensions);
139 let location = store.browser_location();
140 if location != self.selected_at {
141 self.selected = None;
142 self.selected_at = location.clone();
143 }
144 let places = store.browser_places();
145 let pins = Self::read_pins(store);
146 let mut out = FileExplorerOutput::default();
147
148 self.show_nav_bar(ui, store, &location, options, &mut out);
149 if !self.error.is_empty() {
150 ui.colored_label(ui.visuals().error_fg_color, &self.error);
151 }
152 ui.add_space(2.0);
153 ui.separator();
154
155 let screen_h = ui.ctx().content_rect().height();
160 let max_h = (screen_h - 210.0).clamp(240.0, 900.0);
161 egui::Resize::default()
162 .id_salt("brep-file-explorer-size")
163 .min_size([460.0, 240.0])
164 .max_size([f32::INFINITY, max_h])
165 .default_size([560.0, max_h.min(360.0)])
166 .show(ui, |ui| {
167 ui.horizontal_top(|ui| {
168 if !places.is_empty() {
169 ui.vertical(|ui| {
170 self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
171 });
172 ui.separator();
173 }
174 ui.vertical(|ui| {
175 self.show_list(ui, store, &entries, options, &mut out);
176 });
177 });
178 });
179
180 self.handle_keys(ui, options, &mut out);
181 out.selected = self.selected.clone();
182 self.show_footer(ui, options, &mut out);
183 out
184 }
185
186 fn nav<F: FnOnce() -> Result<(), String>>(&mut self, store: &dyn ModelStore, action: F) {
192 let before = store.browser_location();
193 match action() {
194 Ok(()) => {
195 let after = store.browser_location();
196 if after != before {
197 self.history.push(before);
198 self.forward.clear();
199 }
200 self.error.clear();
201 self.selected = None;
202 }
203 Err(error) => self.error = error,
204 }
205 }
206
207 fn back(&mut self, store: &dyn ModelStore) {
210 if let Some(prev) = self.history.pop() {
211 let current = store.browser_location();
212 if store.browser_navigate(&prev).is_ok() {
213 self.forward.push(current);
214 self.selected = None;
215 self.error.clear();
216 } else {
217 self.history.push(prev);
218 }
219 }
220 }
221
222 fn forward_go(&mut self, store: &dyn ModelStore) {
223 if let Some(next) = self.forward.pop() {
224 let current = store.browser_location();
225 if store.browser_navigate(&next).is_ok() {
226 self.history.push(current);
227 self.selected = None;
228 self.error.clear();
229 } else {
230 self.forward.push(next);
231 }
232 }
233 }
234
235 fn show_nav_bar(
238 &mut self,
239 ui: &mut egui::Ui,
240 store: &dyn ModelStore,
241 location: &str,
242 options: FileExplorerOptions<'_>,
243 out: &mut FileExplorerOutput,
244 ) {
245 ui.horizontal(|ui| {
246 let back = ui.add_enabled(!self.history.is_empty(), egui::Button::new("\u{25C0}").small());
247 out.hits.push((format!("{}:back", options.hit_prefix), back.rect));
248 if back.clicked() {
249 self.back(store);
250 }
251 let fwd = {
252 let b = crate::icon_text::icon_button(ui, "\u{25B6}").small();
253 ui.add_enabled(!self.forward.is_empty(), b)
254 };
255 out.hits.push((format!("{}:forward", options.hit_prefix), fwd.rect));
256 if fwd.clicked() {
257 self.forward_go(store);
258 }
259 let up = ui.small_button("\u{2191}");
260 out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
261 if up.clicked() {
262 self.nav(store, || store.browser_up());
263 }
264 ui.separator();
265 self.show_breadcrumb(ui, store, location, options, out);
266 });
267 ui.horizontal(|ui| {
268 let create = ui.small_button("+ Folder");
269 out.hits
270 .push((format!("{}:new-folder", options.hit_prefix), create.rect));
271 let field = ui.add(
272 egui::TextEdit::singleline(&mut self.new_folder)
273 .hint_text("New folder")
274 .desired_width(140.0),
275 );
276 out.hits
277 .push((format!("{}:new-folder-name", options.hit_prefix), field.rect));
278 let submit = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
279 if (create.clicked() || submit) && !self.new_folder.trim().is_empty() {
280 match store.browser_create_dir(self.new_folder.trim()) {
281 Ok(()) => {
282 self.new_folder.clear();
283 self.error.clear();
284 }
285 Err(error) => self.error = error,
286 }
287 }
288 });
289 }
290
291 fn show_breadcrumb(
294 &mut self,
295 ui: &mut egui::Ui,
296 store: &dyn ModelStore,
297 location: &str,
298 options: FileExplorerOptions<'_>,
299 out: &mut FileExplorerOutput,
300 ) {
301 if self.editing_path.is_some() {
302 let mut buf = self.editing_path.take().unwrap();
303 let resp = ui.add(
304 egui::TextEdit::singleline(&mut buf)
305 .hint_text("type a path, Enter to go")
306 .desired_width(300.0),
307 );
308 out.hits
309 .push((format!("{}:path-edit", options.hit_prefix), resp.rect));
310 if self.path_focus {
311 resp.request_focus();
312 self.path_focus = false;
313 }
314 let go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
315 if go {
316 self.nav(store, || store.browser_navigate(&buf));
317 } else if !resp.lost_focus() {
318 self.editing_path = Some(buf); }
320 return;
322 }
323 let avail = ui.available_width();
324 egui::ScrollArea::horizontal()
325 .id_salt(format!("{}-crumbs", options.hit_prefix))
326 .max_width(avail)
327 .auto_shrink([false, true])
331 .show(ui, |ui| {
332 ui.horizontal(|ui| {
333 for (i, (label, path)) in breadcrumb_segments(location).into_iter().enumerate() {
334 if i > 0 {
335 ui.weak("\u{203A}"); }
337 let seg = ui.add(egui::Button::new(label).frame(false).small());
338 out.hits
339 .push((format!("{}:crumb:{i}", options.hit_prefix), seg.rect));
340 if seg.clicked() {
341 self.nav(store, || store.browser_navigate(&path));
342 }
343 }
344 let edit = {
345 let b = crate::icon_text::icon_button(ui, "\u{270E}").small();
346 ui.add(b)
347 }; out.hits
349 .push((format!("{}:path-edit-toggle", options.hit_prefix), edit.rect));
350 if edit.clicked() {
351 self.editing_path = Some(location.to_string());
352 self.path_focus = true;
353 }
354 });
355 });
356 }
357
358 #[allow(clippy::too_many_arguments)]
361 fn show_sidebar(
362 &mut self,
363 ui: &mut egui::Ui,
364 store: &dyn ModelStore,
365 places: &[BrowserPlace],
366 pins: &[String],
367 location: &str,
368 options: FileExplorerOptions<'_>,
369 out: &mut FileExplorerOutput,
370 ) {
371 ui.set_min_width(132.0);
372 ui.set_max_width(152.0);
373 egui::ScrollArea::vertical()
374 .id_salt(format!("{}-sidebar", options.hit_prefix))
375 .auto_shrink([false, false])
376 .show(ui, |ui| {
377 ui.weak("Places");
378 for place in places {
379 let glyph = place_glyph(place.kind);
380 let resp = ui.selectable_label(false, format!("{glyph} {}", place.label));
381 out.hits
382 .push((format!("{}:place:{}", options.hit_prefix, place.label), resp.rect));
383 if resp.clicked() {
384 let loc = place.location.clone();
385 self.nav(store, || store.browser_navigate(&loc));
386 }
387 }
388 if !pins.is_empty() {
389 ui.add_space(6.0);
390 ui.weak("Pinned");
391 for pin in pins {
392 ui.horizontal(|ui| {
393 let label = pin.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(pin);
394 let resp = ui.selectable_label(false, format!("\u{1F4CC} {label}"));
395 out.hits
396 .push((format!("{}:pin:{label}", options.hit_prefix), resp.rect));
397 if resp.clicked() {
398 let loc = pin.clone();
399 self.nav(store, || store.browser_navigate(&loc));
400 }
401 let x = {
402 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
403 ui.add(b)
404 };
405 out.hits
406 .push((format!("{}:unpin:{label}", options.hit_prefix), x.rect));
407 if x.clicked() {
408 Self::set_pin(store, pin, false);
409 }
410 });
411 }
412 }
413 ui.add_space(6.0);
414 let pinned_now = pins.iter().any(|p| p == location);
415 let label = if pinned_now {
416 "\u{2715} Unpin folder"
417 } else {
418 "\u{1F4CC} Pin folder"
419 };
420 let btn = ui.small_button(label);
421 out.hits
422 .push((format!("{}:pin-current", options.hit_prefix), btn.rect));
423 if btn.clicked() {
424 Self::set_pin(store, location, !pinned_now);
425 }
426 });
427 }
428
429 fn read_pins(store: &dyn ModelStore) -> Vec<String> {
431 store
432 .read(PINNED_KEY)
433 .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
434 .unwrap_or_default()
435 }
436
437 fn set_pin(store: &dyn ModelStore, location: &str, want: bool) {
439 let mut pins = Self::read_pins(store);
440 let has = pins.iter().any(|p| p == location);
441 if want && !has {
442 pins.push(location.to_string());
443 } else if !want && has {
444 pins.retain(|p| p != location);
445 } else {
446 return;
447 }
448 let _ = store.write(
449 PINNED_KEY,
450 &serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into()),
451 );
452 }
453
454 fn show_list(
457 &mut self,
458 ui: &mut egui::Ui,
459 store: &dyn ModelStore,
460 entries: &[BrowserEntry],
461 options: FileExplorerOptions<'_>,
462 out: &mut FileExplorerOutput,
463 ) {
464 ui.horizontal(|ui| {
466 let hidden = ui.checkbox(&mut self.show_hidden, "Hidden");
467 out.hits
468 .push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
469 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
470 let search = ui.add(
471 egui::TextEdit::singleline(&mut self.query)
472 .hint_text("Filter files")
473 .desired_width(150.0),
474 );
475 out.hits
476 .push((format!("{}:filter", options.hit_prefix), search.rect));
477 });
478 });
479
480 let query = self.query.trim().to_ascii_lowercase();
481 let mut visible: Vec<&BrowserEntry> = entries
482 .iter()
483 .filter(|entry| {
484 let hidden = entry.name.starts_with('.');
486 (self.show_hidden || !hidden)
487 && (entry.is_dir
488 || query.is_empty()
489 || entry.name.to_ascii_lowercase().contains(&query))
490 })
491 .collect();
492 let (key, desc) = (self.sort_key, self.sort_desc);
494 visible.sort_by(|a, b| {
495 b.is_dir.cmp(&a.is_dir).then_with(|| {
496 let ord = match key {
497 SortKey::Name => cmp_name(a, b),
498 SortKey::Kind => entry_kind(a).cmp(&entry_kind(b)).then_with(|| cmp_name(a, b)),
499 SortKey::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)).then_with(|| cmp_name(a, b)),
500 SortKey::Date => cmp_opt(a.modified, b.modified).then_with(|| cmp_name(a, b)),
501 };
502 if desc { ord.reverse() } else { ord }
503 })
504 });
505 self.nav_files = visible
506 .iter()
507 .filter(|entry| !entry.is_dir)
508 .map(|entry| entry.identity.clone())
509 .collect();
510
511 if visible.is_empty() {
512 ui.separator();
513 ui.weak(options.empty_label);
514 return;
515 }
516
517 let row_h = egui::TextStyle::Body.resolve(ui.style()).size + 6.0;
520 let mut clicked_sort: Option<SortKey> = None;
521 TableBuilder::new(ui)
522 .id_salt(format!("{}-table", options.hit_prefix))
523 .striped(true)
524 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
525 .column(Column::remainder().at_least(150.0).clip(true)) .column(Column::auto().at_least(72.0)) .column(Column::auto().at_least(66.0)) .column(Column::auto().at_least(120.0)) .column(Column::auto().at_least(20.0)) .header(row_h, |mut header| {
531 for (label, col) in [
532 ("Name", SortKey::Name),
533 ("Type", SortKey::Kind),
534 ("Size", SortKey::Size),
535 ("Date", SortKey::Date),
536 ] {
537 header.col(|ui| {
538 let arrow = if key == col {
539 if desc {
540 " \u{25BE}"
541 } else {
542 " \u{25B4}"
543 }
544 } else {
545 ""
546 };
547 let btn =
548 ui.add(egui::Button::new(format!("{label}{arrow}")).frame(false));
549 out.hits.push((
550 format!("{}:sort:{}", options.hit_prefix, sort_slug(col)),
551 btn.rect,
552 ));
553 if btn.clicked() {
554 clicked_sort = Some(col);
555 }
556 });
557 }
558 header.col(|_ui| {});
559 })
560 .body(|body| {
561 body.rows(row_h, visible.len(), |mut row| {
562 let entry = visible[row.index()];
563 let icon = if entry.is_dir {
564 "\u{1F5C0}"
565 } else {
566 options.row_icon
567 };
568 let highlight = match &self.selected {
571 Some(sel) => !entry.is_dir && sel == &entry.identity,
572 None => options.current == Some(entry.identity.as_str()),
573 };
574 row.col(|ui| {
575 let r = crate::icon_text::selectable_icon_label(
576 ui,
577 highlight,
578 &format!("{icon} {}", entry.name),
579 );
580 out.hits
581 .push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
582 if r.double_clicked() {
583 if entry.is_dir {
584 self.nav(store, || store.browser_enter(&entry.identity));
585 } else {
586 self.selected = Some(entry.identity.clone());
587 out.activated = Some(entry.identity.clone());
588 }
589 } else if r.clicked() {
590 if entry.is_dir {
591 self.nav(store, || store.browser_enter(&entry.identity));
592 } else {
593 self.selected = Some(entry.identity.clone());
594 out.picked = Some(entry.identity.clone());
595 }
596 }
597 });
598 row.col(|ui| {
599 ui.weak(entry_kind(entry));
600 });
601 row.col(|ui| {
602 ui.weak(fmt_size(entry.size));
603 });
604 row.col(|ui| {
605 ui.weak(fmt_date(entry.modified));
606 });
607 row.col(|ui| {
608 if options.allow_delete && !entry.is_dir {
609 let del = {
610 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
611 ui.add(b)
612 };
613 out.hits.push((format!("del:{}", entry.name), del.rect));
614 if del.clicked() {
615 out.remove = Some(entry.identity.clone());
616 }
617 }
618 });
619 });
620 });
621 if let Some(col) = clicked_sort {
622 if self.sort_key == col {
623 self.sort_desc = !self.sort_desc;
624 } else {
625 self.sort_key = col;
626 self.sort_desc = false;
627 }
628 }
629 }
630
631 fn handle_keys(
635 &mut self,
636 ui: &egui::Ui,
637 options: FileExplorerOptions<'_>,
638 out: &mut FileExplorerOutput,
639 ) {
640 if options.confirm_label.is_none() || ui.ctx().egui_wants_keyboard_input() {
641 return;
642 }
643 if self.nav_files.is_empty() {
644 return;
645 }
646 let (down, up, enter) = ui.input(|i| {
647 (
648 i.key_pressed(egui::Key::ArrowDown),
649 i.key_pressed(egui::Key::ArrowUp),
650 i.key_pressed(egui::Key::Enter),
651 )
652 });
653 if down || up {
654 let current = self
655 .selected
656 .as_deref()
657 .and_then(|s| self.nav_files.iter().position(|f| f == s));
658 let next = match current {
659 Some(i) if down => (i + 1).min(self.nav_files.len() - 1),
660 Some(i) => i.saturating_sub(1),
661 None => 0,
662 };
663 self.selected = Some(self.nav_files[next].clone());
664 }
665 if enter {
666 if let Some(sel) = &self.selected {
667 out.activated = Some(sel.clone());
668 }
669 }
670 }
671
672 fn show_footer(
673 &self,
674 ui: &mut egui::Ui,
675 options: FileExplorerOptions<'_>,
676 out: &mut FileExplorerOutput,
677 ) {
678 ui.separator();
679 ui.horizontal(|ui| {
680 if let Some(label) = options.confirm_label {
681 let confirm =
682 ui.add_enabled(self.selected.is_some(), egui::Button::new(label));
683 out.hits
684 .push((format!("{}:confirm", options.hit_prefix), confirm.rect));
685 if confirm.clicked() {
686 out.activated = self.selected.clone();
687 }
688 }
689 if options.allow_import {
690 let import = {
691 let label = format!("\u{2B06} {}", options.import_label);
692 let b = crate::icon_text::icon_button(ui, &label);
693 ui.add(b)
694 };
695 out.hits.push((options.import_hit.into(), import.rect));
696 out.import = import.clicked();
697 }
698 if options.show_cancel {
699 let cancel = ui.button("Cancel");
700 out.hits.push(("cancel".into(), cancel.rect));
701 out.cancel = cancel.clicked();
702 }
703 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
705 if let Some(sel) = &self.selected {
706 let name = sel.rsplit(['/', '\\']).next().unwrap_or(sel);
707 ui.weak(name);
708 }
709 });
710 });
711 }
712
713}
714
715fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
717 a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
718}
719
720fn cmp_opt(a: Option<f64>, b: Option<f64>) -> Ordering {
722 match (a, b) {
723 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
724 (Some(_), None) => Ordering::Less,
725 (None, Some(_)) => Ordering::Greater,
726 (None, None) => Ordering::Equal,
727 }
728}
729
730fn entry_kind(entry: &BrowserEntry) -> String {
732 if entry.is_dir {
733 return "Folder".into();
734 }
735 let lower = entry.name.to_ascii_lowercase();
736 if lower.ends_with(".brep.json") {
737 return "BREP model".into();
738 }
739 match entry.name.rsplit_once('.') {
740 Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_uppercase(),
741 _ => "File".into(),
742 }
743}
744
745fn sort_slug(key: SortKey) -> &'static str {
747 match key {
748 SortKey::Name => "name",
749 SortKey::Kind => "type",
750 SortKey::Size => "size",
751 SortKey::Date => "date",
752 }
753}
754
755fn fmt_size(size: Option<u64>) -> String {
757 match size {
758 None => "\u{2014}".into(),
759 Some(bytes) => {
760 let b = bytes as f64;
761 if bytes < 1024 {
762 format!("{bytes} B")
763 } else if b < 1024.0 * 1024.0 {
764 format!("{:.1} KB", b / 1024.0)
765 } else if b < 1024.0 * 1024.0 * 1024.0 {
766 format!("{:.1} MB", b / (1024.0 * 1024.0))
767 } else {
768 format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
769 }
770 }
771 }
772}
773
774fn fmt_date(modified: Option<f64>) -> String {
776 let Some(secs) = modified else {
777 return "\u{2014}".into();
778 };
779 let days = (secs / 86_400.0).floor() as i64;
780 let (y, m, d) = civil_from_days(days);
781 let sod = ((secs as i64) % 86_400 + 86_400) % 86_400;
782 format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", sod / 3600, (sod % 3600) / 60)
783}
784
785fn civil_from_days(z: i64) -> (i64, u32, u32) {
789 let z = z + 719_468;
790 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
791 let doe = z - era * 146_097;
792 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
793 let y = yoe + era * 400;
794 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
795 let mp = (5 * doy + 2) / 153;
796 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
797 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
798 (if m <= 2 { y + 1 } else { y }, m, d)
799}
800
801fn breadcrumb_segments(location: &str) -> Vec<(String, String)> {
805 let mut out = vec![("/".to_string(), "/".to_string())];
806 let mut acc = String::new();
807 for comp in location.split('/').filter(|s| !s.is_empty()) {
808 acc.push('/');
809 acc.push_str(comp);
810 out.push((comp.to_string(), acc.clone()));
811 }
812 out
813}
814
815fn place_glyph(kind: PlaceKind) -> &'static str {
817 match kind {
818 PlaceKind::Home => "\u{2302}", PlaceKind::Documents => "\u{1F5CE}", PlaceKind::Downloads => "\u{2B07}", PlaceKind::Models => "\u{1F5C0}", PlaceKind::Root => "/",
823 }
824}
825
826#[cfg(test)]
827mod tests {
828 use super::*;
829
830 #[test]
831 fn explorer_is_constructible_as_independent_embeddable_state() {
832 let explorer = FileExplorer::new();
833 assert!(explorer.query.is_empty());
834 assert!(explorer.new_folder.is_empty());
835 assert!(explorer.error.is_empty());
836 assert!(explorer.selected.is_none());
837 assert!(explorer.history.is_empty());
838 }
839
840 #[test]
841 fn breadcrumb_splits_into_navigable_ancestors() {
842 let segs = breadcrumb_segments("/home/user/models");
843 assert_eq!(segs.first().unwrap(), &("/".to_string(), "/".to_string()));
844 assert_eq!(segs.last().unwrap().1, "/home/user/models");
845 assert_eq!(segs[1], ("home".to_string(), "/home".to_string()));
846 assert_eq!(breadcrumb_segments("/").len(), 1);
848 }
849}