1use crate::automation::hit_keys::HitKeyDoc;
21use crate::store::{BrowserEntry, BrowserPlace, ModelStore, PlaceKind, PINNED_KEY};
22use eframe::egui;
23use egui_extras::{Column, TableBuilder};
24use std::cmp::Ordering;
25
26#[derive(Clone, Copy)]
27pub struct FileExplorerOptions<'a> {
28 pub hit_prefix: &'a str,
29 pub empty_label: &'a str,
30 pub row_icon: &'a str,
31 pub current: Option<&'a str>,
32 pub allow_delete: bool,
33 pub allow_import: bool,
34 pub import_label: &'a str,
35 pub import_hit: &'a str,
36 pub show_cancel: bool,
37 pub confirm_label: Option<&'a str>,
43 pub extensions: &'a [&'a str],
44}
45
46impl<'a> FileExplorerOptions<'a> {
47 pub fn open(current: Option<&'a str>) -> Self {
48 Self {
49 hit_prefix: "open",
50 empty_label: "(no saved models)",
51 row_icon: "\u{1F5CE}",
52 current,
53 allow_delete: true,
54 allow_import: false,
55 import_label: "Upload\u{2026}",
56 import_hit: "upload",
57 show_cancel: true,
58 confirm_label: Some("Open"),
59 extensions: crate::store::MODEL_EXTENSIONS,
60 }
61 }
62}
63
64pub static HIT_KEYS: &[HitKeyDoc] = &[
76 HitKeyDoc { panel: "file", prefix: "open:", meaning: "the Open modal's explorer: open:<file name> (a row), open:confirm, open:filter, open:back / forward / up, open:path-edit, open:path-edit-toggle, open:crumb:<i>, open:place:<label>, open:pin:<label>, open:unpin:<label>, open:pin-current, open:new-folder, open:new-folder-name, open:hidden-toggle, open:sort:<column>", command: Some("doc_load") },
77 HitKeyDoc { panel: "file", prefix: "import:", meaning: "the Import modal's explorer, same key set under the `import` prefix, plus import:upload", command: Some("doc_import") },
78 HitKeyDoc { panel: "file", prefix: "saveas:", meaning: "the Save As modal's destination explorer (saveas:file:<key>) and saveas:upload", command: Some("doc_json") },
79 HitKeyDoc { panel: "file", prefix: "insert:", meaning: "the Insert-component modal: insert:lib:<name> for a parts-library entry, insert:model:<key> for the store explorer, insert:upload", command: None },
80 HitKeyDoc { panel: "file", prefix: "del:", meaning: "delete the file on that explorer row (del:<file name>)", command: None },
81 HitKeyDoc { panel: "file", prefix: "upload", meaning: "the explorer's Upload… action where the platform has real-file interchange", command: None },
82 HitKeyDoc { panel: "stepparts", prefix: "steplib:dest:", meaning: "the parts-library destination explorer, the same key set under the `steplib:dest` prefix", command: None },
83];
84
85#[derive(Default)]
86pub struct FileExplorerOutput {
87 pub activated: Option<String>,
90 pub selected: Option<String>,
95 pub picked: Option<String>,
99 pub remove: Option<String>,
100 pub import: bool,
101 pub cancel: bool,
102 pub hits: Vec<(String, egui::Rect)>,
103}
104
105#[derive(Clone, Copy, PartialEq, Eq, Default)]
107enum SortKey {
108 #[default]
109 Name,
110 Kind,
111 Size,
112 Date,
113}
114
115#[derive(Default)]
118pub struct FileExplorer {
119 query: String,
120 new_folder: String,
121 error: String,
122 selected: Option<String>,
127 selected_at: String,
130 history: Vec<String>,
132 forward: Vec<String>,
133 editing_path: Option<String>,
135 path_focus: bool,
137 nav_files: Vec<String>,
140 sort_key: SortKey,
142 sort_desc: bool,
143 show_hidden: bool,
146}
147
148impl FileExplorer {
149 pub fn new() -> Self {
150 Self::default()
151 }
152
153 pub fn selected(&self) -> Option<&str> {
157 self.selected.as_deref()
158 }
159
160 pub fn clear_selection(&mut self) {
166 self.selected = None;
167 }
168
169 pub fn show_store(
171 &mut self,
172 ui: &mut egui::Ui,
173 store: &dyn ModelStore,
174 options: FileExplorerOptions<'_>,
175 ) -> FileExplorerOutput {
176 let entries = store.browser_entries(options.extensions);
177 let location = store.browser_location();
178 if location != self.selected_at {
179 self.selected = None;
180 self.selected_at = location.clone();
181 }
182 let places = store.browser_places();
183 let pins = Self::read_pins(store);
184 let mut out = FileExplorerOutput::default();
185
186 let selected_before = self.selected.clone();
187 self.show_nav_bar(ui, store, &location, options, &mut out);
188 if !self.error.is_empty() {
189 ui.colored_label(ui.visuals().error_fg_color, &self.error);
190 }
191 ui.add_space(2.0);
192 ui.separator();
193
194 bottom_panel(ui, "brep-file-explorer-footer", options.hit_prefix, |ui| {
201 self.show_footer(ui, options, &mut out)
202 });
203 ui.horizontal_top(|ui| {
204 if !places.is_empty() {
205 ui.vertical(|ui| {
206 self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
207 });
208 ui.separator();
209 }
210 ui.vertical(|ui| {
211 self.show_list(ui, store, &entries, options, &mut out);
212 });
213 });
214
215 self.handle_keys(ui, options, &mut out);
216 out.selected = self.selected.clone();
217 if out.picked.is_some() || self.selected != selected_before {
222 ui.ctx().request_repaint();
223 }
224 out
225 }
226
227 fn nav<F: FnOnce() -> Result<(), String>>(&mut self, store: &dyn ModelStore, action: F) {
233 let before = store.browser_location();
234 match action() {
235 Ok(()) => {
236 let after = store.browser_location();
237 if after != before {
238 self.history.push(before);
239 self.forward.clear();
240 }
241 self.error.clear();
242 self.selected = None;
243 }
244 Err(error) => self.error = error,
245 }
246 }
247
248 fn back(&mut self, store: &dyn ModelStore) {
251 if let Some(prev) = self.history.pop() {
252 let current = store.browser_location();
253 if store.browser_navigate(&prev).is_ok() {
254 self.forward.push(current);
255 self.selected = None;
256 self.error.clear();
257 } else {
258 self.history.push(prev);
259 }
260 }
261 }
262
263 fn forward_go(&mut self, store: &dyn ModelStore) {
264 if let Some(next) = self.forward.pop() {
265 let current = store.browser_location();
266 if store.browser_navigate(&next).is_ok() {
267 self.history.push(current);
268 self.selected = None;
269 self.error.clear();
270 } else {
271 self.forward.push(next);
272 }
273 }
274 }
275
276 fn show_nav_bar(
279 &mut self,
280 ui: &mut egui::Ui,
281 store: &dyn ModelStore,
282 location: &str,
283 options: FileExplorerOptions<'_>,
284 out: &mut FileExplorerOutput,
285 ) {
286 ui.horizontal(|ui| {
287 let back = ui.add_enabled(!self.history.is_empty(), egui::Button::new("\u{25C0}").small());
288 out.hits.push((format!("{}:back", options.hit_prefix), back.rect));
289 if back.clicked() {
290 self.back(store);
291 }
292 let fwd = {
293 let b = crate::icon_text::icon_button(ui, "\u{25B6}").small();
294 ui.add_enabled(!self.forward.is_empty(), b)
295 };
296 out.hits.push((format!("{}:forward", options.hit_prefix), fwd.rect));
297 if fwd.clicked() {
298 self.forward_go(store);
299 }
300 let up = ui.small_button("\u{2191}");
301 out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
302 if up.clicked() {
303 self.nav(store, || store.browser_up());
304 }
305 ui.separator();
306 self.show_breadcrumb(ui, store, location, options, out);
307 });
308 ui.horizontal(|ui| {
309 let create = ui.small_button("+ Folder");
310 out.hits
311 .push((format!("{}:new-folder", options.hit_prefix), create.rect));
312 let field = ui.add(
313 egui::TextEdit::singleline(&mut self.new_folder)
314 .hint_text("New folder")
315 .desired_width(140.0),
316 );
317 out.hits
318 .push((format!("{}:new-folder-name", options.hit_prefix), field.rect));
319 let submit = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
320 if (create.clicked() || submit) && !self.new_folder.trim().is_empty() {
321 match store.browser_create_dir(self.new_folder.trim()) {
322 Ok(()) => {
323 self.new_folder.clear();
324 self.error.clear();
325 }
326 Err(error) => self.error = error,
327 }
328 }
329 });
330 }
331
332 fn show_breadcrumb(
335 &mut self,
336 ui: &mut egui::Ui,
337 store: &dyn ModelStore,
338 location: &str,
339 options: FileExplorerOptions<'_>,
340 out: &mut FileExplorerOutput,
341 ) {
342 if self.editing_path.is_some() {
343 let mut buf = self.editing_path.take().unwrap();
344 let resp = ui.add(
345 egui::TextEdit::singleline(&mut buf)
346 .hint_text("type a path, Enter to go")
347 .desired_width(300.0),
348 );
349 out.hits
350 .push((format!("{}:path-edit", options.hit_prefix), resp.rect));
351 if self.path_focus {
352 resp.request_focus();
353 self.path_focus = false;
354 }
355 let go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
356 if go {
357 self.nav(store, || store.browser_navigate(&buf));
358 } else if !resp.lost_focus() {
359 self.editing_path = Some(buf); }
361 return;
363 }
364 let edit = ui.add(crate::icon_text::icon_button(ui, "\u{270E}").small()); out.hits
369 .push((format!("{}:path-edit-toggle", options.hit_prefix), edit.rect));
370 if edit.on_hover_text("Type a path").clicked() {
371 self.editing_path = Some(location.to_string());
372 self.path_focus = true;
373 }
374 let avail = ui.available_width();
375 egui::ScrollArea::horizontal()
376 .id_salt(format!("{}-crumbs", options.hit_prefix))
377 .max_width(avail)
378 .auto_shrink([false, true])
382 .stick_to_right(true)
385 .show(ui, |ui| {
386 ui.horizontal(|ui| {
387 for (i, (label, path)) in breadcrumb_segments(location).into_iter().enumerate() {
388 if i > 0 {
389 ui.weak("\u{203A}"); }
391 let seg = ui.add(egui::Button::new(label).frame(false).small());
392 out.hits
393 .push((format!("{}:crumb:{i}", options.hit_prefix), seg.rect));
394 if seg.clicked() {
395 self.nav(store, || store.browser_navigate(&path));
396 }
397 }
398 });
399 });
400 }
401
402 #[allow(clippy::too_many_arguments)]
405 fn show_sidebar(
406 &mut self,
407 ui: &mut egui::Ui,
408 store: &dyn ModelStore,
409 places: &[BrowserPlace],
410 pins: &[String],
411 location: &str,
412 options: FileExplorerOptions<'_>,
413 out: &mut FileExplorerOutput,
414 ) {
415 ui.set_min_width(132.0);
416 ui.set_max_width(152.0);
417 egui::ScrollArea::vertical()
418 .id_salt(format!("{}-sidebar", options.hit_prefix))
419 .auto_shrink([false, false])
420 .show(ui, |ui| {
421 ui.weak("Places");
422 for place in places {
423 let glyph = place_glyph(place.kind);
424 let resp = ui.selectable_label(false, format!("{glyph} {}", place.label));
425 out.hits
426 .push((format!("{}:place:{}", options.hit_prefix, place.label), resp.rect));
427 if resp.clicked() {
428 let loc = place.location.clone();
429 self.nav(store, || store.browser_navigate(&loc));
430 }
431 }
432 if !pins.is_empty() {
433 ui.add_space(6.0);
434 ui.weak("Pinned");
435 for pin in pins {
436 ui.horizontal(|ui| {
437 let label = pin.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(pin);
438 let resp = ui.selectable_label(false, format!("\u{1F4CC} {label}"));
439 out.hits
440 .push((format!("{}:pin:{label}", options.hit_prefix), resp.rect));
441 if resp.clicked() {
442 let loc = pin.clone();
443 self.nav(store, || store.browser_navigate(&loc));
444 }
445 let x = {
446 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
447 ui.add(b)
448 };
449 out.hits
450 .push((format!("{}:unpin:{label}", options.hit_prefix), x.rect));
451 if x.clicked() {
452 Self::set_pin(store, pin, false);
453 }
454 });
455 }
456 }
457 ui.add_space(6.0);
458 let pinned_now = pins.iter().any(|p| p == location);
459 let label = if pinned_now {
460 "\u{2715} Unpin folder"
461 } else {
462 "\u{1F4CC} Pin folder"
463 };
464 let btn = ui.small_button(label);
465 out.hits
466 .push((format!("{}:pin-current", options.hit_prefix), btn.rect));
467 if btn.clicked() {
468 Self::set_pin(store, location, !pinned_now);
469 }
470 });
471 }
472
473 fn read_pins(store: &dyn ModelStore) -> Vec<String> {
475 store
476 .read(PINNED_KEY)
477 .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
478 .unwrap_or_default()
479 }
480
481 fn set_pin(store: &dyn ModelStore, location: &str, want: bool) {
483 let mut pins = Self::read_pins(store);
484 let has = pins.iter().any(|p| p == location);
485 if want && !has {
486 pins.push(location.to_string());
487 } else if !want && has {
488 pins.retain(|p| p != location);
489 } else {
490 return;
491 }
492 let _ = store.write(
493 PINNED_KEY,
494 &serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into()),
495 );
496 }
497
498 fn show_list(
501 &mut self,
502 ui: &mut egui::Ui,
503 store: &dyn ModelStore,
504 entries: &[BrowserEntry],
505 options: FileExplorerOptions<'_>,
506 out: &mut FileExplorerOutput,
507 ) {
508 ui.horizontal(|ui| {
510 let hidden = ui.checkbox(&mut self.show_hidden, "Hidden");
511 out.hits
512 .push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
513 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
514 let search = ui.add(
515 egui::TextEdit::singleline(&mut self.query)
516 .hint_text("Filter files")
517 .desired_width(150.0),
518 );
519 out.hits
520 .push((format!("{}:filter", options.hit_prefix), search.rect));
521 });
522 });
523
524 let query = self.query.trim().to_ascii_lowercase();
525 let mut visible: Vec<&BrowserEntry> = entries
526 .iter()
527 .filter(|entry| {
528 let hidden = entry.name.starts_with('.');
530 (self.show_hidden || !hidden)
531 && (entry.is_dir
532 || query.is_empty()
533 || entry.name.to_ascii_lowercase().contains(&query))
534 })
535 .collect();
536 let (key, desc) = (self.sort_key, self.sort_desc);
538 visible.sort_by(|a, b| {
539 b.is_dir.cmp(&a.is_dir).then_with(|| {
540 let ord = match key {
541 SortKey::Name => cmp_name(a, b),
542 SortKey::Kind => entry_kind(a).cmp(&entry_kind(b)).then_with(|| cmp_name(a, b)),
543 SortKey::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)).then_with(|| cmp_name(a, b)),
544 SortKey::Date => cmp_opt(a.modified, b.modified).then_with(|| cmp_name(a, b)),
545 };
546 if desc { ord.reverse() } else { ord }
547 })
548 });
549 self.nav_files = visible
550 .iter()
551 .filter(|entry| !entry.is_dir)
552 .map(|entry| entry.identity.clone())
553 .collect();
554
555 if visible.is_empty() {
556 ui.separator();
557 ui.weak(options.empty_label);
558 return;
559 }
560
561 let row_h = egui::TextStyle::Body.resolve(ui.style()).size + 6.0;
565 let mut clicked_sort: Option<SortKey> = None;
566 TableBuilder::new(ui)
567 .id_salt(format!("{}-table", options.hit_prefix))
568 .striped(true)
569 .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
570 .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| {
576 for (label, col) in [
577 ("Name", SortKey::Name),
578 ("Type", SortKey::Kind),
579 ("Size", SortKey::Size),
580 ("Date", SortKey::Date),
581 ] {
582 header.col(|ui| {
583 let arrow = if key == col {
584 if desc {
585 " \u{25BE}"
586 } else {
587 " \u{25B4}"
588 }
589 } else {
590 ""
591 };
592 let btn =
593 ui.add(egui::Button::new(format!("{label}{arrow}")).frame(false));
594 out.hits.push((
595 format!("{}:sort:{}", options.hit_prefix, sort_slug(col)),
596 btn.rect,
597 ));
598 if btn.clicked() {
599 clicked_sort = Some(col);
600 }
601 });
602 }
603 header.col(|_ui| {});
604 })
605 .body(|body| {
606 body.rows(row_h, visible.len(), |mut row| {
607 let entry = visible[row.index()];
608 let icon = if entry.is_dir {
609 "\u{1F5C0}"
610 } else {
611 options.row_icon
612 };
613 let highlight = match &self.selected {
616 Some(sel) => !entry.is_dir && sel == &entry.identity,
617 None => options.current == Some(entry.identity.as_str()),
618 };
619 row.col(|ui| {
620 let r = crate::icon_text::selectable_icon_label(
621 ui,
622 highlight,
623 &format!("{icon} {}", entry.name),
624 );
625 out.hits
626 .push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
627 if r.double_clicked() {
628 if entry.is_dir {
629 self.nav(store, || store.browser_enter(&entry.identity));
630 } else {
631 self.selected = Some(entry.identity.clone());
632 out.activated = Some(entry.identity.clone());
633 }
634 } else if r.clicked() {
635 if entry.is_dir {
636 self.nav(store, || store.browser_enter(&entry.identity));
637 } else {
638 self.selected = Some(entry.identity.clone());
639 out.picked = Some(entry.identity.clone());
640 }
641 }
642 });
643 row.col(|ui| {
644 ui.weak(entry_kind(entry));
645 });
646 row.col(|ui| {
647 ui.weak(fmt_size(entry.size));
648 });
649 row.col(|ui| {
650 ui.weak(fmt_date(entry.modified));
651 });
652 row.col(|ui| {
653 if options.allow_delete && !entry.is_dir {
654 let del = {
655 let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
656 ui.add(b)
657 };
658 out.hits.push((format!("del:{}", entry.name), del.rect));
659 if del.clicked() {
660 out.remove = Some(entry.identity.clone());
661 }
662 }
663 });
664 });
665 });
666 if let Some(col) = clicked_sort {
667 if self.sort_key == col {
668 self.sort_desc = !self.sort_desc;
669 } else {
670 self.sort_key = col;
671 self.sort_desc = false;
672 }
673 }
674 }
675
676 fn handle_keys(
680 &mut self,
681 ui: &egui::Ui,
682 options: FileExplorerOptions<'_>,
683 out: &mut FileExplorerOutput,
684 ) {
685 if options.confirm_label.is_none() || ui.ctx().egui_wants_keyboard_input() {
686 return;
687 }
688 if self.nav_files.is_empty() {
689 return;
690 }
691 let (down, up, enter) = ui.input(|i| {
692 (
693 i.key_pressed(egui::Key::ArrowDown),
694 i.key_pressed(egui::Key::ArrowUp),
695 i.key_pressed(egui::Key::Enter),
696 )
697 });
698 if down || up {
699 let current = self
700 .selected
701 .as_deref()
702 .and_then(|s| self.nav_files.iter().position(|f| f == s));
703 let next = match current {
704 Some(i) if down => (i + 1).min(self.nav_files.len() - 1),
705 Some(i) => i.saturating_sub(1),
706 None => 0,
707 };
708 self.selected = Some(self.nav_files[next].clone());
709 }
710 if enter {
711 if let Some(sel) = &self.selected {
712 out.activated = Some(sel.clone());
713 }
714 }
715 }
716
717 fn show_footer(
718 &self,
719 ui: &mut egui::Ui,
720 options: FileExplorerOptions<'_>,
721 out: &mut FileExplorerOutput,
722 ) {
723 ui.separator();
724 ui.horizontal(|ui| {
725 if let Some(label) = options.confirm_label {
726 let confirm =
727 ui.add_enabled(self.selected.is_some(), egui::Button::new(label));
728 out.hits
729 .push((format!("{}:confirm", options.hit_prefix), confirm.rect));
730 if confirm.clicked() {
731 out.activated = self.selected.clone();
732 }
733 }
734 if options.allow_import {
735 let import = {
736 let label = format!("\u{2B06} {}", options.import_label);
737 let b = crate::icon_text::icon_button(ui, &label);
738 ui.add(b)
739 };
740 out.hits.push((options.import_hit.into(), import.rect));
741 out.import = import.clicked();
742 }
743 if options.show_cancel {
744 let cancel = ui.button("Cancel");
745 out.hits.push(("cancel".into(), cancel.rect));
746 out.cancel = cancel.clicked();
747 }
748 ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
750 if let Some(sel) = &self.selected {
751 let name = sel.rsplit(['/', '\\']).next().unwrap_or(sel);
752 ui.add(
756 egui::Label::new(egui::RichText::new(name).weak()).truncate(),
757 );
758 }
759 });
760 });
761 }
762
763}
764
765pub fn dialog_body<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
775 let screen = ui.ctx().content_rect().size();
776 let max = egui::vec2((screen.x - 40.0).max(320.0), (screen.y - 80.0).max(240.0));
777 egui::Resize::default()
778 .id_salt("brep-file-dialog-size")
779 .with_stroke(false)
782 .min_size(egui::vec2(460.0_f32.min(max.x), 300.0_f32.min(max.y)))
783 .max_size(max)
784 .default_size(egui::vec2(620.0_f32.min(max.x), 520.0_f32.min(max.y)))
785 .show(ui, add_contents)
786}
787
788pub fn dialog_footer<R>(
795 ui: &mut egui::Ui,
796 salt: &str,
797 add_contents: impl FnOnce(&mut egui::Ui) -> R,
798) -> R {
799 bottom_panel(ui, "brep-file-dialog-footer", salt, add_contents)
800}
801
802fn bottom_panel<R>(
806 ui: &mut egui::Ui,
807 base: &str,
808 salt: &str,
809 add_contents: impl FnOnce(&mut egui::Ui) -> R,
810) -> R {
811 egui::containers::panel::Panel::bottom(egui::Id::new((base, salt)))
812 .frame(egui::Frame::NONE)
813 .show_separator_line(false)
814 .show(ui, add_contents)
815 .inner
816}
817
818fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
820 a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
821}
822
823fn cmp_opt(a: Option<f64>, b: Option<f64>) -> Ordering {
825 match (a, b) {
826 (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
827 (Some(_), None) => Ordering::Less,
828 (None, Some(_)) => Ordering::Greater,
829 (None, None) => Ordering::Equal,
830 }
831}
832
833fn entry_kind(entry: &BrowserEntry) -> String {
835 if entry.is_dir {
836 return "Folder".into();
837 }
838 let lower = entry.name.to_ascii_lowercase();
839 if lower.ends_with(".nbrep") {
840 return "BREP model".into();
841 }
842 match entry.name.rsplit_once('.') {
843 Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_uppercase(),
844 _ => "File".into(),
845 }
846}
847
848fn sort_slug(key: SortKey) -> &'static str {
850 match key {
851 SortKey::Name => "name",
852 SortKey::Kind => "type",
853 SortKey::Size => "size",
854 SortKey::Date => "date",
855 }
856}
857
858fn fmt_size(size: Option<u64>) -> String {
860 match size {
861 None => "\u{2014}".into(),
862 Some(bytes) => {
863 let b = bytes as f64;
864 if bytes < 1024 {
865 format!("{bytes} B")
866 } else if b < 1024.0 * 1024.0 {
867 format!("{:.1} KB", b / 1024.0)
868 } else if b < 1024.0 * 1024.0 * 1024.0 {
869 format!("{:.1} MB", b / (1024.0 * 1024.0))
870 } else {
871 format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
872 }
873 }
874 }
875}
876
877fn fmt_date(modified: Option<f64>) -> String {
879 let Some(secs) = modified else {
880 return "\u{2014}".into();
881 };
882 let days = (secs / 86_400.0).floor() as i64;
883 let (y, m, d) = civil_from_days(days);
884 let sod = ((secs as i64) % 86_400 + 86_400) % 86_400;
885 format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", sod / 3600, (sod % 3600) / 60)
886}
887
888fn civil_from_days(z: i64) -> (i64, u32, u32) {
892 let z = z + 719_468;
893 let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
894 let doe = z - era * 146_097;
895 let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
896 let y = yoe + era * 400;
897 let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
898 let mp = (5 * doy + 2) / 153;
899 let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
900 let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
901 (if m <= 2 { y + 1 } else { y }, m, d)
902}
903
904fn breadcrumb_segments(location: &str) -> Vec<(String, String)> {
908 let mut out = vec![("/".to_string(), "/".to_string())];
909 let mut acc = String::new();
910 for comp in location.split('/').filter(|s| !s.is_empty()) {
911 acc.push('/');
912 acc.push_str(comp);
913 out.push((comp.to_string(), acc.clone()));
914 }
915 out
916}
917
918fn place_glyph(kind: PlaceKind) -> &'static str {
920 match kind {
921 PlaceKind::Home => "\u{2302}", PlaceKind::Documents => "\u{1F5CE}", PlaceKind::Downloads => "\u{2B07}", PlaceKind::Models => "\u{1F5C0}", PlaceKind::Root => "/",
926 }
927}
928