Skip to main content

brep_app/panels/
file_explorer.rs

1//! Reusable, embeddable file explorer for the application's persistent store.
2//! It owns only transient browsing state; callers decide what activating a file
3//! means (open a document, choose a Save-As name, or insert an ACOMP part).
4//!
5//! Interaction model (matches a conventional file dialog): a single click on a
6//! FILE row selects it (highlight + selection preview); a double-click, the
7//! Enter key, or the footer confirm button *activates* it. Directories are pure
8//! navigation — a single click walks into them. A left sidebar offers quick
9//! places + pinned folders, and the top bar is a clickable breadcrumb with an
10//! editable path and back / forward history — all driven through the
11//! [`ModelStore`] seam so web and native share one UI.
12
13use 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    /// Footer primary/confirm button label (`"Open"` / `"Import"` / `"Insert"`).
30    /// `None` when the caller supplies its own action button and uses the
31    /// explorer only for navigation + name selection (Save As, step-parts
32    /// destination) — then keyboard confirm is disabled too, leaving Enter to
33    /// the caller's name field.
34    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    /// A file was CONFIRMED (double-click, Enter, or the footer confirm button):
59    /// the caller acts on it and closes the modal.
60    pub activated: Option<String>,
61    /// The file currently highlighted, persisting across frames until the
62    /// browser location changes. Mirrors the live selection for callers that
63    /// want it (and for the headed verifier). `None` when a directory or nothing
64    /// is selected.
65    pub selected: Option<String>,
66    /// A file row was single-clicked THIS frame — a one-shot event. Name-field
67    /// callers (Save As, step-parts) fill their field from it without clobbering
68    /// keystrokes the user makes afterwards.
69    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/// Which column the file table is sorted by.
77#[derive(Clone, Copy, PartialEq, Eq, Default)]
78enum SortKey {
79    #[default]
80    Name,
81    Kind,
82    Size,
83    Date,
84}
85
86/// Embeddable browser state. The search text deliberately survives modal uses,
87/// making repeated Open / ACOMP operations retain the user's working filter.
88#[derive(Default)]
89pub struct FileExplorer {
90    query: String,
91    new_folder: String,
92    error: String,
93    /// The highlighted FILE identity (directories are navigated, never selected).
94    /// Persists across frames so the footer confirm button and keyboard Enter
95    /// have a stable target; cleared when the browser location changes so a
96    /// stale highlight never leaks into a different directory.
97    selected: Option<String>,
98    /// The location `selected` belongs to — a mismatch means we walked into
99    /// another directory and must drop the highlight.
100    selected_at: String,
101    /// Visited-location back / forward stacks for the nav buttons.
102    history: Vec<String>,
103    forward: Vec<String>,
104    /// `Some` while the breadcrumb is swapped for an editable path field.
105    editing_path: Option<String>,
106    /// Grab focus for the path field on the frame it appears.
107    path_focus: bool,
108    /// The visible FILE identities in display order — the keyboard-nav target
109    /// set, refreshed every frame by [`Self::show_list`].
110    nav_files: Vec<String>,
111    /// Which column the file table sorts by, and its direction (`false` = asc).
112    sort_key: SortKey,
113    sort_desc: bool,
114    /// Show dotfile / hidden entries in the list. Off by default; toggling it on
115    /// never affects navigation — a hidden path can always be entered directly.
116    show_hidden: bool,
117}
118
119impl FileExplorer {
120    pub fn new() -> Self {
121        Self::default()
122    }
123
124    /// The file identity currently highlighted (for the headed verifier and any
125    /// caller mirroring the selection). `None` when nothing / a directory holds
126    /// the highlight.
127    pub fn selected(&self) -> Option<&str> {
128        self.selected.as_deref()
129    }
130
131    /// Browse the model entries exposed by a platform [`ModelStore`].
132    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        // The browse area is a single RESIZABLE region (drag the bottom-right
156        // handle) whose height is capped to the viewport so the dialog can never
157        // grow taller than the screen; the file list scrolls inside it. One shared
158        // id means resizing any dialog resizes them all.
159        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    // --- navigation with history ---------------------------------------------
187
188    /// Run a navigation action and, when it actually changes location, record the
189    /// previous location on the back stack (clearing the forward stack) and drop
190    /// the file highlight. Shared by up / breadcrumb / place / enter-directory.
191    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    /// Step back to the previous location, remembering the current one for
208    /// forward. A failed navigate restores the stack unchanged.
209    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    // --- top navigation bar: back/forward/up + breadcrumb + new folder --------
236
237    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    /// Either a clickable breadcrumb (each ancestor navigates) with a pencil
292    /// toggle, or — while editing — a path field that navigates on Enter.
293    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); // still editing
319            }
320            // lost focus without Enter → cancel (editing_path stays None)
321            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            // Fill the row width (so a long path scrolls horizontally) but shrink
328            // to ONE row's height — `false` here would balloon vertically to the
329            // modal's huge available height and push the dialog off-screen.
330            .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}"); // ›
336                        }
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                        }; // ✎
348                    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    // --- left sidebar: places + pinned folders --------------------------------
359
360    #[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    /// Read the pinned-locations list (a JSON array under the reserved key).
430    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    /// Add or remove `location` from the pinned list, persisting the result.
438    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    // --- central file list ----------------------------------------------------
455
456    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        // Header row: hidden toggle (left) + filter (right).
465        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                // Dotfiles hide unless toggled on; the filter box applies to files.
485                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        // Directories always first; within each group, by the active column.
493        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        // The PRIMARY layout: a sortable Name / Type / Size / Date table whose
518        // body scrolls inside the resizable region. Header cells toggle the sort.
519        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)) // Name
526            .column(Column::auto().at_least(72.0)) // Type
527            .column(Column::auto().at_least(66.0)) // Size
528            .column(Column::auto().at_least(120.0)) // Date
529            .column(Column::auto().at_least(20.0)) // delete
530            .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                    // An explicit selection wins; before the user picks a row, the
569                    // caller's `current` document shows highlighted.
570                    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    /// Keyboard navigation over the visible FILES: ↑/↓ move the selection, Enter
632    /// confirms it. Only active when the caller offers a confirm action and no
633    /// text field (filter / name / path) holds focus, so typing is never hijacked.
634    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            // Selection preview (bare file name), right-aligned.
704            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
715/// Case-insensitive name order — the tie-breaker for every column sort.
716fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
717    a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
718}
719
720/// Order two optional timestamps, sorting `None` (unknown, e.g. web) last.
721fn 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
730/// The Type column text for an entry (Folder / BREP model / extension / File).
731fn 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
745/// Stable slug for a sort column's hit key.
746fn 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
755/// Human-readable byte size, or an em-dash when the backend cannot report it.
756fn 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
774/// `YYYY-MM-DD HH:MM` (UTC) from whole Unix seconds, or an em-dash when unknown.
775fn 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
785/// Gregorian `(year, month, day)` from a day count since the Unix epoch
786/// (Howard Hinnant's `civil_from_days`) — pure integer arithmetic, so it is
787/// wasm-safe and never touches `SystemTime`.
788fn 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
801/// Split a POSIX-ish location into `(label, navigable-path)` breadcrumb segments,
802/// starting at the root. e.g. `/home/user/models` →
803/// `[("/", "/"), ("home", "/home"), ("user", "/home/user"), ("models", "/home/user/models")]`.
804fn 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
815/// The built-in glyph a sidebar place draws for its category.
816fn place_glyph(kind: PlaceKind) -> &'static str {
817    match kind {
818        PlaceKind::Home => "\u{2302}",       // ⌂
819        PlaceKind::Documents => "\u{1F5CE}", // 🗎
820        PlaceKind::Downloads => "\u{2B07}",  // ⬇
821        PlaceKind::Models => "\u{1F5C0}",    // 🗀
822        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        // A bare root yields just the root segment.
847        assert_eq!(breadcrumb_segments("/").len(), 1);
848    }
849}