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