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//!
13//! The explorer never sizes itself: it FILLS the region its host hands it. A
14//! modal host wraps its whole body in [`dialog_body`], which puts one resize
15//! grip at the dialog window's bottom-right corner; a window host ([`egui::Window`])
16//! already has that grip. Either way the host's own trailing widgets go in a
17//! [`dialog_footer`] BEFORE the explorer, so they pin to the bottom and the
18//! file list is the only part that scrolls.
19
20use crate::store::{BrowserEntry, BrowserPlace, ModelStore, PlaceKind, PINNED_KEY};
21use eframe::egui;
22use egui_extras::{Column, TableBuilder};
23use std::cmp::Ordering;
24
25#[derive(Clone, Copy)]
26pub struct FileExplorerOptions<'a> {
27    pub hit_prefix: &'a str,
28    pub empty_label: &'a str,
29    pub row_icon: &'a str,
30    pub current: Option<&'a str>,
31    pub allow_delete: bool,
32    pub allow_import: bool,
33    pub import_label: &'a str,
34    pub import_hit: &'a str,
35    pub show_cancel: bool,
36    /// Footer primary/confirm button label (`"Open"` / `"Import"` / `"Insert"`).
37    /// `None` when the caller supplies its own action button and uses the
38    /// explorer only for navigation + name selection (Save As, step-parts
39    /// destination) — then keyboard confirm is disabled too, leaving Enter to
40    /// the caller's name field.
41    pub confirm_label: Option<&'a str>,
42    pub extensions: &'a [&'a str],
43}
44
45impl<'a> FileExplorerOptions<'a> {
46    pub fn open(current: Option<&'a str>) -> Self {
47        Self {
48            hit_prefix: "open",
49            empty_label: "(no saved models)",
50            row_icon: "\u{1F5CE}",
51            current,
52            allow_delete: true,
53            allow_import: false,
54            import_label: "Upload\u{2026}",
55            import_hit: "upload",
56            show_cancel: true,
57            confirm_label: Some("Open"),
58            extensions: &["BREP.json", "json"],
59        }
60    }
61}
62
63#[derive(Default)]
64pub struct FileExplorerOutput {
65    /// A file was CONFIRMED (double-click, Enter, or the footer confirm button):
66    /// the caller acts on it and closes the modal.
67    pub activated: Option<String>,
68    /// The file currently highlighted, persisting across frames until the
69    /// browser location changes. Mirrors the live selection for callers that
70    /// want it (and for the headed verifier). `None` when a directory or nothing
71    /// is selected.
72    pub selected: Option<String>,
73    /// A file row was single-clicked THIS frame — a one-shot event. Name-field
74    /// callers (Save As, step-parts) fill their field from it without clobbering
75    /// keystrokes the user makes afterwards.
76    pub picked: Option<String>,
77    pub remove: Option<String>,
78    pub import: bool,
79    pub cancel: bool,
80    pub hits: Vec<(String, egui::Rect)>,
81}
82
83/// Which column the file table is sorted by.
84#[derive(Clone, Copy, PartialEq, Eq, Default)]
85enum SortKey {
86    #[default]
87    Name,
88    Kind,
89    Size,
90    Date,
91}
92
93/// Embeddable browser state. The search text deliberately survives modal uses,
94/// making repeated Open / ACOMP operations retain the user's working filter.
95#[derive(Default)]
96pub struct FileExplorer {
97    query: String,
98    new_folder: String,
99    error: String,
100    /// The highlighted FILE identity (directories are navigated, never selected).
101    /// Persists across frames so the footer confirm button and keyboard Enter
102    /// have a stable target; cleared when the browser location changes so a
103    /// stale highlight never leaks into a different directory.
104    selected: Option<String>,
105    /// The location `selected` belongs to — a mismatch means we walked into
106    /// another directory and must drop the highlight.
107    selected_at: String,
108    /// Visited-location back / forward stacks for the nav buttons.
109    history: Vec<String>,
110    forward: Vec<String>,
111    /// `Some` while the breadcrumb is swapped for an editable path field.
112    editing_path: Option<String>,
113    /// Grab focus for the path field on the frame it appears.
114    path_focus: bool,
115    /// The visible FILE identities in display order — the keyboard-nav target
116    /// set, refreshed every frame by [`Self::show_list`].
117    nav_files: Vec<String>,
118    /// Which column the file table sorts by, and its direction (`false` = asc).
119    sort_key: SortKey,
120    sort_desc: bool,
121    /// Show dotfile / hidden entries in the list. Off by default; toggling it on
122    /// never affects navigation — a hidden path can always be entered directly.
123    show_hidden: bool,
124}
125
126impl FileExplorer {
127    pub fn new() -> Self {
128        Self::default()
129    }
130
131    /// The file identity currently highlighted (for the headed verifier and any
132    /// caller mirroring the selection). `None` when nothing / a directory holds
133    /// the highlight.
134    pub fn selected(&self) -> Option<&str> {
135        self.selected.as_deref()
136    }
137
138    /// Browse the model entries exposed by a platform [`ModelStore`].
139    pub fn show_store(
140        &mut self,
141        ui: &mut egui::Ui,
142        store: &dyn ModelStore,
143        options: FileExplorerOptions<'_>,
144    ) -> FileExplorerOutput {
145        let entries = store.browser_entries(options.extensions);
146        let location = store.browser_location();
147        if location != self.selected_at {
148            self.selected = None;
149            self.selected_at = location.clone();
150        }
151        let places = store.browser_places();
152        let pins = Self::read_pins(store);
153        let mut out = FileExplorerOutput::default();
154
155        let selected_before = self.selected.clone();
156        self.show_nav_bar(ui, store, &location, options, &mut out);
157        if !self.error.is_empty() {
158            ui.colored_label(ui.visuals().error_fg_color, &self.error);
159        }
160        ui.add_space(2.0);
161        ui.separator();
162
163        // The explorer FILLS the region its host gave it — a modal's resizable
164        // body ([`dialog_body`]), a window's content — rather than carrying a
165        // resize handle of its own around the file table: the footer pins to the
166        // bottom (under anything the host already pinned there) and the browse
167        // area takes every pixel between it and the nav bar, so the file list is
168        // the only thing that scrolls.
169        bottom_panel(ui, "brep-file-explorer-footer", options.hit_prefix, |ui| {
170            self.show_footer(ui, options, &mut out)
171        });
172        ui.horizontal_top(|ui| {
173            if !places.is_empty() {
174                ui.vertical(|ui| {
175                    self.show_sidebar(ui, store, &places, &pins, &location, options, &mut out);
176                });
177                ui.separator();
178            }
179            ui.vertical(|ui| {
180                self.show_list(ui, store, &entries, options, &mut out);
181            });
182        });
183
184        self.handle_keys(ui, options, &mut out);
185        out.selected = self.selected.clone();
186        // The footer (and any host footer: a name field, a confirm button) was
187        // drawn BEFORE the row that just changed the highlight, so it is showing
188        // last frame's selection. Ask for one more frame rather than leaving a
189        // confirm button greyed out until the pointer happens to move again.
190        if out.picked.is_some() || self.selected != selected_before {
191            ui.ctx().request_repaint();
192        }
193        out
194    }
195
196    // --- navigation with history ---------------------------------------------
197
198    /// Run a navigation action and, when it actually changes location, record the
199    /// previous location on the back stack (clearing the forward stack) and drop
200    /// the file highlight. Shared by up / breadcrumb / place / enter-directory.
201    fn nav<F: FnOnce() -> Result<(), String>>(&mut self, store: &dyn ModelStore, action: F) {
202        let before = store.browser_location();
203        match action() {
204            Ok(()) => {
205                let after = store.browser_location();
206                if after != before {
207                    self.history.push(before);
208                    self.forward.clear();
209                }
210                self.error.clear();
211                self.selected = None;
212            }
213            Err(error) => self.error = error,
214        }
215    }
216
217    /// Step back to the previous location, remembering the current one for
218    /// forward. A failed navigate restores the stack unchanged.
219    fn back(&mut self, store: &dyn ModelStore) {
220        if let Some(prev) = self.history.pop() {
221            let current = store.browser_location();
222            if store.browser_navigate(&prev).is_ok() {
223                self.forward.push(current);
224                self.selected = None;
225                self.error.clear();
226            } else {
227                self.history.push(prev);
228            }
229        }
230    }
231
232    fn forward_go(&mut self, store: &dyn ModelStore) {
233        if let Some(next) = self.forward.pop() {
234            let current = store.browser_location();
235            if store.browser_navigate(&next).is_ok() {
236                self.history.push(current);
237                self.selected = None;
238                self.error.clear();
239            } else {
240                self.forward.push(next);
241            }
242        }
243    }
244
245    // --- top navigation bar: back/forward/up + breadcrumb + new folder --------
246
247    fn show_nav_bar(
248        &mut self,
249        ui: &mut egui::Ui,
250        store: &dyn ModelStore,
251        location: &str,
252        options: FileExplorerOptions<'_>,
253        out: &mut FileExplorerOutput,
254    ) {
255        ui.horizontal(|ui| {
256            let back = ui.add_enabled(!self.history.is_empty(), egui::Button::new("\u{25C0}").small());
257            out.hits.push((format!("{}:back", options.hit_prefix), back.rect));
258            if back.clicked() {
259                self.back(store);
260            }
261            let fwd = {
262            let b = crate::icon_text::icon_button(ui, "\u{25B6}").small();
263            ui.add_enabled(!self.forward.is_empty(), b)
264        };
265            out.hits.push((format!("{}:forward", options.hit_prefix), fwd.rect));
266            if fwd.clicked() {
267                self.forward_go(store);
268            }
269            let up = ui.small_button("\u{2191}");
270            out.hits.push((format!("{}:up", options.hit_prefix), up.rect));
271            if up.clicked() {
272                self.nav(store, || store.browser_up());
273            }
274            ui.separator();
275            self.show_breadcrumb(ui, store, location, options, out);
276        });
277        ui.horizontal(|ui| {
278            let create = ui.small_button("+ Folder");
279            out.hits
280                .push((format!("{}:new-folder", options.hit_prefix), create.rect));
281            let field = ui.add(
282                egui::TextEdit::singleline(&mut self.new_folder)
283                    .hint_text("New folder")
284                    .desired_width(140.0),
285            );
286            out.hits
287                .push((format!("{}:new-folder-name", options.hit_prefix), field.rect));
288            let submit = field.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
289            if (create.clicked() || submit) && !self.new_folder.trim().is_empty() {
290                match store.browser_create_dir(self.new_folder.trim()) {
291                    Ok(()) => {
292                        self.new_folder.clear();
293                        self.error.clear();
294                    }
295                    Err(error) => self.error = error,
296                }
297            }
298        });
299    }
300
301    /// Either a clickable breadcrumb (each ancestor navigates) with a pencil
302    /// toggle, or — while editing — a path field that navigates on Enter.
303    fn show_breadcrumb(
304        &mut self,
305        ui: &mut egui::Ui,
306        store: &dyn ModelStore,
307        location: &str,
308        options: FileExplorerOptions<'_>,
309        out: &mut FileExplorerOutput,
310    ) {
311        if self.editing_path.is_some() {
312            let mut buf = self.editing_path.take().unwrap();
313            let resp = ui.add(
314                egui::TextEdit::singleline(&mut buf)
315                    .hint_text("type a path, Enter to go")
316                    .desired_width(300.0),
317            );
318            out.hits
319                .push((format!("{}:path-edit", options.hit_prefix), resp.rect));
320            if self.path_focus {
321                resp.request_focus();
322                self.path_focus = false;
323            }
324            let go = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
325            if go {
326                self.nav(store, || store.browser_navigate(&buf));
327            } else if !resp.lost_focus() {
328                self.editing_path = Some(buf); // still editing
329            }
330            // lost focus without Enter → cancel (editing_path stays None)
331            return;
332        }
333        let avail = ui.available_width();
334        egui::ScrollArea::horizontal()
335            .id_salt(format!("{}-crumbs", options.hit_prefix))
336            .max_width(avail)
337            // Fill the row width (so a long path scrolls horizontally) but shrink
338            // to ONE row's height — `false` here would balloon vertically to the
339            // modal's huge available height and push the dialog off-screen.
340            .auto_shrink([false, true])
341            .show(ui, |ui| {
342                ui.horizontal(|ui| {
343                    for (i, (label, path)) in breadcrumb_segments(location).into_iter().enumerate() {
344                        if i > 0 {
345                            ui.weak("\u{203A}"); // ›
346                        }
347                        let seg = ui.add(egui::Button::new(label).frame(false).small());
348                        out.hits
349                            .push((format!("{}:crumb:{i}", options.hit_prefix), seg.rect));
350                        if seg.clicked() {
351                            self.nav(store, || store.browser_navigate(&path));
352                        }
353                    }
354                    let edit = {
355                            let b = crate::icon_text::icon_button(ui, "\u{270E}").small();
356                            ui.add(b)
357                        }; // ✎
358                    out.hits
359                        .push((format!("{}:path-edit-toggle", options.hit_prefix), edit.rect));
360                    if edit.clicked() {
361                        self.editing_path = Some(location.to_string());
362                        self.path_focus = true;
363                    }
364                });
365            });
366    }
367
368    // --- left sidebar: places + pinned folders --------------------------------
369
370    #[allow(clippy::too_many_arguments)]
371    fn show_sidebar(
372        &mut self,
373        ui: &mut egui::Ui,
374        store: &dyn ModelStore,
375        places: &[BrowserPlace],
376        pins: &[String],
377        location: &str,
378        options: FileExplorerOptions<'_>,
379        out: &mut FileExplorerOutput,
380    ) {
381        ui.set_min_width(132.0);
382        ui.set_max_width(152.0);
383        egui::ScrollArea::vertical()
384            .id_salt(format!("{}-sidebar", options.hit_prefix))
385            .auto_shrink([false, false])
386            .show(ui, |ui| {
387                ui.weak("Places");
388                for place in places {
389                    let glyph = place_glyph(place.kind);
390                    let resp = ui.selectable_label(false, format!("{glyph} {}", place.label));
391                    out.hits
392                        .push((format!("{}:place:{}", options.hit_prefix, place.label), resp.rect));
393                    if resp.clicked() {
394                        let loc = place.location.clone();
395                        self.nav(store, || store.browser_navigate(&loc));
396                    }
397                }
398                if !pins.is_empty() {
399                    ui.add_space(6.0);
400                    ui.weak("Pinned");
401                    for pin in pins {
402                        ui.horizontal(|ui| {
403                            let label = pin.rsplit(['/', '\\']).find(|s| !s.is_empty()).unwrap_or(pin);
404                            let resp = ui.selectable_label(false, format!("\u{1F4CC} {label}"));
405                            out.hits
406                                .push((format!("{}:pin:{label}", options.hit_prefix), resp.rect));
407                            if resp.clicked() {
408                                let loc = pin.clone();
409                                self.nav(store, || store.browser_navigate(&loc));
410                            }
411                            let x = {
412                                let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
413                                ui.add(b)
414                            };
415                            out.hits
416                                .push((format!("{}:unpin:{label}", options.hit_prefix), x.rect));
417                            if x.clicked() {
418                                Self::set_pin(store, pin, false);
419                            }
420                        });
421                    }
422                }
423                ui.add_space(6.0);
424                let pinned_now = pins.iter().any(|p| p == location);
425                let label = if pinned_now {
426                    "\u{2715} Unpin folder"
427                } else {
428                    "\u{1F4CC} Pin folder"
429                };
430                let btn = ui.small_button(label);
431                out.hits
432                    .push((format!("{}:pin-current", options.hit_prefix), btn.rect));
433                if btn.clicked() {
434                    Self::set_pin(store, location, !pinned_now);
435                }
436            });
437    }
438
439    /// Read the pinned-locations list (a JSON array under the reserved key).
440    fn read_pins(store: &dyn ModelStore) -> Vec<String> {
441        store
442            .read(PINNED_KEY)
443            .and_then(|raw| serde_json::from_str::<Vec<String>>(&raw).ok())
444            .unwrap_or_default()
445    }
446
447    /// Add or remove `location` from the pinned list, persisting the result.
448    fn set_pin(store: &dyn ModelStore, location: &str, want: bool) {
449        let mut pins = Self::read_pins(store);
450        let has = pins.iter().any(|p| p == location);
451        if want && !has {
452            pins.push(location.to_string());
453        } else if !want && has {
454            pins.retain(|p| p != location);
455        } else {
456            return;
457        }
458        let _ = store.write(
459            PINNED_KEY,
460            &serde_json::to_string(&pins).unwrap_or_else(|_| "[]".into()),
461        );
462    }
463
464    // --- central file list ----------------------------------------------------
465
466    fn show_list(
467        &mut self,
468        ui: &mut egui::Ui,
469        store: &dyn ModelStore,
470        entries: &[BrowserEntry],
471        options: FileExplorerOptions<'_>,
472        out: &mut FileExplorerOutput,
473    ) {
474        // Header row: hidden toggle (left) + filter (right).
475        ui.horizontal(|ui| {
476            let hidden = ui.checkbox(&mut self.show_hidden, "Hidden");
477            out.hits
478                .push((format!("{}:hidden-toggle", options.hit_prefix), hidden.rect));
479            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
480                let search = ui.add(
481                    egui::TextEdit::singleline(&mut self.query)
482                        .hint_text("Filter files")
483                        .desired_width(150.0),
484                );
485                out.hits
486                    .push((format!("{}:filter", options.hit_prefix), search.rect));
487            });
488        });
489
490        let query = self.query.trim().to_ascii_lowercase();
491        let mut visible: Vec<&BrowserEntry> = entries
492            .iter()
493            .filter(|entry| {
494                // Dotfiles hide unless toggled on; the filter box applies to files.
495                let hidden = entry.name.starts_with('.');
496                (self.show_hidden || !hidden)
497                    && (entry.is_dir
498                        || query.is_empty()
499                        || entry.name.to_ascii_lowercase().contains(&query))
500            })
501            .collect();
502        // Directories always first; within each group, by the active column.
503        let (key, desc) = (self.sort_key, self.sort_desc);
504        visible.sort_by(|a, b| {
505            b.is_dir.cmp(&a.is_dir).then_with(|| {
506                let ord = match key {
507                    SortKey::Name => cmp_name(a, b),
508                    SortKey::Kind => entry_kind(a).cmp(&entry_kind(b)).then_with(|| cmp_name(a, b)),
509                    SortKey::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)).then_with(|| cmp_name(a, b)),
510                    SortKey::Date => cmp_opt(a.modified, b.modified).then_with(|| cmp_name(a, b)),
511                };
512                if desc { ord.reverse() } else { ord }
513            })
514        });
515        self.nav_files = visible
516            .iter()
517            .filter(|entry| !entry.is_dir)
518            .map(|entry| entry.identity.clone())
519            .collect();
520
521        if visible.is_empty() {
522            ui.separator();
523            ui.weak(options.empty_label);
524            return;
525        }
526
527        // The PRIMARY layout: a sortable Name / Type / Size / Date table whose
528        // body scrolls inside the height the host left us. Header cells toggle
529        // the sort.
530        let row_h = egui::TextStyle::Body.resolve(ui.style()).size + 6.0;
531        let mut clicked_sort: Option<SortKey> = None;
532        TableBuilder::new(ui)
533            .id_salt(format!("{}-table", options.hit_prefix))
534            .striped(true)
535            .cell_layout(egui::Layout::left_to_right(egui::Align::Center))
536            .column(Column::remainder().at_least(150.0).clip(true)) // Name
537            .column(Column::auto().at_least(72.0)) // Type
538            .column(Column::auto().at_least(66.0)) // Size
539            .column(Column::auto().at_least(120.0)) // Date
540            .column(Column::auto().at_least(20.0)) // delete
541            .header(row_h, |mut header| {
542                for (label, col) in [
543                    ("Name", SortKey::Name),
544                    ("Type", SortKey::Kind),
545                    ("Size", SortKey::Size),
546                    ("Date", SortKey::Date),
547                ] {
548                    header.col(|ui| {
549                        let arrow = if key == col {
550                            if desc {
551                                " \u{25BE}"
552                            } else {
553                                " \u{25B4}"
554                            }
555                        } else {
556                            ""
557                        };
558                        let btn =
559                            ui.add(egui::Button::new(format!("{label}{arrow}")).frame(false));
560                        out.hits.push((
561                            format!("{}:sort:{}", options.hit_prefix, sort_slug(col)),
562                            btn.rect,
563                        ));
564                        if btn.clicked() {
565                            clicked_sort = Some(col);
566                        }
567                    });
568                }
569                header.col(|_ui| {});
570            })
571            .body(|body| {
572                body.rows(row_h, visible.len(), |mut row| {
573                    let entry = visible[row.index()];
574                    let icon = if entry.is_dir {
575                        "\u{1F5C0}"
576                    } else {
577                        options.row_icon
578                    };
579                    // An explicit selection wins; before the user picks a row, the
580                    // caller's `current` document shows highlighted.
581                    let highlight = match &self.selected {
582                        Some(sel) => !entry.is_dir && sel == &entry.identity,
583                        None => options.current == Some(entry.identity.as_str()),
584                    };
585                    row.col(|ui| {
586                        let r = crate::icon_text::selectable_icon_label(
587                            ui,
588                            highlight,
589                            &format!("{icon} {}", entry.name),
590                        );
591                        out.hits
592                            .push((format!("{}:{}", options.hit_prefix, entry.name), r.rect));
593                        if r.double_clicked() {
594                            if entry.is_dir {
595                                self.nav(store, || store.browser_enter(&entry.identity));
596                            } else {
597                                self.selected = Some(entry.identity.clone());
598                                out.activated = Some(entry.identity.clone());
599                            }
600                        } else if r.clicked() {
601                            if entry.is_dir {
602                                self.nav(store, || store.browser_enter(&entry.identity));
603                            } else {
604                                self.selected = Some(entry.identity.clone());
605                                out.picked = Some(entry.identity.clone());
606                            }
607                        }
608                    });
609                    row.col(|ui| {
610                        ui.weak(entry_kind(entry));
611                    });
612                    row.col(|ui| {
613                        ui.weak(fmt_size(entry.size));
614                    });
615                    row.col(|ui| {
616                        ui.weak(fmt_date(entry.modified));
617                    });
618                    row.col(|ui| {
619                        if options.allow_delete && !entry.is_dir {
620                            let del = {
621                                let b = crate::icon_text::icon_button(ui, "\u{2715}").small();
622                                ui.add(b)
623                            };
624                            out.hits.push((format!("del:{}", entry.name), del.rect));
625                            if del.clicked() {
626                                out.remove = Some(entry.identity.clone());
627                            }
628                        }
629                    });
630                });
631            });
632        if let Some(col) = clicked_sort {
633            if self.sort_key == col {
634                self.sort_desc = !self.sort_desc;
635            } else {
636                self.sort_key = col;
637                self.sort_desc = false;
638            }
639        }
640    }
641
642    /// Keyboard navigation over the visible FILES: ↑/↓ move the selection, Enter
643    /// confirms it. Only active when the caller offers a confirm action and no
644    /// text field (filter / name / path) holds focus, so typing is never hijacked.
645    fn handle_keys(
646        &mut self,
647        ui: &egui::Ui,
648        options: FileExplorerOptions<'_>,
649        out: &mut FileExplorerOutput,
650    ) {
651        if options.confirm_label.is_none() || ui.ctx().egui_wants_keyboard_input() {
652            return;
653        }
654        if self.nav_files.is_empty() {
655            return;
656        }
657        let (down, up, enter) = ui.input(|i| {
658            (
659                i.key_pressed(egui::Key::ArrowDown),
660                i.key_pressed(egui::Key::ArrowUp),
661                i.key_pressed(egui::Key::Enter),
662            )
663        });
664        if down || up {
665            let current = self
666                .selected
667                .as_deref()
668                .and_then(|s| self.nav_files.iter().position(|f| f == s));
669            let next = match current {
670                Some(i) if down => (i + 1).min(self.nav_files.len() - 1),
671                Some(i) => i.saturating_sub(1),
672                None => 0,
673            };
674            self.selected = Some(self.nav_files[next].clone());
675        }
676        if enter {
677            if let Some(sel) = &self.selected {
678                out.activated = Some(sel.clone());
679            }
680        }
681    }
682
683    fn show_footer(
684        &self,
685        ui: &mut egui::Ui,
686        options: FileExplorerOptions<'_>,
687        out: &mut FileExplorerOutput,
688    ) {
689        ui.separator();
690        ui.horizontal(|ui| {
691            if let Some(label) = options.confirm_label {
692                let confirm =
693                    ui.add_enabled(self.selected.is_some(), egui::Button::new(label));
694                out.hits
695                    .push((format!("{}:confirm", options.hit_prefix), confirm.rect));
696                if confirm.clicked() {
697                    out.activated = self.selected.clone();
698                }
699            }
700            if options.allow_import {
701                let import = {
702                let label = format!("\u{2B06} {}", options.import_label);
703                let b = crate::icon_text::icon_button(ui, &label);
704                ui.add(b)
705            };
706                out.hits.push((options.import_hit.into(), import.rect));
707                out.import = import.clicked();
708            }
709            if options.show_cancel {
710                let cancel = ui.button("Cancel");
711                out.hits.push(("cancel".into(), cancel.rect));
712                out.cancel = cancel.clicked();
713            }
714            // Selection preview (bare file name), right-aligned.
715            ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
716                if let Some(sel) = &self.selected {
717                    let name = sel.rsplit(['/', '\\']).next().unwrap_or(sel);
718                    // Truncated: the preview is the LAST thing on the footer row,
719                    // so a long file name laid out at its natural width runs left
720                    // across the Import / Cancel buttons it is meant to sit beside.
721                    ui.add(
722                        egui::Label::new(egui::RichText::new(name).weak()).truncate(),
723                    );
724                }
725            });
726        });
727    }
728
729}
730
731/// Wrap a file dialog's WHOLE body — heading, browser, footer and all — in the
732/// shared resizable frame. The drag grip therefore lands at the dialog WINDOW's
733/// bottom-right corner instead of around the file table, and dragging it grows
734/// the dialog itself in both axes, capped to the viewport so it can never grow
735/// off-screen. One shared id means resizing any file dialog resizes them all.
736///
737/// Only for MODAL hosts: an [`egui::Window`] already carries its own corner
738/// grip, and a second frame inside it would put back exactly the handle this
739/// removes.
740pub fn dialog_body<R>(ui: &mut egui::Ui, add_contents: impl FnOnce(&mut egui::Ui) -> R) -> R {
741    let screen = ui.ctx().content_rect().size();
742    let max = egui::vec2((screen.x - 40.0).max(320.0), (screen.y - 80.0).max(240.0));
743    egui::Resize::default()
744        .id_salt("brep-file-dialog-size")
745        // The modal frame already draws the dialog's border; the resize region
746        // fills it exactly, so its own stroke would only double it up.
747        .with_stroke(false)
748        .min_size(egui::vec2(460.0_f32.min(max.x), 300.0_f32.min(max.y)))
749        .max_size(max)
750        .default_size(egui::vec2(620.0_f32.min(max.x), 520.0_f32.min(max.y)))
751        .show(ui, add_contents)
752}
753
754/// Pin a dialog's own trailing widgets — a status line, a name field, the
755/// action buttons — to the BOTTOM of the dialog. Call it BEFORE
756/// [`FileExplorer::show_store`]: the explorer then fills the gap that is left,
757/// which is what keeps the buttons still while the file list scrolls. `salt`
758/// distinguishes one dialog's footer from another's (two can be on screen at
759/// once), and the widgets inside are drawn top-down in the order written.
760pub fn dialog_footer<R>(
761    ui: &mut egui::Ui,
762    salt: &str,
763    add_contents: impl FnOnce(&mut egui::Ui) -> R,
764) -> R {
765    bottom_panel(ui, "brep-file-dialog-footer", salt, add_contents)
766}
767
768/// The one bottom-pinned strip both footers use: no frame and no separator line
769/// of its own, so it reads as part of the dialog rather than as a panel, and it
770/// takes exactly the height its contents need.
771fn bottom_panel<R>(
772    ui: &mut egui::Ui,
773    base: &str,
774    salt: &str,
775    add_contents: impl FnOnce(&mut egui::Ui) -> R,
776) -> R {
777    egui::containers::panel::Panel::bottom(egui::Id::new((base, salt)))
778        .frame(egui::Frame::NONE)
779        .show_separator_line(false)
780        .show(ui, add_contents)
781        .inner
782}
783
784/// Case-insensitive name order — the tie-breaker for every column sort.
785fn cmp_name(a: &BrowserEntry, b: &BrowserEntry) -> Ordering {
786    a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase())
787}
788
789/// Order two optional timestamps, sorting `None` (unknown, e.g. web) last.
790fn cmp_opt(a: Option<f64>, b: Option<f64>) -> Ordering {
791    match (a, b) {
792        (Some(x), Some(y)) => x.partial_cmp(&y).unwrap_or(Ordering::Equal),
793        (Some(_), None) => Ordering::Less,
794        (None, Some(_)) => Ordering::Greater,
795        (None, None) => Ordering::Equal,
796    }
797}
798
799/// The Type column text for an entry (Folder / BREP model / extension / File).
800fn entry_kind(entry: &BrowserEntry) -> String {
801    if entry.is_dir {
802        return "Folder".into();
803    }
804    let lower = entry.name.to_ascii_lowercase();
805    if lower.ends_with(".brep.json") {
806        return "BREP model".into();
807    }
808    match entry.name.rsplit_once('.') {
809        Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() => ext.to_uppercase(),
810        _ => "File".into(),
811    }
812}
813
814/// Stable slug for a sort column's hit key.
815fn sort_slug(key: SortKey) -> &'static str {
816    match key {
817        SortKey::Name => "name",
818        SortKey::Kind => "type",
819        SortKey::Size => "size",
820        SortKey::Date => "date",
821    }
822}
823
824/// Human-readable byte size, or an em-dash when the backend cannot report it.
825fn fmt_size(size: Option<u64>) -> String {
826    match size {
827        None => "\u{2014}".into(),
828        Some(bytes) => {
829            let b = bytes as f64;
830            if bytes < 1024 {
831                format!("{bytes} B")
832            } else if b < 1024.0 * 1024.0 {
833                format!("{:.1} KB", b / 1024.0)
834            } else if b < 1024.0 * 1024.0 * 1024.0 {
835                format!("{:.1} MB", b / (1024.0 * 1024.0))
836            } else {
837                format!("{:.1} GB", b / (1024.0 * 1024.0 * 1024.0))
838            }
839        }
840    }
841}
842
843/// `YYYY-MM-DD HH:MM` (UTC) from whole Unix seconds, or an em-dash when unknown.
844fn fmt_date(modified: Option<f64>) -> String {
845    let Some(secs) = modified else {
846        return "\u{2014}".into();
847    };
848    let days = (secs / 86_400.0).floor() as i64;
849    let (y, m, d) = civil_from_days(days);
850    let sod = ((secs as i64) % 86_400 + 86_400) % 86_400;
851    format!("{y:04}-{m:02}-{d:02} {:02}:{:02}", sod / 3600, (sod % 3600) / 60)
852}
853
854/// Gregorian `(year, month, day)` from a day count since the Unix epoch
855/// (Howard Hinnant's `civil_from_days`) — pure integer arithmetic, so it is
856/// wasm-safe and never touches `SystemTime`.
857fn civil_from_days(z: i64) -> (i64, u32, u32) {
858    let z = z + 719_468;
859    let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
860    let doe = z - era * 146_097;
861    let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
862    let y = yoe + era * 400;
863    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
864    let mp = (5 * doy + 2) / 153;
865    let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
866    let m = (if mp < 10 { mp + 3 } else { mp - 9 }) as u32;
867    (if m <= 2 { y + 1 } else { y }, m, d)
868}
869
870/// Split a POSIX-ish location into `(label, navigable-path)` breadcrumb segments,
871/// starting at the root. e.g. `/home/user/models` →
872/// `[("/", "/"), ("home", "/home"), ("user", "/home/user"), ("models", "/home/user/models")]`.
873fn breadcrumb_segments(location: &str) -> Vec<(String, String)> {
874    let mut out = vec![("/".to_string(), "/".to_string())];
875    let mut acc = String::new();
876    for comp in location.split('/').filter(|s| !s.is_empty()) {
877        acc.push('/');
878        acc.push_str(comp);
879        out.push((comp.to_string(), acc.clone()));
880    }
881    out
882}
883
884/// The built-in glyph a sidebar place draws for its category.
885fn place_glyph(kind: PlaceKind) -> &'static str {
886    match kind {
887        PlaceKind::Home => "\u{2302}",       // ⌂
888        PlaceKind::Documents => "\u{1F5CE}", // 🗎
889        PlaceKind::Downloads => "\u{2B07}",  // ⬇
890        PlaceKind::Models => "\u{1F5C0}",    // 🗀
891        PlaceKind::Root => "/",
892    }
893}
894
895// BREP private tests: 12bd276cf8fa12e1