Skip to main content

dear_file_browser/
dialog_core.rs

1use std::path::{Path, PathBuf};
2use std::{
3    collections::{VecDeque, hash_map::DefaultHasher},
4    hash::Hasher,
5};
6
7use crate::core::{
8    ClickAction, DialogMode, ExtensionPolicy, FileDialogError, FileFilter, SavePolicy, Selection,
9    SortBy, SortMode,
10};
11use crate::fs::{FileSystem, FsEntry};
12use crate::places::Places;
13use indexmap::IndexSet;
14use regex::RegexBuilder;
15
16#[cfg(feature = "tracing")]
17use tracing::trace;
18
19/// Keyboard/mouse modifier keys used by selection/navigation logic.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub struct Modifiers {
22    /// Ctrl key held.
23    pub ctrl: bool,
24    /// Shift key held.
25    pub shift: bool,
26}
27
28/// Input event for driving the dialog core without direct UI coupling.
29#[derive(Clone, Debug)]
30pub enum CoreEvent {
31    /// Ctrl+A style select all visible entries.
32    SelectAll,
33    /// Move focus by row/column delta.
34    MoveFocus {
35        /// Signed movement in current view order.
36        delta: i32,
37        /// Modifier keys used by selection semantics.
38        modifiers: Modifiers,
39    },
40    /// Click an entry row/cell.
41    ClickEntry {
42        /// Entry identity in current view.
43        id: EntryId,
44        /// Modifier keys used by selection semantics.
45        modifiers: Modifiers,
46    },
47    /// Double-click an entry row/cell.
48    DoubleClickEntry {
49        /// Entry identity in current view.
50        id: EntryId,
51    },
52    /// Type-to-select prefix.
53    SelectByPrefix(String),
54    /// Activate focused entry (Enter).
55    ActivateFocused,
56    /// Navigate to parent directory.
57    NavigateUp,
58    /// Navigate to a target directory.
59    NavigateTo(PathBuf),
60    /// Navigate backward in directory history.
61    NavigateBack,
62    /// Navigate forward in directory history.
63    NavigateForward,
64    /// Refresh the current directory view (forces a rescan on next tick).
65    Refresh,
66    /// Focus and select one entry by id.
67    FocusAndSelectById(EntryId),
68    /// Replace current selection by entry ids.
69    ReplaceSelectionByIds(Vec<EntryId>),
70    /// Clear current selection/focus/anchor.
71    ClearSelection,
72}
73
74/// Side effect emitted after applying a [`CoreEvent`].
75#[derive(Clone, Copy, Debug, PartialEq, Eq)]
76pub enum CoreEventOutcome {
77    /// No extra action required by host/UI.
78    None,
79    /// Confirmation should be attempted by host/UI.
80    RequestConfirm,
81}
82
83/// Per-frame gate for whether the dialog is allowed to confirm.
84///
85/// This is primarily used by IGFD-style custom panes to disable confirmation
86/// and provide user feedback when extra validation fails.
87#[derive(Clone, Debug)]
88pub struct ConfirmGate {
89    /// Whether confirmation is allowed.
90    pub can_confirm: bool,
91    /// Optional user-facing message shown when confirmation is blocked.
92    pub message: Option<String>,
93}
94
95impl Default for ConfirmGate {
96    fn default() -> Self {
97        Self {
98            can_confirm: true,
99            message: None,
100        }
101    }
102}
103
104/// Stable identifier for a directory entry within dialog snapshots.
105#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
106pub struct EntryId(u64);
107
108impl EntryId {
109    fn new(value: u64) -> Self {
110        Self(value)
111    }
112
113    /// Build a stable entry id from an absolute or relative path.
114    pub fn from_path(path: &Path) -> Self {
115        let mut hasher = DefaultHasher::new();
116        hasher.write(path.to_string_lossy().as_bytes());
117        Self::new(hasher.finish())
118    }
119}
120
121/// Decision returned by a scan hook for one filesystem entry.
122#[derive(Clone, Copy, Debug, PartialEq, Eq)]
123pub enum ScanHookAction {
124    /// Keep the entry in the directory snapshot.
125    Keep,
126    /// Drop the entry before filter/sort/view processing.
127    Drop,
128}
129
130/// Directory scan strategy.
131#[derive(Clone, Copy, Debug, PartialEq, Eq)]
132pub enum ScanPolicy {
133    /// Run scan synchronously on the caller thread.
134    Sync,
135    /// Consume scan output incrementally in bounded entry batches.
136    Incremental {
137        /// Max number of entries per batch.
138        batch_entries: usize,
139        /// Max batches to apply in one UI tick.
140        max_batches_per_tick: usize,
141    },
142}
143
144impl Default for ScanPolicy {
145    fn default() -> Self {
146        Self::Sync
147    }
148}
149
150impl ScanPolicy {
151    /// Recommended batch size for incremental scan.
152    pub const TUNED_BATCH_ENTRIES: usize = 512;
153    /// Recommended apply budget to balance throughput and frame pacing.
154    pub const TUNED_MAX_BATCHES_PER_TICK: usize = 2;
155
156    /// Returns a tuned incremental policy for large directories.
157    pub const fn tuned_incremental() -> Self {
158        Self::Incremental {
159            batch_entries: Self::TUNED_BATCH_ENTRIES,
160            max_batches_per_tick: Self::TUNED_MAX_BATCHES_PER_TICK,
161        }
162    }
163
164    fn normalized(self) -> Self {
165        match self {
166            Self::Sync => Self::Sync,
167            Self::Incremental {
168                batch_entries,
169                max_batches_per_tick,
170            } => Self::Incremental {
171                batch_entries: batch_entries.max(1),
172                max_batches_per_tick: max_batches_per_tick.max(1),
173            },
174        }
175    }
176
177    fn max_batches_per_tick(self) -> usize {
178        match self {
179            Self::Sync => usize::MAX,
180            Self::Incremental {
181                max_batches_per_tick,
182                ..
183            } => max_batches_per_tick,
184        }
185    }
186}
187
188/// Immutable scan request descriptor.
189#[derive(Clone, Debug)]
190pub struct ScanRequest {
191    /// Monotonic scan generation.
192    pub generation: u64,
193    /// Directory being scanned.
194    pub cwd: PathBuf,
195    /// Scan policy at submission time.
196    pub scan_policy: ScanPolicy,
197    /// Submission timestamp.
198    pub submitted_at: std::time::Instant,
199}
200
201/// One batch emitted by the scan pipeline.
202#[derive(Clone, Debug, PartialEq, Eq)]
203pub struct ScanBatch {
204    /// Batch generation.
205    pub generation: u64,
206    /// Batch payload kind.
207    pub kind: ScanBatchKind,
208    /// Whether this batch completes the generation.
209    pub is_final: bool,
210}
211
212impl ScanBatch {
213    fn begin(generation: u64) -> Self {
214        Self {
215            generation,
216            kind: ScanBatchKind::Begin,
217            is_final: false,
218        }
219    }
220
221    fn entries(generation: u64, loaded: usize, is_final: bool) -> Self {
222        Self {
223            generation,
224            kind: ScanBatchKind::Entries { loaded },
225            is_final,
226        }
227    }
228
229    fn complete(generation: u64, loaded: usize) -> Self {
230        Self {
231            generation,
232            kind: ScanBatchKind::Complete { loaded },
233            is_final: true,
234        }
235    }
236
237    fn error(generation: u64, message: String) -> Self {
238        Self {
239            generation,
240            kind: ScanBatchKind::Error { message },
241            is_final: true,
242        }
243    }
244}
245
246/// Payload kind for [`ScanBatch`].
247#[derive(Clone, Debug, PartialEq, Eq)]
248pub enum ScanBatchKind {
249    /// Scan generation started.
250    Begin,
251    /// A payload with currently loaded entry count.
252    Entries {
253        /// Number of entries currently loaded.
254        loaded: usize,
255    },
256    /// Scan generation completed.
257    Complete {
258        /// Final loaded entry count.
259        loaded: usize,
260    },
261    /// Scan generation failed.
262    Error {
263        /// Human-readable error message.
264        message: String,
265    },
266}
267
268/// Current scan status for the dialog core.
269#[derive(Clone, Debug, PartialEq, Eq)]
270pub enum ScanStatus {
271    /// No scan is currently running.
272    Idle,
273    /// A scan generation is running.
274    Scanning {
275        /// Active generation id.
276        generation: u64,
277    },
278    /// A scan generation has partial data.
279    Partial {
280        /// Active generation id.
281        generation: u64,
282        /// Number of currently loaded entries.
283        loaded: usize,
284    },
285    /// A scan generation finished successfully.
286    Complete {
287        /// Completed generation id.
288        generation: u64,
289        /// Final number of loaded entries.
290        loaded: usize,
291    },
292    /// A scan generation failed.
293    Failed {
294        /// Failed generation id.
295        generation: u64,
296        /// Error message captured from filesystem backend.
297        message: String,
298    },
299}
300
301impl Default for ScanStatus {
302    fn default() -> Self {
303        Self::Idle
304    }
305}
306
307type ScanHookFn = dyn FnMut(&mut FsEntry) -> ScanHookAction + 'static;
308
309struct ScanHook {
310    inner: Box<ScanHookFn>,
311}
312
313impl std::fmt::Debug for ScanHook {
314    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
315        f.debug_struct("ScanHook").finish_non_exhaustive()
316    }
317}
318
319impl ScanHook {
320    fn new<F>(hook: F) -> Self
321    where
322        F: FnMut(&mut FsEntry) -> ScanHookAction + 'static,
323    {
324        Self {
325            inner: Box::new(hook),
326        }
327    }
328
329    fn apply(&mut self, entry: &mut FsEntry) -> ScanHookAction {
330        (self.inner)(entry)
331    }
332}
333
334/// Rich metadata attached to a filesystem entry.
335#[derive(Clone, Debug)]
336pub struct FileMeta {
337    /// Whether this entry is a directory.
338    pub is_dir: bool,
339    /// Whether this entry is a symbolic link.
340    pub is_symlink: bool,
341    /// File size in bytes (files only).
342    pub size: Option<u64>,
343    /// Last modified timestamp.
344    pub modified: Option<std::time::SystemTime>,
345}
346
347/// Snapshot of one directory listing refresh.
348#[derive(Clone, Debug)]
349pub struct DirSnapshot {
350    /// Directory path used to build this snapshot.
351    pub cwd: PathBuf,
352    /// Number of captured entries in this snapshot.
353    pub entry_count: usize,
354
355    pub(crate) entries: Vec<DirEntry>,
356}
357
358/// A single directory entry in the current directory view.
359#[derive(Clone, Debug)]
360pub(crate) struct DirEntry {
361    /// Stable entry id.
362    pub(crate) id: EntryId,
363    /// Base name (no parent path).
364    pub(crate) name: String,
365    /// Full path.
366    pub(crate) path: PathBuf,
367    /// Whether this entry is a directory.
368    pub(crate) is_dir: bool,
369    /// Whether this entry itself is a symbolic link.
370    pub(crate) is_symlink: bool,
371    /// File size in bytes (files only).
372    pub(crate) size: Option<u64>,
373    /// Last modified timestamp.
374    pub(crate) modified: Option<std::time::SystemTime>,
375}
376
377impl DirEntry {
378    /// A display label used by the default UI (dirs are bracketed).
379    pub(crate) fn display_name(&self) -> String {
380        if self.is_dir {
381            format!("[{}]", self.name)
382        } else {
383            self.name.clone()
384        }
385    }
386
387    fn stable_id(&self) -> EntryId {
388        self.id
389    }
390}
391
392#[derive(Debug)]
393enum ScanRuntime {
394    Sync(SyncScanRuntime),
395    Worker(WorkerScanRuntime),
396}
397
398impl Default for ScanRuntime {
399    fn default() -> Self {
400        Self::Sync(SyncScanRuntime::default())
401    }
402}
403
404impl ScanRuntime {
405    fn from_policy(policy: ScanPolicy) -> Self {
406        match policy {
407            ScanPolicy::Sync => Self::Sync(SyncScanRuntime::default()),
408            ScanPolicy::Incremental { batch_entries, .. } => {
409                Self::Worker(WorkerScanRuntime::new(batch_entries))
410            }
411        }
412    }
413
414    fn set_policy(&mut self, policy: ScanPolicy) {
415        match policy {
416            ScanPolicy::Sync => {
417                if !matches!(self, Self::Sync(_)) {
418                    *self = Self::Sync(SyncScanRuntime::default());
419                }
420            }
421            ScanPolicy::Incremental { batch_entries, .. } => {
422                if let Self::Worker(runtime) = self {
423                    runtime.set_batch_entries(batch_entries);
424                } else {
425                    *self = Self::Worker(WorkerScanRuntime::new(batch_entries));
426                }
427            }
428        }
429    }
430
431    fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
432        match self {
433            Self::Sync(runtime) => runtime.submit(request, result),
434            Self::Worker(runtime) => runtime.submit(request, result),
435        }
436    }
437
438    fn poll_batch(&mut self) -> Option<RuntimeBatch> {
439        match self {
440            Self::Sync(runtime) => runtime.poll_batch(),
441            Self::Worker(runtime) => runtime.poll_batch(),
442        }
443    }
444
445    fn cancel_generation(&mut self, generation: u64) {
446        match self {
447            Self::Sync(runtime) => runtime.cancel_generation(generation),
448            Self::Worker(runtime) => runtime.cancel_generation(generation),
449        }
450    }
451}
452
453#[derive(Debug, Default)]
454struct SyncScanRuntime {
455    batches: VecDeque<RuntimeBatch>,
456}
457
458impl SyncScanRuntime {
459    fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
460        self.batches.clear();
461        self.batches.push_back(RuntimeBatch {
462            generation: request.generation,
463            kind: RuntimeBatchKind::Begin {
464                cwd: request.cwd.clone(),
465            },
466        });
467
468        match result {
469            Ok(snapshot) => {
470                let loaded = snapshot.entry_count;
471                self.batches.push_back(RuntimeBatch {
472                    generation: request.generation,
473                    kind: RuntimeBatchKind::ReplaceSnapshot { snapshot },
474                });
475                self.batches.push_back(RuntimeBatch {
476                    generation: request.generation,
477                    kind: RuntimeBatchKind::Complete { loaded },
478                });
479            }
480            Err(err) => {
481                self.batches.push_back(RuntimeBatch {
482                    generation: request.generation,
483                    kind: RuntimeBatchKind::Error {
484                        cwd: request.cwd,
485                        message: err.to_string(),
486                    },
487                });
488            }
489        }
490    }
491
492    fn poll_batch(&mut self) -> Option<RuntimeBatch> {
493        self.batches.pop_front()
494    }
495
496    fn cancel_generation(&mut self, generation: u64) {
497        self.batches.retain(|batch| batch.generation != generation);
498    }
499}
500
501#[derive(Debug)]
502struct WorkerScanRuntime {
503    batches: VecDeque<RuntimeBatch>,
504    batch_entries: usize,
505}
506
507impl WorkerScanRuntime {
508    fn new(batch_entries: usize) -> Self {
509        Self {
510            batches: VecDeque::new(),
511            batch_entries: batch_entries.max(1),
512        }
513    }
514
515    fn set_batch_entries(&mut self, batch_entries: usize) {
516        self.batch_entries = batch_entries.max(1);
517    }
518
519    fn submit(&mut self, request: ScanRequest, result: std::io::Result<DirSnapshot>) {
520        self.batches.clear();
521        self.batches.push_back(RuntimeBatch {
522            generation: request.generation,
523            kind: RuntimeBatchKind::Begin {
524                cwd: request.cwd.clone(),
525            },
526        });
527
528        match result {
529            Ok(snapshot) => {
530                let DirSnapshot { cwd, entries, .. } = snapshot;
531                let total = entries.len();
532                let mut loaded = 0usize;
533                let mut chunk = Vec::with_capacity(self.batch_entries);
534                for entry in entries {
535                    chunk.push(entry);
536                    if chunk.len() >= self.batch_entries {
537                        loaded += chunk.len();
538                        self.batches.push_back(RuntimeBatch {
539                            generation: request.generation,
540                            kind: RuntimeBatchKind::AppendEntries {
541                                cwd: cwd.clone(),
542                                entries: std::mem::take(&mut chunk),
543                                loaded,
544                            },
545                        });
546                    }
547                }
548
549                if !chunk.is_empty() {
550                    loaded += chunk.len();
551                    self.batches.push_back(RuntimeBatch {
552                        generation: request.generation,
553                        kind: RuntimeBatchKind::AppendEntries {
554                            cwd: cwd.clone(),
555                            entries: chunk,
556                            loaded,
557                        },
558                    });
559                }
560
561                self.batches.push_back(RuntimeBatch {
562                    generation: request.generation,
563                    kind: RuntimeBatchKind::Complete { loaded: total },
564                });
565            }
566            Err(err) => {
567                self.batches.push_back(RuntimeBatch {
568                    generation: request.generation,
569                    kind: RuntimeBatchKind::Error {
570                        cwd: request.cwd,
571                        message: err.to_string(),
572                    },
573                });
574            }
575        }
576    }
577
578    fn poll_batch(&mut self) -> Option<RuntimeBatch> {
579        self.batches.pop_front()
580    }
581
582    fn cancel_generation(&mut self, generation: u64) {
583        self.batches.retain(|batch| batch.generation != generation);
584    }
585}
586
587#[derive(Debug)]
588struct RuntimeBatch {
589    generation: u64,
590    kind: RuntimeBatchKind,
591}
592
593#[derive(Debug)]
594enum RuntimeBatchKind {
595    Begin {
596        cwd: PathBuf,
597    },
598    ReplaceSnapshot {
599        snapshot: DirSnapshot,
600    },
601    AppendEntries {
602        cwd: PathBuf,
603        entries: Vec<DirEntry>,
604        loaded: usize,
605    },
606    Complete {
607        loaded: usize,
608    },
609    Error {
610        cwd: PathBuf,
611        message: String,
612    },
613}
614
615/// Core state machine for the ImGui-embedded file dialog.
616///
617/// This type contains only domain state and logic (selection, navigation,
618/// filtering, sorting). It does not depend on Dear ImGui types and can be unit
619/// tested by driving its methods.
620#[derive(Debug)]
621pub struct FileDialogCore {
622    /// Mode.
623    pub mode: DialogMode,
624    /// Current working directory.
625    pub cwd: PathBuf,
626    selected_ids: IndexSet<EntryId>,
627    /// Optional filename input for SaveFile.
628    pub save_name: String,
629    /// Filters (lower-case extensions).
630    filters: Vec<FileFilter>,
631    /// Active filter index (None = All).
632    active_filter: Option<usize>,
633    filter_selection_mode: FilterSelectionMode,
634    /// Click behavior for directories: select or navigate.
635    pub click_action: ClickAction,
636    /// Search query to filter entries by substring (case-insensitive).
637    pub search: String,
638    /// Current sort column.
639    pub sort_by: SortBy,
640    /// Sort order flag (true = ascending).
641    pub sort_ascending: bool,
642    /// String comparison mode used for sorting.
643    pub sort_mode: SortMode,
644    /// Put directories before files when sorting.
645    pub dirs_first: bool,
646    /// Allow selecting multiple files.
647    pub allow_multi: bool,
648    /// Optional cap for maximum number of selected files (OpenFiles mode).
649    ///
650    /// - `None` => no limit
651    /// - `Some(1)` => single selection
652    pub max_selection: Option<usize>,
653    /// Show dotfiles (simple heuristic).
654    pub show_hidden: bool,
655    /// Double-click navigates/confirm (directories/files).
656    pub double_click: bool,
657    /// Places shown in the left pane (System + Bookmarks + custom groups).
658    pub places: Places,
659    /// Save behavior knobs (SaveFile mode only).
660    pub save_policy: SavePolicy,
661
662    result: Option<Result<Selection, FileDialogError>>,
663    pending_overwrite: Option<Selection>,
664    focused_id: Option<EntryId>,
665    selection_anchor_id: Option<EntryId>,
666    view_names: Vec<String>,
667    view_ids: Vec<EntryId>,
668    entries: Vec<DirEntry>,
669
670    scan_hook: Option<ScanHook>,
671    scan_policy: ScanPolicy,
672    scan_status: ScanStatus,
673    scan_generation: u64,
674    scan_started_at: Option<std::time::Instant>,
675    scan_runtime: ScanRuntime,
676    dir_snapshot: DirSnapshot,
677    dir_snapshot_dirty: bool,
678    last_view_key: Option<ViewKey>,
679
680    nav_back: VecDeque<PathBuf>,
681    nav_forward: VecDeque<PathBuf>,
682    nav_recent: VecDeque<PathBuf>,
683}
684
685#[derive(Clone, Copy, Debug, PartialEq, Eq)]
686enum FilterSelectionMode {
687    /// If filters exist, default to the first filter unless the user explicitly selects a filter.
688    AutoFirst,
689    /// Respect the explicitly selected filter (`None` = All files).
690    Manual,
691}
692
693impl FileDialogCore {
694    /// Creates a new dialog core for a mode.
695    pub fn new(mode: DialogMode) -> Self {
696        let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
697        let nav_recent = VecDeque::from([cwd.clone()]);
698        Self {
699            mode,
700            cwd,
701            selected_ids: IndexSet::new(),
702            save_name: String::new(),
703            filters: Vec::new(),
704            active_filter: None,
705            filter_selection_mode: FilterSelectionMode::AutoFirst,
706            click_action: ClickAction::Select,
707            search: String::new(),
708            sort_by: SortBy::Name,
709            sort_ascending: true,
710            sort_mode: SortMode::default(),
711            dirs_first: true,
712            allow_multi: matches!(mode, DialogMode::OpenFiles),
713            max_selection: None,
714            show_hidden: false,
715            double_click: true,
716            places: Places::default(),
717            save_policy: SavePolicy::default(),
718            result: None,
719            pending_overwrite: None,
720            focused_id: None,
721            selection_anchor_id: None,
722            view_names: Vec::new(),
723            view_ids: Vec::new(),
724            entries: Vec::new(),
725            scan_hook: None,
726            scan_policy: ScanPolicy::default(),
727            scan_status: ScanStatus::default(),
728            scan_generation: 0,
729            scan_started_at: None,
730            scan_runtime: ScanRuntime::from_policy(ScanPolicy::default()),
731            dir_snapshot: DirSnapshot {
732                cwd: PathBuf::new(),
733                entry_count: 0,
734                entries: Vec::new(),
735            },
736            dir_snapshot_dirty: true,
737            last_view_key: None,
738            nav_back: VecDeque::new(),
739            nav_forward: VecDeque::new(),
740            nav_recent,
741        }
742    }
743
744    /// Returns whether a "back" navigation is currently possible.
745    pub fn can_navigate_back(&self) -> bool {
746        !self.nav_back.is_empty()
747    }
748
749    /// Returns whether a "forward" navigation is currently possible.
750    pub fn can_navigate_forward(&self) -> bool {
751        !self.nav_forward.is_empty()
752    }
753
754    /// Returns recently visited directories (most recent first).
755    pub fn recent_paths(&self) -> impl Iterator<Item = &PathBuf> {
756        self.nav_recent.iter()
757    }
758
759    fn push_nav_back(&mut self, cwd: PathBuf) {
760        const NAV_HISTORY_MAX: usize = 64;
761
762        if self.nav_back.back() == Some(&cwd) {
763            return;
764        }
765
766        self.nav_back.push_back(cwd);
767        while self.nav_back.len() > NAV_HISTORY_MAX {
768            let _ = self.nav_back.pop_front();
769        }
770    }
771
772    fn push_nav_forward(&mut self, cwd: PathBuf) {
773        const NAV_HISTORY_MAX: usize = 64;
774
775        if self.nav_forward.back() == Some(&cwd) {
776            return;
777        }
778
779        self.nav_forward.push_back(cwd);
780        while self.nav_forward.len() > NAV_HISTORY_MAX {
781            let _ = self.nav_forward.pop_front();
782        }
783    }
784
785    fn navigate_to_with_history(&mut self, target: PathBuf) {
786        if target == self.cwd {
787            return;
788        }
789
790        let old = self.cwd.clone();
791        self.push_nav_back(old);
792        self.nav_forward.clear();
793        self.set_cwd(target);
794        self.record_recent_cwd();
795    }
796
797    fn navigate_back(&mut self) {
798        let Some(prev) = self.nav_back.pop_back() else {
799            return;
800        };
801        if prev == self.cwd {
802            return;
803        }
804        let old = self.cwd.clone();
805        self.push_nav_forward(old);
806        self.set_cwd(prev);
807        self.record_recent_cwd();
808    }
809
810    fn navigate_forward(&mut self) {
811        let Some(next) = self.nav_forward.pop_back() else {
812            return;
813        };
814        if next == self.cwd {
815            return;
816        }
817        let old = self.cwd.clone();
818        self.push_nav_back(old);
819        self.set_cwd(next);
820        self.record_recent_cwd();
821    }
822
823    fn record_recent_cwd(&mut self) {
824        const NAV_RECENT_MAX: usize = 24;
825
826        let cwd = self.cwd.clone();
827        if self.nav_recent.front() == Some(&cwd) {
828            return;
829        }
830
831        self.nav_recent.retain(|p| p != &cwd);
832        self.nav_recent.push_front(cwd);
833        while self.nav_recent.len() > NAV_RECENT_MAX {
834            let _ = self.nav_recent.pop_back();
835        }
836    }
837
838    /// Returns all configured filters.
839    pub fn filters(&self) -> &[FileFilter] {
840        &self.filters
841    }
842
843    /// Replaces the current filter list and resets selection to "auto first filter".
844    ///
845    /// Use [`Self::set_active_filter_all`] to explicitly select "All files" afterwards.
846    pub fn set_filters(&mut self, filters: Vec<FileFilter>) {
847        self.filters = filters;
848        self.filter_selection_mode = FilterSelectionMode::AutoFirst;
849        self.normalize_active_filter();
850        self.last_view_key = None;
851    }
852
853    /// Adds a filter to the end of the filter list.
854    pub fn add_filter(&mut self, filter: impl Into<FileFilter>) {
855        self.filters.push(filter.into());
856        self.normalize_active_filter();
857        self.last_view_key = None;
858    }
859
860    /// Adds multiple filters to the end of the filter list.
861    pub fn extend_filters<I, F>(&mut self, filters: I)
862    where
863        I: IntoIterator<Item = F>,
864        F: Into<FileFilter>,
865    {
866        self.filters.extend(filters.into_iter().map(Into::into));
867        self.normalize_active_filter();
868        self.last_view_key = None;
869    }
870
871    /// Clears all filters and selects "All files".
872    pub fn clear_filters(&mut self) {
873        self.filters.clear();
874        self.active_filter = None;
875        self.filter_selection_mode = FilterSelectionMode::Manual;
876        self.last_view_key = None;
877    }
878
879    /// Returns the active filter index (into [`Self::filters`]) if any.
880    pub fn active_filter_index(&self) -> Option<usize> {
881        self.active_filter
882    }
883
884    /// Returns the active filter (if any).
885    pub fn active_filter(&self) -> Option<&FileFilter> {
886        self.active_filter.and_then(|i| self.filters.get(i))
887    }
888
889    /// Explicitly select "All files" as the active filter.
890    pub fn set_active_filter_all(&mut self) {
891        self.active_filter = None;
892        self.filter_selection_mode = FilterSelectionMode::Manual;
893        self.last_view_key = None;
894    }
895
896    /// Explicitly select a filter by index.
897    ///
898    /// Returns `false` if the index is out of bounds.
899    pub fn set_active_filter_index(&mut self, index: usize) -> bool {
900        if index >= self.filters.len() {
901            return false;
902        }
903        self.active_filter = Some(index);
904        self.filter_selection_mode = FilterSelectionMode::Manual;
905        self.last_view_key = None;
906        true
907    }
908
909    /// Returns a snapshot of the current entries list.
910    pub(crate) fn entries(&self) -> &[DirEntry] {
911        &self.entries
912    }
913
914    /// Apply one core event and return host-facing outcome.
915    pub fn handle_event(&mut self, event: CoreEvent) -> CoreEventOutcome {
916        match event {
917            CoreEvent::SelectAll => {
918                self.select_all();
919                CoreEventOutcome::None
920            }
921            CoreEvent::MoveFocus { delta, modifiers } => {
922                self.move_focus(delta, modifiers);
923                CoreEventOutcome::None
924            }
925            CoreEvent::ClickEntry { id, modifiers } => {
926                self.click_entry(id, modifiers);
927                CoreEventOutcome::None
928            }
929            CoreEvent::DoubleClickEntry { id } => {
930                if self.double_click_entry(id) {
931                    CoreEventOutcome::RequestConfirm
932                } else {
933                    CoreEventOutcome::None
934                }
935            }
936            CoreEvent::SelectByPrefix(prefix) => {
937                self.select_by_prefix(&prefix);
938                CoreEventOutcome::None
939            }
940            CoreEvent::ActivateFocused => {
941                if self.activate_focused() {
942                    CoreEventOutcome::RequestConfirm
943                } else {
944                    CoreEventOutcome::None
945                }
946            }
947            CoreEvent::NavigateUp => {
948                self.navigate_up();
949                CoreEventOutcome::None
950            }
951            CoreEvent::NavigateTo(path) => {
952                self.navigate_to(path);
953                CoreEventOutcome::None
954            }
955            CoreEvent::NavigateBack => {
956                self.navigate_back();
957                CoreEventOutcome::None
958            }
959            CoreEvent::NavigateForward => {
960                self.navigate_forward();
961                CoreEventOutcome::None
962            }
963            CoreEvent::Refresh => {
964                self.invalidate_dir_cache();
965                CoreEventOutcome::None
966            }
967            CoreEvent::FocusAndSelectById(id) => {
968                self.focus_and_select_by_id(id);
969                CoreEventOutcome::None
970            }
971            CoreEvent::ReplaceSelectionByIds(ids) => {
972                self.replace_selection_by_ids(ids);
973                CoreEventOutcome::None
974            }
975            CoreEvent::ClearSelection => {
976                self.clear_selection();
977                CoreEventOutcome::None
978            }
979        }
980    }
981
982    /// Mark the current directory snapshot as dirty so it will be refreshed on next draw.
983    pub fn invalidate_dir_cache(&mut self) {
984        self.dir_snapshot_dirty = true;
985        self.last_view_key = None;
986    }
987
988    /// Returns the currently configured scan policy.
989    pub fn scan_policy(&self) -> ScanPolicy {
990        self.scan_policy
991    }
992
993    /// Sets scan policy for future directory refreshes.
994    ///
995    /// Values are normalized to avoid invalid batch sizes.
996    /// Calling this invalidates the directory cache when policy changes.
997    pub fn set_scan_policy(&mut self, policy: ScanPolicy) {
998        let normalized = policy.normalized();
999        if self.scan_policy == normalized {
1000            return;
1001        }
1002        self.scan_policy = normalized;
1003        self.scan_runtime.set_policy(normalized);
1004        self.invalidate_dir_cache();
1005    }
1006
1007    /// Returns the latest issued scan generation.
1008    pub fn scan_generation(&self) -> u64 {
1009        self.scan_generation
1010    }
1011
1012    /// Returns the current scan status.
1013    pub fn scan_status(&self) -> &ScanStatus {
1014        &self.scan_status
1015    }
1016
1017    /// Requests a rescan on the next refresh tick.
1018    pub fn request_rescan(&mut self) {
1019        self.invalidate_dir_cache();
1020    }
1021
1022    /// Installs a scan hook that can mutate or drop filesystem entries.
1023    ///
1024    /// The hook runs before filtering/sorting and before snapshot ids are built.
1025    /// Calling this invalidates the directory cache.
1026    pub fn set_scan_hook<F>(&mut self, hook: F)
1027    where
1028        F: FnMut(&mut FsEntry) -> ScanHookAction + 'static,
1029    {
1030        self.scan_hook = Some(ScanHook::new(hook));
1031        self.invalidate_dir_cache();
1032    }
1033
1034    /// Clears the scan hook and reverts to raw filesystem entries.
1035    ///
1036    /// Calling this invalidates the directory cache.
1037    pub fn clear_scan_hook(&mut self) {
1038        if self.scan_hook.is_none() {
1039            return;
1040        }
1041        self.scan_hook = None;
1042        self.invalidate_dir_cache();
1043    }
1044
1045    /// Returns the final result once the user confirms/cancels, and clears it.
1046    pub(crate) fn take_result(&mut self) -> Option<Result<Selection, FileDialogError>> {
1047        self.result.take()
1048    }
1049
1050    /// Sets the current directory and clears selection/focus.
1051    pub fn set_cwd(&mut self, cwd: PathBuf) {
1052        self.cwd = cwd;
1053        self.clear_selection();
1054    }
1055
1056    /// Selects and focuses a single entry by id.
1057    pub fn focus_and_select_by_id(&mut self, id: EntryId) {
1058        self.select_single_by_id(id);
1059    }
1060
1061    /// Replace selection by entry ids.
1062    pub fn replace_selection_by_ids<I>(&mut self, ids: I)
1063    where
1064        I: IntoIterator<Item = EntryId>,
1065    {
1066        self.selected_ids.clear();
1067        for id in ids {
1068            self.selected_ids.insert(id);
1069        }
1070        self.enforce_selection_cap();
1071        let last = self.selected_ids.iter().next_back().copied();
1072        self.focused_id = last;
1073        self.selection_anchor_id = last;
1074    }
1075
1076    /// Clear current selection, focus and anchor.
1077    pub fn clear_selection(&mut self) {
1078        self.selected_ids.clear();
1079        self.focused_id = None;
1080        self.selection_anchor_id = None;
1081    }
1082
1083    /// Returns the number of currently selected entries.
1084    pub fn selected_len(&self) -> usize {
1085        self.selected_ids.len()
1086    }
1087
1088    /// Returns whether there is at least one selected entry.
1089    pub fn has_selection(&self) -> bool {
1090        !self.selected_ids.is_empty()
1091    }
1092
1093    /// Returns selected entry ids in deterministic selection order.
1094    pub fn selected_entry_ids(&self) -> Vec<EntryId> {
1095        self.selected_ids.iter().copied().collect()
1096    }
1097
1098    /// Resolves selected entry paths from ids in the current snapshot.
1099    ///
1100    /// Any ids that are not currently resolvable are skipped.
1101    pub fn selected_entry_paths(&self) -> Vec<PathBuf> {
1102        self.selected_ids
1103            .iter()
1104            .filter_map(|id| self.entry_path_by_id(*id).map(Path::to_path_buf))
1105            .collect()
1106    }
1107
1108    /// Counts selected files and directories in the current snapshot.
1109    ///
1110    /// Returns `(files, dirs)`. Any ids that are not currently resolvable are skipped.
1111    pub fn selected_entry_counts(&self) -> (usize, usize) {
1112        self.selected_ids
1113            .iter()
1114            .filter_map(|id| self.entry_by_id(*id))
1115            .fold((0usize, 0usize), |(files, dirs), entry| {
1116                if entry.is_dir {
1117                    (files, dirs + 1)
1118                } else {
1119                    (files + 1, dirs)
1120                }
1121            })
1122    }
1123    /// Resolves an entry path from an entry id in the current snapshot.
1124    ///
1125    /// Returns `None` when the id is not currently resolvable (for example,
1126    /// before the next rescan after create/rename/paste).
1127    pub fn entry_path_by_id(&self, id: EntryId) -> Option<&Path> {
1128        self.entry_by_id(id).map(|entry| entry.path.as_path())
1129    }
1130
1131    /// Returns the currently focused entry id, if any.
1132    pub fn focused_entry_id(&self) -> Option<EntryId> {
1133        self.focused_id
1134    }
1135
1136    pub(crate) fn is_selected_id(&self, id: EntryId) -> bool {
1137        self.selected_ids.contains(&id)
1138    }
1139
1140    /// Refreshes the directory snapshot and view cache if needed.
1141    pub(crate) fn rescan_if_needed(&mut self, fs: &dyn FileSystem) {
1142        self.normalize_active_filter();
1143        self.refresh_dir_snapshot_if_needed(fs);
1144
1145        let key = ViewKey::new(self);
1146        if self.last_view_key.as_ref() == Some(&key) {
1147            return;
1148        }
1149
1150        let rebuild_reason = if self.last_view_key.is_none() {
1151            "snapshot_or_forced"
1152        } else {
1153            "view_inputs_changed"
1154        };
1155        let rebuild_started_at = std::time::Instant::now();
1156
1157        let mut entries = self.dir_snapshot.entries.clone();
1158        filter_entries_in_place(
1159            &mut entries,
1160            self.mode,
1161            self.show_hidden,
1162            &self.filters,
1163            self.active_filter,
1164            &self.search,
1165        );
1166        let type_dots_to_extract = igfd_type_dots_to_extract(self.active_filter());
1167        sort_entries_in_place(
1168            &mut entries,
1169            self.sort_by,
1170            self.sort_ascending,
1171            self.sort_mode,
1172            self.dirs_first,
1173            type_dots_to_extract,
1174        );
1175        self.view_names = entries.iter().map(|e| e.name.clone()).collect();
1176        self.view_ids = entries.iter().map(DirEntry::stable_id).collect();
1177        self.entries = entries;
1178        self.retain_selected_visible();
1179        self.last_view_key = Some(key);
1180
1181        trace_projector_rebuild(
1182            rebuild_reason,
1183            self.entries.len(),
1184            rebuild_started_at.elapsed().as_micros(),
1185        );
1186    }
1187
1188    fn normalize_active_filter(&mut self) {
1189        if self.filters.is_empty() {
1190            self.active_filter = None;
1191            return;
1192        }
1193        // Folder mode always shows only directories; filters are irrelevant there.
1194        if matches!(self.mode, DialogMode::PickFolder) {
1195            self.active_filter = None;
1196            return;
1197        }
1198
1199        match self.filter_selection_mode {
1200            FilterSelectionMode::AutoFirst => {
1201                let i = self.active_filter.unwrap_or(0);
1202                self.active_filter = Some(i.min(self.filters.len().saturating_sub(1)));
1203            }
1204            FilterSelectionMode::Manual => {
1205                if let Some(i) = self.active_filter {
1206                    if i >= self.filters.len() {
1207                        self.active_filter = Some(0);
1208                    }
1209                }
1210            }
1211        }
1212    }
1213
1214    fn refresh_dir_snapshot_if_needed(&mut self, fs: &dyn FileSystem) {
1215        let cwd_changed = self.dir_snapshot.cwd != self.cwd;
1216        let should_refresh = self.dir_snapshot_dirty || cwd_changed;
1217
1218        if should_refresh {
1219            let request = self.begin_scan_request();
1220            self.scan_runtime
1221                .cancel_generation(request.generation.saturating_sub(1));
1222            let scan_result =
1223                read_entries_snapshot_with_fs(fs, &request.cwd, self.scan_hook.as_mut());
1224            self.scan_runtime.submit(request, scan_result);
1225            self.dir_snapshot_dirty = false;
1226        }
1227
1228        let mut budget = self.scan_runtime_batch_budget();
1229        while budget > 0 {
1230            let Some(runtime_batch) = self.scan_runtime.poll_batch() else {
1231                break;
1232            };
1233            self.apply_runtime_batch(runtime_batch);
1234            budget = budget.saturating_sub(1);
1235        }
1236    }
1237
1238    fn scan_runtime_batch_budget(&self) -> usize {
1239        self.scan_policy.max_batches_per_tick()
1240    }
1241
1242    fn begin_scan_request(&mut self) -> ScanRequest {
1243        let generation = self.scan_generation.saturating_add(1);
1244        self.scan_generation = generation;
1245        let request = ScanRequest {
1246            generation,
1247            cwd: self.cwd.clone(),
1248            scan_policy: self.scan_policy,
1249            submitted_at: std::time::Instant::now(),
1250        };
1251        trace_scan_requested(&request);
1252        request
1253    }
1254
1255    fn apply_runtime_batch(&mut self, runtime_batch: RuntimeBatch) {
1256        if runtime_batch.generation != self.scan_generation {
1257            trace_scan_dropped_stale_batch(
1258                runtime_batch.generation,
1259                self.scan_generation,
1260                "runtime_batch",
1261            );
1262            return;
1263        }
1264
1265        match runtime_batch.kind {
1266            RuntimeBatchKind::Begin { cwd } => {
1267                self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
1268                self.last_view_key = None;
1269                self.apply_scan_batch(ScanBatch::begin(runtime_batch.generation));
1270                trace_scan_batch_applied(runtime_batch.generation, 0, "begin");
1271            }
1272            RuntimeBatchKind::ReplaceSnapshot { snapshot } => {
1273                let loaded = snapshot.entry_count;
1274                self.dir_snapshot = snapshot;
1275                self.last_view_key = None;
1276                self.apply_scan_batch(ScanBatch::entries(runtime_batch.generation, loaded, false));
1277                trace_scan_batch_applied(runtime_batch.generation, loaded, "replace_snapshot");
1278            }
1279            RuntimeBatchKind::AppendEntries {
1280                cwd,
1281                entries,
1282                loaded,
1283            } => {
1284                if self.dir_snapshot.cwd != cwd {
1285                    self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
1286                }
1287                let batch_entries = entries.len();
1288                self.dir_snapshot.entries.extend(entries);
1289                self.dir_snapshot.entry_count = self.dir_snapshot.entries.len();
1290                self.last_view_key = None;
1291                self.apply_scan_batch(ScanBatch::entries(runtime_batch.generation, loaded, false));
1292                trace_scan_batch_applied(runtime_batch.generation, batch_entries, "append_entries");
1293            }
1294            RuntimeBatchKind::Complete { loaded } => {
1295                self.last_view_key = None;
1296                self.apply_scan_batch(ScanBatch::complete(runtime_batch.generation, loaded));
1297                trace_scan_batch_applied(runtime_batch.generation, 0, "complete");
1298            }
1299            RuntimeBatchKind::Error { cwd, message } => {
1300                self.dir_snapshot = empty_snapshot_for_cwd(&cwd);
1301                self.last_view_key = None;
1302                self.apply_scan_batch(ScanBatch::error(runtime_batch.generation, message));
1303                trace_scan_batch_applied(runtime_batch.generation, 0, "error");
1304            }
1305        }
1306    }
1307
1308    fn apply_scan_batch(&mut self, batch: ScanBatch) {
1309        if batch.generation != self.scan_generation {
1310            trace_scan_dropped_stale_batch(batch.generation, self.scan_generation, "scan_batch");
1311            return;
1312        }
1313
1314        self.scan_status = match batch.kind {
1315            ScanBatchKind::Begin => {
1316                self.scan_started_at = Some(std::time::Instant::now());
1317                ScanStatus::Scanning {
1318                    generation: batch.generation,
1319                }
1320            }
1321            ScanBatchKind::Entries { loaded } => {
1322                if batch.is_final {
1323                    let duration_ms = self
1324                        .scan_started_at
1325                        .take()
1326                        .map(|started| started.elapsed().as_millis())
1327                        .unwrap_or(0);
1328                    trace_scan_completed(batch.generation, loaded, duration_ms);
1329                    ScanStatus::Complete {
1330                        generation: batch.generation,
1331                        loaded,
1332                    }
1333                } else {
1334                    ScanStatus::Partial {
1335                        generation: batch.generation,
1336                        loaded,
1337                    }
1338                }
1339            }
1340            ScanBatchKind::Complete { loaded } => {
1341                let duration_ms = self
1342                    .scan_started_at
1343                    .take()
1344                    .map(|started| started.elapsed().as_millis())
1345                    .unwrap_or(0);
1346                trace_scan_completed(batch.generation, loaded, duration_ms);
1347                ScanStatus::Complete {
1348                    generation: batch.generation,
1349                    loaded,
1350                }
1351            }
1352            ScanBatchKind::Error { message } => {
1353                self.scan_started_at = None;
1354                ScanStatus::Failed {
1355                    generation: batch.generation,
1356                    message,
1357                }
1358            }
1359        };
1360    }
1361
1362    fn entry_by_id(&self, id: EntryId) -> Option<&DirEntry> {
1363        self.entries.iter().find(|entry| entry.id == id)
1364    }
1365
1366    fn name_for_id(&self, id: EntryId) -> Option<&str> {
1367        self.entry_by_id(id)
1368            .map(|entry| entry.name.as_str())
1369            .or_else(|| {
1370                self.view_ids
1371                    .iter()
1372                    .position(|candidate| *candidate == id)
1373                    .and_then(|index| self.view_names.get(index).map(String::as_str))
1374            })
1375    }
1376
1377    /// Select the next entry whose base name starts with the given prefix (case-insensitive).
1378    ///
1379    /// This is used by "type-to-select" navigation (IGFD-style).
1380    pub(crate) fn select_by_prefix(&mut self, prefix: &str) {
1381        let prefix = prefix.trim();
1382        if prefix.is_empty() || self.view_ids.is_empty() {
1383            return;
1384        }
1385        let prefix_lower = prefix.to_lowercase();
1386
1387        let len = self.view_ids.len();
1388        let start_idx = self
1389            .focused_id
1390            .and_then(|focused| self.view_ids.iter().position(|id| *id == focused))
1391            .map(|i| (i + 1) % len)
1392            .unwrap_or(0);
1393
1394        for offset in 0..len {
1395            let index = (start_idx + offset) % len;
1396            let id = self.view_ids[index];
1397            if self
1398                .name_for_id(id)
1399                .map(|name| name.to_lowercase().starts_with(&prefix_lower))
1400                .unwrap_or(false)
1401            {
1402                self.select_single_by_id(id);
1403                break;
1404            }
1405        }
1406    }
1407
1408    /// Applies Ctrl+A style selection to all currently visible entries.
1409    pub(crate) fn select_all(&mut self) {
1410        let cap = self.selection_cap();
1411        if cap <= 1 {
1412            return;
1413        }
1414        self.selected_ids.clear();
1415        for id in self.view_ids.iter().take(cap).copied() {
1416            self.selected_ids.insert(id);
1417        }
1418        let last = self.selected_ids.iter().next_back().copied();
1419        self.focused_id = last;
1420        self.selection_anchor_id = last;
1421    }
1422
1423    /// Moves keyboard focus up/down within the current view.
1424    pub(crate) fn move_focus(&mut self, delta: i32, modifiers: Modifiers) {
1425        if self.view_ids.is_empty() {
1426            return;
1427        }
1428
1429        let len = self.view_ids.len();
1430        let current_idx = self
1431            .focused_id
1432            .and_then(|id| self.view_ids.iter().position(|candidate| *candidate == id));
1433        let next_idx = match current_idx {
1434            Some(index) => {
1435                let next = index as i32 + delta;
1436                next.clamp(0, (len - 1) as i32) as usize
1437            }
1438            None => {
1439                if delta >= 0 {
1440                    0
1441                } else {
1442                    len - 1
1443                }
1444            }
1445        };
1446
1447        let target_id = self.view_ids[next_idx];
1448        if modifiers.shift {
1449            let anchor_id = self
1450                .selection_anchor_id
1451                .or(self.focused_id)
1452                .unwrap_or(target_id);
1453            if self.selection_anchor_id.is_none() {
1454                self.selection_anchor_id = Some(anchor_id);
1455            }
1456            if let Some(range) = select_range_by_id_capped(
1457                &self.view_ids,
1458                anchor_id,
1459                target_id,
1460                self.selection_cap(),
1461            ) {
1462                self.selected_ids = range.into_iter().collect();
1463                self.focused_id = Some(target_id);
1464            } else {
1465                self.select_single_by_id(target_id);
1466            }
1467        } else {
1468            self.select_single_by_id(target_id);
1469        }
1470    }
1471
1472    /// Activates the focused entry (Enter).
1473    ///
1474    /// If no selection exists, the focused item becomes selected, then confirm is attempted.
1475    pub(crate) fn activate_focused(&mut self) -> bool {
1476        if self.selected_ids.is_empty() {
1477            if let Some(id) = self.focused_id {
1478                self.selected_ids.insert(id);
1479                self.selection_anchor_id = Some(id);
1480            }
1481        }
1482        !self.selected_ids.is_empty()
1483    }
1484
1485    /// Handles a click on an entry row.
1486    pub(crate) fn click_entry(&mut self, id: EntryId, modifiers: Modifiers) {
1487        let Some(entry) = self.entry_by_id(id).cloned() else {
1488            return;
1489        };
1490
1491        if entry.is_dir {
1492            match self.click_action {
1493                ClickAction::Select => {
1494                    self.select_single_by_id(id);
1495                }
1496                ClickAction::Navigate => {
1497                    let target = self.cwd.join(&entry.name);
1498                    self.navigate_to_with_history(target);
1499                }
1500            }
1501            return;
1502        }
1503
1504        if modifiers.shift {
1505            if let Some(anchor_id) = self.selection_anchor_id {
1506                if let Some(range) =
1507                    select_range_by_id_capped(&self.view_ids, anchor_id, id, self.selection_cap())
1508                {
1509                    self.selected_ids = range.into_iter().collect();
1510                    self.focused_id = Some(id);
1511                    return;
1512                }
1513            }
1514            self.select_single_by_id(id);
1515            return;
1516        }
1517
1518        if self.selection_cap() <= 1 || !modifiers.ctrl {
1519            self.select_single_by_id(id);
1520            return;
1521        }
1522
1523        toggle_select_id(&mut self.selected_ids, id);
1524        self.focused_id = Some(id);
1525        self.selection_anchor_id = Some(id);
1526        self.enforce_selection_cap();
1527    }
1528
1529    /// Handles a double-click on an entry row.
1530    pub(crate) fn double_click_entry(&mut self, id: EntryId) -> bool {
1531        if !self.double_click {
1532            return false;
1533        }
1534
1535        let Some(entry) = self.entry_by_id(id).cloned() else {
1536            return false;
1537        };
1538
1539        if entry.is_dir {
1540            let target = self.cwd.join(&entry.name);
1541            self.navigate_to_with_history(target);
1542            return false;
1543        }
1544
1545        if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles) {
1546            self.select_single_by_id(id);
1547            return true;
1548        }
1549        false
1550    }
1551
1552    /// Navigates one directory up.
1553    pub(crate) fn navigate_up(&mut self) {
1554        let mut target = self.cwd.clone();
1555        if !target.pop() {
1556            return;
1557        }
1558        self.navigate_to_with_history(target);
1559    }
1560
1561    /// Navigates to a directory.
1562    pub(crate) fn navigate_to(&mut self, p: PathBuf) {
1563        self.navigate_to_with_history(p);
1564    }
1565
1566    /// Confirms the dialog. On success, stores a result and signals the UI to close.
1567    pub(crate) fn confirm(
1568        &mut self,
1569        fs: &dyn FileSystem,
1570        gate: &ConfirmGate,
1571        typed_footer_name: Option<&str>,
1572    ) -> Result<(), FileDialogError> {
1573        self.normalize_active_filter();
1574        self.result = None;
1575        self.pending_overwrite = None;
1576        let selected_entries = self
1577            .selected_ids
1578            .iter()
1579            .filter_map(|id| self.entry_by_id(*id).cloned())
1580            .collect::<Vec<_>>();
1581
1582        // Special-case: if a single directory selected in file-open modes, navigate into it
1583        // instead of confirming.
1584        if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
1585            && selected_entries.len() == 1
1586            && selected_entries[0].is_dir
1587        {
1588            let target = self.cwd.join(&selected_entries[0].name);
1589            self.navigate_to_with_history(target);
1590            return Ok(());
1591        }
1592
1593        if !gate.can_confirm {
1594            let msg = gate
1595                .message
1596                .clone()
1597                .unwrap_or_else(|| "validation blocked".to_string());
1598            return Err(FileDialogError::ValidationBlocked(msg));
1599        }
1600
1601        // IGFD-like footer entry: allow opening a file by typing a name/path when no selection exists.
1602        if matches!(self.mode, DialogMode::OpenFile | DialogMode::OpenFiles)
1603            && selected_entries.is_empty()
1604        {
1605            if let Some(typed) = typed_footer_name.map(str::trim) {
1606                if !typed.is_empty() {
1607                    let raw = PathBuf::from(typed);
1608                    let raw = if raw.is_absolute() {
1609                        raw
1610                    } else {
1611                        self.cwd.join(&raw)
1612                    };
1613                    let p = fs.canonicalize(&raw).unwrap_or(raw.clone());
1614                    match fs.metadata(&p) {
1615                        Ok(md) => {
1616                            if md.is_dir {
1617                                self.navigate_to_with_history(p);
1618                                return Ok(());
1619                            }
1620                            self.result = Some(Ok(Selection { paths: vec![p] }));
1621                            return Ok(());
1622                        }
1623                        Err(e) => {
1624                            use std::io::ErrorKind::*;
1625                            let msg = match e.kind() {
1626                                NotFound => format!("No such file: {}", typed),
1627                                PermissionDenied => format!("Permission denied: {}", typed),
1628                                _ => format!("Invalid file '{}': {}", typed, e),
1629                            };
1630                            return Err(FileDialogError::InvalidPath(msg));
1631                        }
1632                    }
1633                }
1634            }
1635        }
1636
1637        let sel = finalize_selection(
1638            self.mode,
1639            &self.cwd,
1640            selected_entries,
1641            &self.save_name,
1642            &self.filters,
1643            self.active_filter,
1644            &self.save_policy,
1645        )?;
1646
1647        if matches!(self.mode, DialogMode::SaveFile) {
1648            let target = sel
1649                .paths
1650                .get(0)
1651                .cloned()
1652                .unwrap_or_else(|| self.cwd.clone());
1653            match fs.metadata(&target) {
1654                Ok(md) => {
1655                    if md.is_dir {
1656                        return Err(FileDialogError::InvalidPath(
1657                            "file name points to a directory".into(),
1658                        ));
1659                    }
1660                    if self.save_policy.confirm_overwrite {
1661                        self.pending_overwrite = Some(sel);
1662                        return Ok(());
1663                    }
1664                }
1665                Err(_) => {}
1666            }
1667        }
1668
1669        self.result = Some(Ok(sel));
1670        Ok(())
1671    }
1672
1673    /// Cancels the dialog.
1674    pub(crate) fn cancel(&mut self) {
1675        self.result = Some(Err(FileDialogError::Cancelled));
1676    }
1677
1678    /// Returns the pending overwrite selection (SaveFile mode) if confirmation is required.
1679    pub(crate) fn pending_overwrite(&self) -> Option<&Selection> {
1680        self.pending_overwrite.as_ref()
1681    }
1682
1683    /// Accept an overwrite prompt and produce the stored selection.
1684    pub(crate) fn accept_overwrite(&mut self) {
1685        if let Some(sel) = self.pending_overwrite.take() {
1686            self.result = Some(Ok(sel));
1687        }
1688    }
1689
1690    /// Cancel an overwrite prompt and return to the dialog.
1691    pub(crate) fn cancel_overwrite(&mut self) {
1692        self.pending_overwrite = None;
1693    }
1694
1695    fn select_single_by_id(&mut self, id: EntryId) {
1696        self.selected_ids.clear();
1697        self.selected_ids.insert(id);
1698        self.focused_id = Some(id);
1699        self.selection_anchor_id = Some(id);
1700    }
1701
1702    fn selection_cap(&self) -> usize {
1703        if !self.allow_multi {
1704            return 1;
1705        }
1706        self.max_selection.unwrap_or(usize::MAX).max(1)
1707    }
1708
1709    fn enforce_selection_cap(&mut self) {
1710        let cap = self.selection_cap();
1711        if cap == usize::MAX || self.selected_ids.len() <= cap {
1712            return;
1713        }
1714        while self.selected_ids.len() > cap {
1715            let Some(first) = self.selected_ids.iter().next().copied() else {
1716                break;
1717            };
1718            self.selected_ids.shift_remove(&first);
1719        }
1720    }
1721
1722    fn retain_selected_visible(&mut self) {
1723        if self.selected_ids.is_empty() {
1724            return;
1725        }
1726
1727        let allow_unresolved = self.allow_unresolved_selection();
1728        if !allow_unresolved {
1729            self.selected_ids
1730                .retain(|id| self.view_ids.iter().any(|visible| visible == id));
1731            self.enforce_selection_cap();
1732        }
1733
1734        if self.view_ids.is_empty() {
1735            if !allow_unresolved {
1736                self.focused_id = None;
1737                self.selection_anchor_id = None;
1738            }
1739            return;
1740        }
1741
1742        if !allow_unresolved
1743            && self
1744                .focused_id
1745                .map(|id| self.view_ids.iter().all(|visible| *visible != id))
1746                .unwrap_or(false)
1747        {
1748            self.focused_id = self.selected_ids.iter().next_back().copied();
1749        }
1750        if !allow_unresolved
1751            && self
1752                .selection_anchor_id
1753                .map(|id| self.view_ids.iter().all(|visible| *visible != id))
1754                .unwrap_or(false)
1755        {
1756            self.selection_anchor_id = self.focused_id;
1757        }
1758    }
1759
1760    fn allow_unresolved_selection(&self) -> bool {
1761        matches!(
1762            self.scan_status,
1763            ScanStatus::Scanning { .. } | ScanStatus::Partial { .. }
1764        )
1765    }
1766}
1767
1768#[derive(Clone, Debug, PartialEq, Eq)]
1769struct ViewKey {
1770    cwd: PathBuf,
1771    mode: DialogMode,
1772    show_hidden: bool,
1773    search: String,
1774    sort_by: SortBy,
1775    sort_ascending: bool,
1776    sort_mode: SortMode,
1777    dirs_first: bool,
1778    active_filter_hash: u64,
1779}
1780
1781impl ViewKey {
1782    fn new(core: &FileDialogCore) -> Self {
1783        Self {
1784            cwd: core.cwd.clone(),
1785            mode: core.mode,
1786            show_hidden: core.show_hidden,
1787            search: core.search.clone(),
1788            sort_by: core.sort_by,
1789            sort_ascending: core.sort_ascending,
1790            sort_mode: core.sort_mode,
1791            dirs_first: core.dirs_first,
1792            active_filter_hash: active_filter_hash(&core.filters, core.active_filter),
1793        }
1794    }
1795}
1796
1797fn active_filter_hash(filters: &[FileFilter], active_filter: Option<usize>) -> u64 {
1798    let Some(i) = active_filter else {
1799        return 0;
1800    };
1801    let Some(f) = filters.get(i) else {
1802        return 0;
1803    };
1804    let mut hasher = DefaultHasher::new();
1805    // Hash both name and tokens so changes trigger a view rebuild.
1806    hasher.write(f.name.as_bytes());
1807    for t in &f.extensions {
1808        hasher.write(t.as_bytes());
1809        hasher.write_u8(0);
1810    }
1811    hasher.finish()
1812}
1813
1814fn effective_filters(filters: &[FileFilter], active_filter: Option<usize>) -> Vec<FileFilter> {
1815    match active_filter {
1816        Some(i) => filters.get(i).cloned().into_iter().collect(),
1817        None => Vec::new(),
1818    }
1819}
1820
1821#[derive(Debug)]
1822enum FilterMatcher {
1823    Any,
1824    Extension(String),
1825    ExtensionGlob(String),
1826    NameRegex(regex::Regex),
1827}
1828
1829fn compile_filter_matchers(filters: &[FileFilter]) -> Vec<FilterMatcher> {
1830    let mut out = Vec::new();
1831    for f in filters {
1832        for token in &f.extensions {
1833            let t = token.trim();
1834            if t.is_empty() {
1835                continue;
1836            }
1837
1838            if let Some(re) = parse_regex_token(t) {
1839                let built = RegexBuilder::new(re)
1840                    .case_insensitive(true)
1841                    .build()
1842                    .map(FilterMatcher::NameRegex);
1843                if let Ok(m) = built {
1844                    out.push(m);
1845                }
1846                continue;
1847            }
1848
1849            if t == "*" {
1850                out.push(FilterMatcher::Any);
1851                continue;
1852            }
1853
1854            if t.contains('*') || t.contains('?') {
1855                let p = normalize_extension_glob(t);
1856                out.push(FilterMatcher::ExtensionGlob(p));
1857                continue;
1858            }
1859
1860            if let Some(ext) = plain_extension_token(t) {
1861                out.push(FilterMatcher::Extension(ext.to_string()));
1862            }
1863        }
1864    }
1865    out
1866}
1867
1868#[cfg(test)]
1869fn matches_filters(name: &str, filters: &[FileFilter]) -> bool {
1870    let matchers = compile_filter_matchers(filters);
1871    matches_filter_matchers(name, &matchers)
1872}
1873
1874fn matches_filter_matchers(name: &str, matchers: &[FilterMatcher]) -> bool {
1875    if matchers.is_empty() {
1876        return true;
1877    }
1878    let name_lower = name.to_lowercase();
1879    let ext_full = full_extension_lower(&name_lower);
1880
1881    matchers.iter().any(|m| match m {
1882        FilterMatcher::Any => true,
1883        FilterMatcher::Extension(ext) => has_extension_suffix(&name_lower, ext),
1884        FilterMatcher::ExtensionGlob(pat) => wildcard_match(pat.as_str(), ext_full),
1885        FilterMatcher::NameRegex(re) => re.is_match(name),
1886    })
1887}
1888
1889fn parse_regex_token(token: &str) -> Option<&str> {
1890    let t = token.trim();
1891    if t.starts_with("((") && t.ends_with("))") && t.len() >= 4 {
1892        Some(&t[2..t.len() - 2])
1893    } else {
1894        None
1895    }
1896}
1897
1898fn plain_extension_token(token: &str) -> Option<&str> {
1899    let t = token.trim().trim_start_matches('.');
1900    if t.is_empty() {
1901        return None;
1902    }
1903    if parse_regex_token(t).is_some() {
1904        return None;
1905    }
1906    if t.contains('*') || t.contains('?') {
1907        return None;
1908    }
1909    Some(t)
1910}
1911
1912fn normalize_extension_glob(token: &str) -> String {
1913    let t = token.trim().to_lowercase();
1914    if t.starts_with('.') || t.starts_with('*') || t.starts_with('?') {
1915        t
1916    } else {
1917        format!(".{t}")
1918    }
1919}
1920
1921fn full_extension_lower(name_lower: &str) -> &str {
1922    name_lower.find('.').map(|i| &name_lower[i..]).unwrap_or("")
1923}
1924
1925fn wildcard_match(pattern: &str, text: &str) -> bool {
1926    // Basic glob matcher supporting `*` and `?`.
1927    //
1928    // - `*` matches any sequence (including empty)
1929    // - `?` matches any single byte
1930    let p = pattern.as_bytes();
1931    let t = text.as_bytes();
1932    let (mut pi, mut ti) = (0usize, 0usize);
1933    let mut star_pi: Option<usize> = None;
1934    let mut star_ti: usize = 0;
1935
1936    while ti < t.len() {
1937        if pi < p.len() && (p[pi] == b'?' || p[pi] == t[ti]) {
1938            pi += 1;
1939            ti += 1;
1940            continue;
1941        }
1942        if pi < p.len() && p[pi] == b'*' {
1943            star_pi = Some(pi);
1944            pi += 1;
1945            star_ti = ti;
1946            continue;
1947        }
1948        if let Some(sp) = star_pi {
1949            pi = sp + 1;
1950            star_ti += 1;
1951            ti = star_ti;
1952            continue;
1953        }
1954        return false;
1955    }
1956
1957    while pi < p.len() && p[pi] == b'*' {
1958        pi += 1;
1959    }
1960    pi == p.len()
1961}
1962
1963fn has_extension_suffix(name_lower: &str, ext: &str) -> bool {
1964    let ext = ext.trim_start_matches('.');
1965    if ext.is_empty() {
1966        return false;
1967    }
1968    if !name_lower.ends_with(ext) {
1969        return false;
1970    }
1971    let prefix_len = name_lower.len() - ext.len();
1972    if prefix_len == 0 {
1973        return false;
1974    }
1975    name_lower.as_bytes()[prefix_len - 1] == b'.'
1976}
1977
1978fn toggle_select_id(list: &mut IndexSet<EntryId>, id: EntryId) {
1979    if !list.shift_remove(&id) {
1980        list.insert(id);
1981    }
1982}
1983
1984fn select_range_by_id_capped(
1985    view_ids: &[EntryId],
1986    anchor: EntryId,
1987    target: EntryId,
1988    cap: usize,
1989) -> Option<Vec<EntryId>> {
1990    let ia = view_ids.iter().position(|id| *id == anchor)?;
1991    let it = view_ids.iter().position(|id| *id == target)?;
1992    let (lo, hi) = if ia <= it { (ia, it) } else { (it, ia) };
1993    let mut range = view_ids[lo..=hi].to_vec();
1994    if cap != usize::MAX && range.len() > cap {
1995        if it >= ia {
1996            let start = range.len() - cap;
1997            range = range[start..].to_vec();
1998        } else {
1999            range.truncate(cap);
2000        }
2001    }
2002    Some(range)
2003}
2004
2005fn finalize_selection(
2006    mode: DialogMode,
2007    cwd: &Path,
2008    selected_entries: Vec<DirEntry>,
2009    save_name: &str,
2010    filters: &[FileFilter],
2011    active_filter: Option<usize>,
2012    save_policy: &SavePolicy,
2013) -> Result<Selection, FileDialogError> {
2014    let mut sel = Selection { paths: Vec::new() };
2015    let eff_filters = effective_filters(filters, active_filter);
2016    let matchers = compile_filter_matchers(&eff_filters);
2017    match mode {
2018        DialogMode::PickFolder => {
2019            if let Some(dir) = selected_entries.into_iter().find(|e| e.is_dir) {
2020                sel.paths.push(dir.path);
2021            } else {
2022                // No explicit selection => pick current directory.
2023                sel.paths.push(cwd.to_path_buf());
2024            }
2025        }
2026        DialogMode::OpenFile | DialogMode::OpenFiles => {
2027            if selected_entries.is_empty() {
2028                return Err(FileDialogError::InvalidPath("no selection".into()));
2029            }
2030            for entry in selected_entries {
2031                if entry.is_dir {
2032                    continue;
2033                }
2034                if !matches_filter_matchers(&entry.name, &matchers) {
2035                    continue;
2036                }
2037                sel.paths.push(entry.path);
2038            }
2039            if sel.paths.is_empty() {
2040                return Err(FileDialogError::InvalidPath(
2041                    "no file matched filters".into(),
2042                ));
2043            }
2044        }
2045        DialogMode::SaveFile => {
2046            let name = normalize_save_name(save_name, &eff_filters, save_policy.extension_policy);
2047            if name.is_empty() {
2048                return Err(FileDialogError::InvalidPath("empty file name".into()));
2049            }
2050            sel.paths.push(cwd.join(name));
2051        }
2052    }
2053    Ok(sel)
2054}
2055
2056fn normalize_save_name(save_name: &str, filters: &[FileFilter], policy: ExtensionPolicy) -> String {
2057    let name = save_name.trim().to_string();
2058    if name.is_empty() {
2059        return name;
2060    }
2061
2062    let default_ext = filters
2063        .first()
2064        .and_then(|f| f.extensions.iter().find_map(|s| plain_extension_token(s)))
2065        .map(|s| s.trim_start_matches('.'));
2066    let Some(default_ext) = default_ext else {
2067        return name;
2068    };
2069
2070    let p = Path::new(&name);
2071    let has_ext = p.extension().and_then(|s| s.to_str()).is_some();
2072
2073    match policy {
2074        ExtensionPolicy::KeepUser => name,
2075        ExtensionPolicy::AddIfMissing => {
2076            if has_ext {
2077                name
2078            } else {
2079                format!("{name}.{default_ext}")
2080            }
2081        }
2082        ExtensionPolicy::ReplaceByFilter => {
2083            let stem = p.file_stem().and_then(|s| s.to_str()).unwrap_or(&name);
2084            format!("{stem}.{default_ext}")
2085        }
2086    }
2087}
2088
2089fn filter_entries_in_place(
2090    entries: &mut Vec<DirEntry>,
2091    mode: DialogMode,
2092    show_hidden: bool,
2093    filters: &[FileFilter],
2094    active_filter: Option<usize>,
2095    search: &str,
2096) {
2097    let display_filters = effective_filters(filters, active_filter);
2098    let matchers = compile_filter_matchers(&display_filters);
2099    let search_lower = if search.is_empty() {
2100        None
2101    } else {
2102        Some(search.to_lowercase())
2103    };
2104    entries.retain(|e| {
2105        if !show_hidden && e.name.starts_with('.') {
2106            return false;
2107        }
2108        let pass_kind = if matches!(mode, DialogMode::PickFolder) {
2109            e.is_dir
2110        } else {
2111            e.is_dir || matches_filter_matchers(&e.name, &matchers)
2112        };
2113        let pass_search = match &search_lower {
2114            None => true,
2115            Some(q) => e.name.to_lowercase().contains(q),
2116        };
2117        pass_kind && pass_search
2118    });
2119}
2120
2121fn sort_entries_in_place(
2122    entries: &mut [DirEntry],
2123    sort_by: SortBy,
2124    sort_ascending: bool,
2125    sort_mode: SortMode,
2126    dirs_first: bool,
2127    type_dots_to_extract: usize,
2128) {
2129    entries.sort_by(|a, b| {
2130        if dirs_first && a.is_dir != b.is_dir {
2131            return b.is_dir.cmp(&a.is_dir);
2132        }
2133        let ord = match sort_by {
2134            SortBy::Name => {
2135                let al = a.name.to_lowercase();
2136                let bl = b.name.to_lowercase();
2137                cmp_lower(&al, &bl, sort_mode)
2138            }
2139            SortBy::Type => {
2140                use std::cmp::Ordering;
2141                let al = a.name.to_lowercase();
2142                let bl = b.name.to_lowercase();
2143                let ae = type_extension_lower(&al, type_dots_to_extract);
2144                let be = type_extension_lower(&bl, type_dots_to_extract);
2145                let ord = cmp_lower(ae, be, sort_mode);
2146                if ord == Ordering::Equal {
2147                    cmp_lower(&al, &bl, sort_mode)
2148                } else {
2149                    ord
2150                }
2151            }
2152            SortBy::Extension => {
2153                use std::cmp::Ordering;
2154                let al = a.name.to_lowercase();
2155                let bl = b.name.to_lowercase();
2156                let ae = full_extension_lower(&al);
2157                let be = full_extension_lower(&bl);
2158                let ord = cmp_lower(ae, be, sort_mode);
2159                if ord == Ordering::Equal {
2160                    cmp_lower(&al, &bl, sort_mode)
2161                } else {
2162                    ord
2163                }
2164            }
2165            SortBy::Size => a.size.unwrap_or(0).cmp(&b.size.unwrap_or(0)),
2166            SortBy::Modified => a.modified.cmp(&b.modified),
2167        };
2168        if sort_ascending { ord } else { ord.reverse() }
2169    });
2170}
2171
2172fn igfd_type_dots_to_extract(active_filter: Option<&FileFilter>) -> usize {
2173    let Some(filter) = active_filter else {
2174        return 1;
2175    };
2176    let mut max_dots = 1usize;
2177    for token in &filter.extensions {
2178        let t = token.trim();
2179        if t.is_empty() {
2180            continue;
2181        }
2182        if parse_regex_token(t).is_some() {
2183            continue;
2184        }
2185        let dot_count = t.as_bytes().iter().filter(|&&b| b == b'.').count();
2186        let token_dots = if t.contains('*') || t.contains('?') {
2187            dot_count
2188        } else if t.starts_with('.') {
2189            dot_count
2190        } else {
2191            dot_count.saturating_add(1)
2192        };
2193        max_dots = max_dots.max(token_dots);
2194    }
2195    max_dots.max(1)
2196}
2197
2198fn type_extension_lower(name_lower: &str, dots_to_extract: usize) -> &str {
2199    if dots_to_extract == 0 {
2200        return full_extension_lower(name_lower);
2201    }
2202    let bytes = name_lower.as_bytes();
2203    let total_dots = bytes.iter().filter(|&&b| b == b'.').count();
2204    if total_dots == 0 {
2205        return "";
2206    }
2207    let dots = dots_to_extract.min(total_dots);
2208    let mut seen = 0usize;
2209    for i in (0..bytes.len()).rev() {
2210        if bytes[i] == b'.' {
2211            seen += 1;
2212            if seen == dots {
2213                return &name_lower[i..];
2214            }
2215        }
2216    }
2217    ""
2218}
2219
2220fn cmp_lower(a: &str, b: &str, mode: SortMode) -> std::cmp::Ordering {
2221    match mode {
2222        SortMode::Natural => natural_cmp_lower(a, b),
2223        SortMode::Lexicographic => a.cmp(b),
2224    }
2225}
2226
2227pub(crate) fn natural_cmp_lower(a: &str, b: &str) -> std::cmp::Ordering {
2228    use std::cmp::Ordering;
2229    let ab = a.as_bytes();
2230    let bb = b.as_bytes();
2231    let (mut i, mut j) = (0usize, 0usize);
2232
2233    while i < ab.len() && j < bb.len() {
2234        let ca = ab[i];
2235        let cb = bb[j];
2236
2237        if ca.is_ascii_digit() && cb.is_ascii_digit() {
2238            let (a_end, a_trim, a_trim_end) = scan_number(ab, i);
2239            let (b_end, b_trim, b_trim_end) = scan_number(bb, j);
2240
2241            let a_len = a_trim_end.saturating_sub(a_trim);
2242            let b_len = b_trim_end.saturating_sub(b_trim);
2243
2244            let ord = match a_len.cmp(&b_len) {
2245                Ordering::Equal => ab[a_trim..a_trim_end].cmp(&bb[b_trim..b_trim_end]),
2246                o => o,
2247            };
2248
2249            if ord != Ordering::Equal {
2250                return ord;
2251            }
2252
2253            // Same numeric value: shorter (fewer leading zeros) sorts first.
2254            let ord = (a_end - i).cmp(&(b_end - j));
2255            if ord != Ordering::Equal {
2256                return ord;
2257            }
2258
2259            i = a_end;
2260            j = b_end;
2261            continue;
2262        }
2263
2264        if ca != cb {
2265            return ca.cmp(&cb);
2266        }
2267        i += 1;
2268        j += 1;
2269    }
2270
2271    a.len().cmp(&b.len())
2272}
2273
2274fn scan_number(bytes: &[u8], start: usize) -> (usize, usize, usize) {
2275    let mut end = start;
2276    while end < bytes.len() && bytes[end].is_ascii_digit() {
2277        end += 1;
2278    }
2279    let mut trim = start;
2280    while trim < end && bytes[trim] == b'0' {
2281        trim += 1;
2282    }
2283    (end, trim, end)
2284}
2285
2286fn entry_id_from_path(path: &Path, is_dir: bool, is_symlink: bool) -> EntryId {
2287    let mut hasher = DefaultHasher::new();
2288    hasher.write(path.to_string_lossy().as_bytes());
2289    hasher.write_u8(if is_dir { 1 } else { 0 });
2290    hasher.write_u8(if is_symlink { 1 } else { 0 });
2291    EntryId::new(hasher.finish())
2292}
2293
2294fn sanitize_scanned_entry(mut entry: FsEntry, dir: &Path) -> Option<FsEntry> {
2295    if entry.path.as_os_str().is_empty() {
2296        if entry.name.trim().is_empty() {
2297            return None;
2298        }
2299        entry.path = dir.join(&entry.name);
2300    }
2301
2302    if entry.name.trim().is_empty() {
2303        let inferred_name = entry
2304            .path
2305            .file_name()
2306            .map(|n| n.to_string_lossy().to_string())
2307            .filter(|n| !n.is_empty())?;
2308        entry.name = inferred_name;
2309    }
2310
2311    if entry.is_dir {
2312        entry.size = None;
2313    }
2314
2315    Some(entry)
2316}
2317
2318fn read_entries_snapshot_with_fs(
2319    fs: &dyn FileSystem,
2320    dir: &Path,
2321    mut scan_hook: Option<&mut ScanHook>,
2322) -> std::io::Result<DirSnapshot> {
2323    let mut out = Vec::new();
2324    let rd = fs.read_dir(dir)?;
2325    for mut entry in rd {
2326        if let Some(hook) = scan_hook.as_deref_mut() {
2327            if matches!(hook.apply(&mut entry), ScanHookAction::Drop) {
2328                continue;
2329            }
2330        }
2331
2332        let Some(entry) = sanitize_scanned_entry(entry, dir) else {
2333            continue;
2334        };
2335
2336        let meta = FileMeta {
2337            is_dir: entry.is_dir,
2338            is_symlink: entry.is_symlink,
2339            size: entry.size,
2340            modified: entry.modified,
2341        };
2342        out.push(DirEntry {
2343            id: entry_id_from_path(&entry.path, meta.is_dir, meta.is_symlink),
2344            name: entry.name,
2345            path: entry.path,
2346            is_dir: meta.is_dir,
2347            is_symlink: meta.is_symlink,
2348            size: meta.size,
2349            modified: meta.modified,
2350        });
2351    }
2352    Ok(DirSnapshot {
2353        cwd: dir.to_path_buf(),
2354        entry_count: out.len(),
2355        entries: out,
2356    })
2357}
2358
2359fn empty_snapshot_for_cwd(cwd: &Path) -> DirSnapshot {
2360    DirSnapshot {
2361        cwd: cwd.to_path_buf(),
2362        entry_count: 0,
2363        entries: Vec::new(),
2364    }
2365}
2366
2367#[cfg(feature = "tracing")]
2368fn trace_scan_requested(request: &ScanRequest) {
2369    trace!(
2370        event = "scan.requested",
2371        generation = request.generation,
2372        cwd = %request.cwd.display(),
2373        ?request.scan_policy,
2374        "scan requested"
2375    );
2376}
2377
2378#[cfg(not(feature = "tracing"))]
2379fn trace_scan_requested(_request: &ScanRequest) {}
2380
2381#[cfg(feature = "tracing")]
2382fn trace_scan_batch_applied(generation: u64, entries: usize, kind: &'static str) {
2383    trace!(
2384        event = "scan.batch_applied",
2385        generation, entries, kind, "scan batch applied"
2386    );
2387}
2388
2389#[cfg(not(feature = "tracing"))]
2390fn trace_scan_batch_applied(_generation: u64, _entries: usize, _kind: &'static str) {}
2391
2392#[cfg(feature = "tracing")]
2393fn trace_scan_completed(generation: u64, total_entries: usize, duration_ms: u128) {
2394    trace!(
2395        event = "scan.completed",
2396        generation, total_entries, duration_ms, "scan completed"
2397    );
2398}
2399
2400#[cfg(not(feature = "tracing"))]
2401fn trace_scan_completed(_generation: u64, _total_entries: usize, _duration_ms: u128) {}
2402
2403#[cfg(feature = "tracing")]
2404fn trace_scan_dropped_stale_batch(generation: u64, current_generation: u64, source: &'static str) {
2405    trace!(
2406        event = "scan.dropped_stale_batch",
2407        generation, current_generation, source, "scan dropped stale batch"
2408    );
2409}
2410
2411#[cfg(not(feature = "tracing"))]
2412fn trace_scan_dropped_stale_batch(
2413    _generation: u64,
2414    _current_generation: u64,
2415    _source: &'static str,
2416) {
2417}
2418
2419#[cfg(feature = "tracing")]
2420fn trace_projector_rebuild(reason: &'static str, visible_entries: usize, duration_us: u128) {
2421    trace!(
2422        event = "projector.rebuild",
2423        reason, visible_entries, duration_us, "projector rebuilt"
2424    );
2425}
2426
2427#[cfg(not(feature = "tracing"))]
2428fn trace_projector_rebuild(_reason: &'static str, _visible_entries: usize, _duration_us: u128) {}
2429
2430#[cfg(test)]
2431mod tests {
2432    use super::*;
2433    use crate::fs::StdFileSystem;
2434    use std::cell::Cell;
2435    use std::time::{Duration, Instant};
2436
2437    fn mods(ctrl: bool, shift: bool) -> Modifiers {
2438        Modifiers { ctrl, shift }
2439    }
2440
2441    fn make_file_entry(name: &str) -> DirEntry {
2442        let path = PathBuf::from("/tmp").join(name);
2443        DirEntry {
2444            id: entry_id_from_path(&path, false, false),
2445            name: name.to_string(),
2446            path,
2447            is_dir: false,
2448            is_symlink: false,
2449            size: None,
2450            modified: None,
2451        }
2452    }
2453
2454    fn make_dir_entry(name: &str) -> DirEntry {
2455        let path = PathBuf::from("/tmp").join(name);
2456        DirEntry {
2457            id: entry_id_from_path(&path, true, false),
2458            name: name.to_string(),
2459            path,
2460            is_dir: true,
2461            is_symlink: false,
2462            size: None,
2463            modified: None,
2464        }
2465    }
2466
2467    fn set_view_files(core: &mut FileDialogCore, names: &[&str]) {
2468        core.entries = names.iter().map(|name| make_file_entry(name)).collect();
2469        core.view_names = core
2470            .entries
2471            .iter()
2472            .map(|entry| entry.name.clone())
2473            .collect();
2474        core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
2475    }
2476
2477    fn entry_id(core: &FileDialogCore, name: &str) -> EntryId {
2478        core.entries
2479            .iter()
2480            .find(|entry| entry.name == name)
2481            .map(|entry| entry.id)
2482            .unwrap_or_else(|| panic!("missing entry id for {name}"))
2483    }
2484
2485    fn selected_entry_names(core: &FileDialogCore) -> Vec<String> {
2486        let entries = core.entries();
2487        core.selected_entry_ids()
2488            .into_iter()
2489            .filter_map(|id| {
2490                entries
2491                    .iter()
2492                    .find(|entry| entry.id == id)
2493                    .map(|entry| entry.name.clone())
2494            })
2495            .collect()
2496    }
2497
2498    fn make_synthetic_fs_entries(count: usize) -> Vec<crate::fs::FsEntry> {
2499        (0..count)
2500            .map(|idx| {
2501                let name = format!("file_{idx:05}.txt");
2502                crate::fs::FsEntry {
2503                    path: PathBuf::from("/tmp").join(&name),
2504                    name,
2505                    is_dir: false,
2506                    is_symlink: false,
2507                    size: Some((idx % 1024) as u64),
2508                    modified: None,
2509                }
2510            })
2511            .collect()
2512    }
2513
2514    #[test]
2515    fn navigation_history_back_forward_tracks_and_clears_forward() {
2516        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2517        let start = PathBuf::from("/tmp").join("start");
2518        let one = PathBuf::from("/tmp").join("one");
2519        let two = PathBuf::from("/tmp").join("two");
2520        core.set_cwd(start.clone());
2521
2522        assert!(!core.can_navigate_back());
2523        assert!(!core.can_navigate_forward());
2524
2525        let _ = core.handle_event(CoreEvent::NavigateTo(one.clone()));
2526        assert_eq!(core.cwd, one);
2527        assert!(core.can_navigate_back());
2528        assert!(!core.can_navigate_forward());
2529
2530        let _ = core.handle_event(CoreEvent::NavigateBack);
2531        assert_eq!(core.cwd, start);
2532        assert!(!core.can_navigate_back());
2533        assert!(core.can_navigate_forward());
2534
2535        let _ = core.handle_event(CoreEvent::NavigateForward);
2536        assert_eq!(core.cwd, one);
2537        assert!(core.can_navigate_back());
2538        assert!(!core.can_navigate_forward());
2539
2540        let _ = core.handle_event(CoreEvent::NavigateBack);
2541        assert_eq!(core.cwd, start);
2542        assert!(core.can_navigate_forward());
2543
2544        let _ = core.handle_event(CoreEvent::NavigateTo(two.clone()));
2545        assert_eq!(core.cwd, two);
2546        assert!(core.can_navigate_back());
2547        assert!(!core.can_navigate_forward());
2548
2549        let _ = core.handle_event(CoreEvent::Refresh);
2550        assert_eq!(core.cwd, two);
2551        assert!(core.can_navigate_back());
2552        assert!(!core.can_navigate_forward());
2553    }
2554
2555    #[derive(Default)]
2556    struct TestFs {
2557        meta: std::collections::HashMap<PathBuf, crate::fs::FsMetadata>,
2558        entries: Vec<crate::fs::FsEntry>,
2559        read_dir_calls: Cell<usize>,
2560        read_dir_error: Option<std::io::ErrorKind>,
2561    }
2562
2563    impl crate::fs::FileSystem for TestFs {
2564        fn read_dir(&self, _dir: &Path) -> std::io::Result<Vec<crate::fs::FsEntry>> {
2565            self.read_dir_calls.set(self.read_dir_calls.get() + 1);
2566            if let Some(kind) = self.read_dir_error {
2567                return Err(std::io::Error::new(kind, "read_dir failure"));
2568            }
2569            Ok(self.entries.clone())
2570        }
2571
2572        fn canonicalize(&self, path: &Path) -> std::io::Result<PathBuf> {
2573            Ok(path.to_path_buf())
2574        }
2575
2576        fn metadata(&self, path: &Path) -> std::io::Result<crate::fs::FsMetadata> {
2577            self.meta
2578                .get(path)
2579                .cloned()
2580                .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "not found"))
2581        }
2582
2583        fn create_dir(&self, _path: &Path) -> std::io::Result<()> {
2584            Err(std::io::Error::new(
2585                std::io::ErrorKind::Unsupported,
2586                "create_dir not supported in TestFs",
2587            ))
2588        }
2589
2590        fn rename(&self, _from: &Path, _to: &Path) -> std::io::Result<()> {
2591            Err(std::io::Error::new(
2592                std::io::ErrorKind::Unsupported,
2593                "rename not supported in TestFs",
2594            ))
2595        }
2596
2597        fn remove_file(&self, _path: &Path) -> std::io::Result<()> {
2598            Err(std::io::Error::new(
2599                std::io::ErrorKind::Unsupported,
2600                "remove_file not supported in TestFs",
2601            ))
2602        }
2603
2604        fn remove_dir(&self, _path: &Path) -> std::io::Result<()> {
2605            Err(std::io::Error::new(
2606                std::io::ErrorKind::Unsupported,
2607                "remove_dir not supported in TestFs",
2608            ))
2609        }
2610
2611        fn remove_dir_all(&self, _path: &Path) -> std::io::Result<()> {
2612            Err(std::io::Error::new(
2613                std::io::ErrorKind::Unsupported,
2614                "remove_dir_all not supported in TestFs",
2615            ))
2616        }
2617
2618        fn copy_file(&self, _from: &Path, _to: &Path) -> std::io::Result<u64> {
2619            Err(std::io::Error::new(
2620                std::io::ErrorKind::Unsupported,
2621                "copy_file not supported in TestFs",
2622            ))
2623        }
2624    }
2625
2626    #[test]
2627    fn entry_path_by_id_resolves_visible_entry_path() {
2628        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2629        set_view_files(&mut core, &["a.txt", "b.txt"]);
2630
2631        let b = entry_id(&core, "b.txt");
2632        assert_eq!(core.entry_path_by_id(b), Some(Path::new("/tmp/b.txt")));
2633    }
2634
2635    #[test]
2636    fn entry_path_by_id_returns_none_for_unresolved_id() {
2637        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2638        set_view_files(&mut core, &["a.txt"]);
2639
2640        let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
2641        assert!(core.entry_path_by_id(missing).is_none());
2642    }
2643    #[test]
2644    fn selected_entry_paths_skips_unresolved_ids() {
2645        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2646        set_view_files(&mut core, &["a.txt", "b.txt"]);
2647
2648        let a = entry_id(&core, "a.txt");
2649        let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
2650        core.replace_selection_by_ids([a, missing]);
2651
2652        assert_eq!(
2653            core.selected_entry_paths(),
2654            vec![PathBuf::from("/tmp/a.txt")]
2655        );
2656    }
2657
2658    #[test]
2659    fn selected_entry_counts_tracks_files_and_dirs() {
2660        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2661        core.entries = vec![make_file_entry("a.txt"), make_dir_entry("folder")];
2662        core.view_names = core
2663            .entries
2664            .iter()
2665            .map(|entry| entry.name.clone())
2666            .collect();
2667        core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
2668
2669        let a = entry_id(&core, "a.txt");
2670        let folder = entry_id(&core, "folder");
2671        let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
2672        core.replace_selection_by_ids([a, folder, missing]);
2673
2674        assert_eq!(core.selected_entry_counts(), (1, 1));
2675    }
2676
2677    #[test]
2678    fn cancel_sets_result() {
2679        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2680        core.cancel();
2681        assert!(matches!(
2682            core.take_result(),
2683            Some(Err(crate::FileDialogError::Cancelled))
2684        ));
2685    }
2686
2687    #[test]
2688    fn click_file_toggles_in_multi_select() {
2689        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2690        core.allow_multi = true;
2691        set_view_files(&mut core, &["a.txt"]);
2692
2693        let a = entry_id(&core, "a.txt");
2694        core.click_entry(a, mods(true, false));
2695        assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
2696        core.click_entry(a, mods(true, false));
2697        assert!(selected_entry_names(&core).is_empty());
2698    }
2699
2700    #[test]
2701    fn focus_and_select_by_id_accepts_unresolved_entry_until_rescan() {
2702        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2703        core.allow_multi = true;
2704
2705        let pending = EntryId::from_path(&core.cwd.join("new_folder"));
2706        core.focus_and_select_by_id(pending);
2707
2708        assert_eq!(core.selected_entry_ids(), vec![pending]);
2709        assert_eq!(core.focused_entry_id(), Some(pending));
2710        assert!(selected_entry_names(&core).is_empty());
2711    }
2712
2713    #[test]
2714    fn focus_and_select_by_id_sets_focus_and_anchor() {
2715        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2716        core.allow_multi = true;
2717        set_view_files(&mut core, &["a.txt", "b.txt"]);
2718
2719        let id = entry_id(&core, "b.txt");
2720        core.focus_and_select_by_id(id);
2721
2722        assert_eq!(core.selected_entry_ids(), vec![id]);
2723        assert_eq!(core.focused_entry_id(), Some(id));
2724        assert_eq!(selected_entry_names(&core), vec!["b.txt"]);
2725    }
2726
2727    #[test]
2728    fn shift_click_selects_a_range_in_view_order() {
2729        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2730        core.allow_multi = true;
2731        set_view_files(&mut core, &["a.txt", "b.txt", "c.txt", "d.txt", "e.txt"]);
2732
2733        core.click_entry(entry_id(&core, "b.txt"), mods(false, false));
2734        core.click_entry(entry_id(&core, "e.txt"), mods(false, true));
2735        assert_eq!(
2736            selected_entry_names(&core),
2737            vec!["b.txt", "c.txt", "d.txt", "e.txt"]
2738        );
2739    }
2740
2741    #[test]
2742    fn ctrl_a_selects_all_when_multi_select_enabled() {
2743        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2744        core.allow_multi = true;
2745        set_view_files(&mut core, &["a", "b", "c"]);
2746
2747        core.select_all();
2748        assert_eq!(selected_entry_names(&core), vec!["a", "b", "c"]);
2749    }
2750
2751    #[test]
2752    fn ctrl_a_respects_max_selection_cap() {
2753        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2754        core.allow_multi = true;
2755        core.max_selection = Some(2);
2756        set_view_files(&mut core, &["a", "b", "c"]);
2757
2758        core.select_all();
2759        assert_eq!(selected_entry_names(&core), vec!["a", "b"]);
2760    }
2761
2762    #[test]
2763    fn shift_click_respects_max_selection_cap_and_keeps_target() {
2764        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2765        core.allow_multi = true;
2766        core.max_selection = Some(2);
2767        set_view_files(&mut core, &["a", "b", "c", "d", "e"]);
2768
2769        core.click_entry(entry_id(&core, "b"), mods(false, false));
2770        core.click_entry(entry_id(&core, "e"), mods(false, true));
2771        assert_eq!(selected_entry_names(&core), vec!["d", "e"]);
2772
2773        core.click_entry(entry_id(&core, "d"), mods(false, false));
2774        core.click_entry(entry_id(&core, "b"), mods(false, true));
2775        assert_eq!(selected_entry_names(&core), vec!["b", "c"]);
2776    }
2777
2778    #[test]
2779    fn ctrl_click_caps_by_dropping_oldest_selected() {
2780        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2781        core.allow_multi = true;
2782        core.max_selection = Some(2);
2783        set_view_files(&mut core, &["a", "b", "c"]);
2784
2785        core.click_entry(entry_id(&core, "a"), mods(false, false));
2786        core.click_entry(entry_id(&core, "b"), mods(true, false));
2787        assert_eq!(selected_entry_names(&core), vec!["a", "b"]);
2788
2789        core.click_entry(entry_id(&core, "c"), mods(true, false));
2790        assert_eq!(selected_entry_names(&core), vec!["b", "c"]);
2791    }
2792
2793    #[test]
2794    fn move_focus_with_shift_extends_range() {
2795        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2796        core.allow_multi = true;
2797        set_view_files(&mut core, &["a", "b", "c", "d"]);
2798
2799        core.click_entry(entry_id(&core, "b"), mods(false, false));
2800        core.move_focus(2, mods(false, true));
2801        assert_eq!(selected_entry_names(&core), vec!["b", "c", "d"]);
2802        assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "d")));
2803    }
2804
2805    #[test]
2806    fn handle_event_activate_focused_requests_confirm() {
2807        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2808        set_view_files(&mut core, &["a.txt"]);
2809        core.focused_id = Some(entry_id(&core, "a.txt"));
2810
2811        let outcome = core.handle_event(CoreEvent::ActivateFocused);
2812        assert_eq!(outcome, CoreEventOutcome::RequestConfirm);
2813        assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
2814    }
2815
2816    #[test]
2817    fn handle_event_double_click_file_requests_confirm() {
2818        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2819        set_view_files(&mut core, &["a.txt"]);
2820
2821        let outcome = core.handle_event(CoreEvent::DoubleClickEntry {
2822            id: entry_id(&core, "a.txt"),
2823        });
2824
2825        assert_eq!(outcome, CoreEventOutcome::RequestConfirm);
2826        assert_eq!(selected_entry_names(&core), vec!["a.txt"]);
2827    }
2828
2829    #[test]
2830    fn handle_event_navigate_up_updates_cwd() {
2831        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2832        core.cwd = PathBuf::from("/tmp/child");
2833
2834        let outcome = core.handle_event(CoreEvent::NavigateUp);
2835
2836        assert_eq!(outcome, CoreEventOutcome::None);
2837        assert_eq!(core.cwd, PathBuf::from("/tmp"));
2838    }
2839
2840    #[test]
2841    fn handle_event_replace_selection_by_ids_uses_id_order() {
2842        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
2843        core.allow_multi = true;
2844        set_view_files(&mut core, &["a.txt", "b.txt", "c.txt"]);
2845
2846        let c = entry_id(&core, "c.txt");
2847        let a = entry_id(&core, "a.txt");
2848        let outcome = core.handle_event(CoreEvent::ReplaceSelectionByIds(vec![c, a]));
2849
2850        assert_eq!(outcome, CoreEventOutcome::None);
2851        assert_eq!(core.selected_entry_ids(), vec![c, a]);
2852        assert_eq!(selected_entry_names(&core), vec!["c.txt", "a.txt"]);
2853    }
2854
2855    #[test]
2856    fn activate_focused_confirms_selection() {
2857        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2858        set_view_files(&mut core, &["a.txt"]);
2859        core.focused_id = Some(entry_id(&core, "a.txt"));
2860
2861        let gate = ConfirmGate::default();
2862        assert!(core.activate_focused());
2863        core.confirm(&StdFileSystem, &gate, None).unwrap();
2864        let sel = core.take_result().unwrap().unwrap();
2865        assert_eq!(sel.paths.len(), 1);
2866        assert_eq!(
2867            sel.paths[0].file_name().and_then(|s| s.to_str()),
2868            Some("a.txt")
2869        );
2870    }
2871
2872    #[test]
2873    fn open_footer_typed_file_confirms_when_no_selection() {
2874        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2875        core.cwd = PathBuf::from("/tmp");
2876
2877        let mut fs = TestFs::default();
2878        fs.meta.insert(
2879            PathBuf::from("/tmp/a.txt"),
2880            crate::fs::FsMetadata {
2881                is_dir: false,
2882                is_symlink: false,
2883            },
2884        );
2885
2886        let gate = ConfirmGate::default();
2887        core.confirm(&fs, &gate, Some("a.txt")).unwrap();
2888        let sel = core.take_result().unwrap().unwrap();
2889        assert_eq!(sel.paths, vec![PathBuf::from("/tmp/a.txt")]);
2890    }
2891
2892    #[test]
2893    fn open_footer_typed_directory_navigates_instead_of_confirming() {
2894        let mut core = FileDialogCore::new(DialogMode::OpenFile);
2895        core.cwd = PathBuf::from("/tmp");
2896
2897        let mut fs = TestFs::default();
2898        fs.meta.insert(
2899            PathBuf::from("/tmp/folder"),
2900            crate::fs::FsMetadata {
2901                is_dir: true,
2902                is_symlink: false,
2903            },
2904        );
2905
2906        let gate = ConfirmGate::default();
2907        core.confirm(&fs, &gate, Some("folder")).unwrap();
2908        assert_eq!(core.cwd, PathBuf::from("/tmp/folder"));
2909        assert!(core.take_result().is_none());
2910    }
2911
2912    #[test]
2913    fn pick_folder_confirms_selected_directory_when_present() {
2914        let mut core = FileDialogCore::new(DialogMode::PickFolder);
2915        core.cwd = PathBuf::from("/tmp");
2916        core.entries = vec![make_dir_entry("a"), make_dir_entry("b")];
2917        core.view_names = core
2918            .entries
2919            .iter()
2920            .map(|entry| entry.name.clone())
2921            .collect();
2922        core.view_ids = core.entries.iter().map(|entry| entry.id).collect();
2923
2924        let b = entry_id(&core, "b");
2925        let _ = core.handle_event(CoreEvent::ClickEntry {
2926            id: b,
2927            modifiers: Modifiers::default(),
2928        });
2929
2930        let gate = ConfirmGate::default();
2931        let fs = TestFs::default();
2932        core.confirm(&fs, &gate, None).unwrap();
2933        let sel = core.take_result().unwrap().unwrap();
2934        assert_eq!(sel.paths, vec![PathBuf::from("/tmp/b")]);
2935    }
2936
2937    #[test]
2938    fn save_adds_extension_from_active_filter_when_missing() {
2939        let mut core = FileDialogCore::new(DialogMode::SaveFile);
2940        core.cwd = PathBuf::from("/tmp");
2941        core.save_name = "asset".into();
2942        core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
2943        core.save_policy.extension_policy = ExtensionPolicy::AddIfMissing;
2944        core.save_policy.confirm_overwrite = false;
2945
2946        let gate = ConfirmGate::default();
2947        let fs = TestFs::default();
2948        core.confirm(&fs, &gate, None).unwrap();
2949        let sel = core.take_result().unwrap().unwrap();
2950        assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
2951    }
2952
2953    #[test]
2954    fn save_keep_user_extension_does_not_modify_name() {
2955        let mut core = FileDialogCore::new(DialogMode::SaveFile);
2956        core.cwd = PathBuf::from("/tmp");
2957        core.save_name = "asset.jpg".into();
2958        core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
2959        core.save_policy.extension_policy = ExtensionPolicy::KeepUser;
2960        core.save_policy.confirm_overwrite = false;
2961
2962        let gate = ConfirmGate::default();
2963        let fs = TestFs::default();
2964        core.confirm(&fs, &gate, None).unwrap();
2965        let sel = core.take_result().unwrap().unwrap();
2966        assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.jpg"));
2967    }
2968
2969    #[test]
2970    fn save_replace_by_filter_replaces_existing_extension() {
2971        let mut core = FileDialogCore::new(DialogMode::SaveFile);
2972        core.cwd = PathBuf::from("/tmp");
2973        core.save_name = "asset.jpg".into();
2974        core.set_filters(vec![FileFilter::new("Images", vec!["png".to_string()])]);
2975        core.save_policy.extension_policy = ExtensionPolicy::ReplaceByFilter;
2976        core.save_policy.confirm_overwrite = false;
2977
2978        let gate = ConfirmGate::default();
2979        let fs = TestFs::default();
2980        core.confirm(&fs, &gate, None).unwrap();
2981        let sel = core.take_result().unwrap().unwrap();
2982        assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
2983    }
2984
2985    #[test]
2986    fn matches_filters_supports_multi_layer_extensions() {
2987        let filters = vec![FileFilter::new("VS", vec!["vcxproj.filters".to_string()])];
2988        assert!(matches_filters("proj.vcxproj.filters", &filters));
2989        assert!(!matches_filters("proj.vcxproj", &filters));
2990        assert!(!matches_filters("vcxproj.filters", &filters));
2991    }
2992
2993    #[test]
2994    fn matches_filters_supports_extension_globs() {
2995        let filters = vec![FileFilter::new(
2996            "VS-ish",
2997            vec![".vcx*".to_string(), ".*.filters".to_string()],
2998        )];
2999        assert!(matches_filters("proj.vcxproj.filters", &filters));
3000        assert!(matches_filters("proj.vcxproj", &filters));
3001        assert!(!matches_filters("README", &filters));
3002    }
3003
3004    #[test]
3005    fn matches_filters_supports_regex_tokens() {
3006        let filters = vec![FileFilter::new(
3007            "Re",
3008            vec![r"((^imgui_.*\.rs$))".to_string()],
3009        )];
3010        assert!(matches_filters("imgui_demo.rs", &filters));
3011        assert!(matches_filters("ImGui_DEMO.RS", &filters));
3012        assert!(!matches_filters("demo_imgui.rs", &filters));
3013    }
3014
3015    #[test]
3016    fn natural_sort_orders_digit_runs() {
3017        let mut entries = vec![
3018            make_file_entry("file10.txt"),
3019            make_file_entry("file2.txt"),
3020            make_file_entry("file1.txt"),
3021        ];
3022        sort_entries_in_place(
3023            &mut entries,
3024            SortBy::Name,
3025            true,
3026            SortMode::Natural,
3027            false,
3028            1,
3029        );
3030        let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
3031        assert_eq!(names, vec!["file1.txt", "file2.txt", "file10.txt"]);
3032    }
3033
3034    #[test]
3035    fn lexicographic_sort_orders_digit_runs_as_strings() {
3036        let mut entries = vec![
3037            make_file_entry("file10.txt"),
3038            make_file_entry("file2.txt"),
3039            make_file_entry("file1.txt"),
3040        ];
3041        sort_entries_in_place(
3042            &mut entries,
3043            SortBy::Name,
3044            true,
3045            SortMode::Lexicographic,
3046            false,
3047            1,
3048        );
3049        let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
3050        assert_eq!(names, vec!["file1.txt", "file10.txt", "file2.txt"]);
3051    }
3052
3053    #[test]
3054    fn sort_by_extension_orders_by_full_extension_then_name() {
3055        let mut entries = vec![
3056            make_file_entry("alpha.tar.gz"),
3057            make_file_entry("beta.rs"),
3058            make_file_entry("gamma.tar.gz"),
3059            make_file_entry("noext"),
3060        ];
3061
3062        sort_entries_in_place(
3063            &mut entries,
3064            SortBy::Extension,
3065            true,
3066            SortMode::Natural,
3067            false,
3068            1,
3069        );
3070        let names: Vec<_> = entries.into_iter().map(|e| e.name).collect();
3071        assert_eq!(
3072            names,
3073            vec!["noext", "beta.rs", "alpha.tar.gz", "gamma.tar.gz"]
3074        );
3075    }
3076
3077    #[test]
3078    fn select_by_prefix_cycles_from_current_focus() {
3079        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3080        set_view_files(&mut core, &["alpha", "beta", "alpine"]);
3081        core.focused_id = Some(entry_id(&core, "alpha"));
3082
3083        core.select_by_prefix("al");
3084        assert_eq!(selected_entry_names(&core), vec!["alpine"]);
3085        assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "alpine")));
3086
3087        core.select_by_prefix("al");
3088        assert_eq!(selected_entry_names(&core), vec!["alpha"]);
3089        assert_eq!(core.focused_entry_id(), Some(entry_id(&core, "alpha")));
3090    }
3091
3092    #[test]
3093    fn save_prompts_overwrite_when_target_exists_and_policy_enabled() {
3094        let mut core = FileDialogCore::new(DialogMode::SaveFile);
3095        core.cwd = PathBuf::from("/tmp");
3096        core.save_name = "asset.png".into();
3097        core.save_policy.confirm_overwrite = true;
3098
3099        let mut fs = TestFs::default();
3100        fs.meta.insert(
3101            PathBuf::from("/tmp/asset.png"),
3102            crate::fs::FsMetadata {
3103                is_dir: false,
3104                is_symlink: false,
3105            },
3106        );
3107
3108        let gate = ConfirmGate::default();
3109        core.confirm(&fs, &gate, None).unwrap();
3110        assert!(core.take_result().is_none());
3111        assert!(core.pending_overwrite().is_some());
3112
3113        core.accept_overwrite();
3114        assert!(core.pending_overwrite().is_none());
3115        let sel = core.take_result().unwrap().unwrap();
3116        assert_eq!(sel.paths[0], PathBuf::from("/tmp/asset.png"));
3117    }
3118
3119    #[test]
3120    fn scan_hook_can_drop_entries_before_snapshot() {
3121        let fs = TestFs {
3122            entries: vec![
3123                crate::fs::FsEntry {
3124                    name: "keep.txt".into(),
3125                    path: PathBuf::from("/tmp/keep.txt"),
3126                    is_dir: false,
3127                    is_symlink: false,
3128                    size: Some(1),
3129                    modified: None,
3130                },
3131                crate::fs::FsEntry {
3132                    name: "drop.txt".into(),
3133                    path: PathBuf::from("/tmp/drop.txt"),
3134                    is_dir: false,
3135                    is_symlink: false,
3136                    size: Some(2),
3137                    modified: None,
3138                },
3139            ],
3140            ..Default::default()
3141        };
3142
3143        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
3144        core.cwd = PathBuf::from("/tmp");
3145        core.set_scan_hook(|entry| {
3146            if entry.name == "drop.txt" {
3147                ScanHookAction::Drop
3148            } else {
3149                ScanHookAction::Keep
3150            }
3151        });
3152
3153        core.rescan_if_needed(&fs);
3154
3155        let names: Vec<&str> = core
3156            .entries()
3157            .iter()
3158            .map(|entry| entry.name.as_str())
3159            .collect();
3160        assert_eq!(names, vec!["keep.txt"]);
3161        assert_eq!(core.dir_snapshot.entry_count, 1);
3162    }
3163
3164    #[test]
3165    fn scan_hook_can_mutate_entry_metadata() {
3166        let fs = TestFs {
3167            entries: vec![crate::fs::FsEntry {
3168                name: "a.txt".into(),
3169                path: PathBuf::from("/tmp/a.txt"),
3170                is_dir: false,
3171                is_symlink: false,
3172                size: Some(12),
3173                modified: Some(std::time::SystemTime::UNIX_EPOCH + Duration::from_secs(7)),
3174            }],
3175            ..Default::default()
3176        };
3177
3178        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3179        core.cwd = PathBuf::from("/tmp");
3180        core.set_scan_hook(|entry| {
3181            entry.name = "renamed.log".to_string();
3182            entry.path = PathBuf::from("/tmp/renamed.log");
3183            entry.size = Some(99);
3184            entry.modified = None;
3185            ScanHookAction::Keep
3186        });
3187
3188        core.rescan_if_needed(&fs);
3189
3190        let entry = core
3191            .entries()
3192            .iter()
3193            .find(|entry| entry.name == "renamed.log")
3194            .expect("mutated entry should exist");
3195        assert_eq!(entry.path, PathBuf::from("/tmp/renamed.log"));
3196        assert_eq!(entry.size, Some(99));
3197        assert_eq!(entry.modified, None);
3198    }
3199
3200    #[test]
3201    fn scan_hook_invalid_mutation_is_skipped_safely() {
3202        let fs = TestFs {
3203            entries: vec![crate::fs::FsEntry {
3204                name: "a.txt".into(),
3205                path: PathBuf::from("/tmp/a.txt"),
3206                is_dir: false,
3207                is_symlink: false,
3208                size: Some(12),
3209                modified: None,
3210            }],
3211            ..Default::default()
3212        };
3213
3214        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3215        core.cwd = PathBuf::from("/tmp");
3216        core.set_scan_hook(|entry| {
3217            entry.name.clear();
3218            entry.path = PathBuf::new();
3219            ScanHookAction::Keep
3220        });
3221
3222        core.rescan_if_needed(&fs);
3223
3224        assert!(core.entries().is_empty());
3225        assert_eq!(core.dir_snapshot.entry_count, 0);
3226    }
3227
3228    #[test]
3229    fn clear_scan_hook_restores_raw_listing() {
3230        let fs = TestFs {
3231            entries: vec![crate::fs::FsEntry {
3232                name: "a.txt".into(),
3233                path: PathBuf::from("/tmp/a.txt"),
3234                is_dir: false,
3235                is_symlink: false,
3236                size: Some(1),
3237                modified: None,
3238            }],
3239            ..Default::default()
3240        };
3241
3242        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3243        core.cwd = PathBuf::from("/tmp");
3244        core.set_scan_hook(|_| ScanHookAction::Drop);
3245        core.rescan_if_needed(&fs);
3246        assert!(core.entries().is_empty());
3247
3248        core.clear_scan_hook();
3249        core.rescan_if_needed(&fs);
3250        assert_eq!(core.entries().len(), 1);
3251        assert_eq!(core.entries()[0].name, "a.txt");
3252    }
3253
3254    #[test]
3255    fn scan_policy_normalizes_and_invalidates_cache() {
3256        let fs = TestFs::default();
3257        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3258        core.cwd = PathBuf::from("/tmp");
3259
3260        core.rescan_if_needed(&fs);
3261        assert_eq!(core.scan_generation(), 1);
3262        assert!(!core.dir_snapshot_dirty);
3263
3264        core.set_scan_policy(ScanPolicy::Incremental {
3265            batch_entries: 0,
3266            max_batches_per_tick: 0,
3267        });
3268        assert_eq!(
3269            core.scan_policy(),
3270            ScanPolicy::Incremental {
3271                batch_entries: 1,
3272                max_batches_per_tick: 1
3273            }
3274        );
3275        assert!(core.dir_snapshot_dirty);
3276
3277        core.rescan_if_needed(&fs);
3278        assert_eq!(core.scan_generation(), 2);
3279    }
3280
3281    #[test]
3282    fn scan_policy_tuned_preset_matches_expected_values() {
3283        assert_eq!(
3284            ScanPolicy::tuned_incremental(),
3285            ScanPolicy::Incremental {
3286                batch_entries: ScanPolicy::TUNED_BATCH_ENTRIES,
3287                max_batches_per_tick: ScanPolicy::TUNED_MAX_BATCHES_PER_TICK,
3288            }
3289        );
3290    }
3291
3292    #[test]
3293    fn incremental_scan_policy_applies_multiple_batches_per_tick() {
3294        let fs = TestFs {
3295            entries: vec![
3296                crate::fs::FsEntry {
3297                    name: "a.txt".into(),
3298                    path: PathBuf::from("/tmp/a.txt"),
3299                    is_dir: false,
3300                    is_symlink: false,
3301                    size: None,
3302                    modified: None,
3303                },
3304                crate::fs::FsEntry {
3305                    name: "b.txt".into(),
3306                    path: PathBuf::from("/tmp/b.txt"),
3307                    is_dir: false,
3308                    is_symlink: false,
3309                    size: None,
3310                    modified: None,
3311                },
3312                crate::fs::FsEntry {
3313                    name: "c.txt".into(),
3314                    path: PathBuf::from("/tmp/c.txt"),
3315                    is_dir: false,
3316                    is_symlink: false,
3317                    size: None,
3318                    modified: None,
3319                },
3320                crate::fs::FsEntry {
3321                    name: "d.txt".into(),
3322                    path: PathBuf::from("/tmp/d.txt"),
3323                    is_dir: false,
3324                    is_symlink: false,
3325                    size: None,
3326                    modified: None,
3327                },
3328            ],
3329            ..Default::default()
3330        };
3331
3332        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3333        core.cwd = PathBuf::from("/tmp");
3334        core.set_scan_policy(ScanPolicy::Incremental {
3335            batch_entries: 1,
3336            max_batches_per_tick: 2,
3337        });
3338
3339        core.rescan_if_needed(&fs);
3340        assert_eq!(
3341            core.scan_status(),
3342            &ScanStatus::Partial {
3343                generation: 1,
3344                loaded: 1,
3345            }
3346        );
3347        assert_eq!(core.entries().len(), 1);
3348
3349        core.rescan_if_needed(&fs);
3350        assert_eq!(
3351            core.scan_status(),
3352            &ScanStatus::Partial {
3353                generation: 1,
3354                loaded: 3,
3355            }
3356        );
3357        assert_eq!(core.entries().len(), 3);
3358
3359        core.rescan_if_needed(&fs);
3360        assert_eq!(
3361            core.scan_status(),
3362            &ScanStatus::Complete {
3363                generation: 1,
3364                loaded: 4,
3365            }
3366        );
3367        assert_eq!(core.entries().len(), 4);
3368    }
3369
3370    #[test]
3371    fn rescan_updates_generation_and_status() {
3372        let fs = TestFs {
3373            entries: vec![
3374                crate::fs::FsEntry {
3375                    name: "a.txt".into(),
3376                    path: PathBuf::from("/tmp/a.txt"),
3377                    is_dir: false,
3378                    is_symlink: false,
3379                    size: None,
3380                    modified: None,
3381                },
3382                crate::fs::FsEntry {
3383                    name: "b.txt".into(),
3384                    path: PathBuf::from("/tmp/b.txt"),
3385                    is_dir: false,
3386                    is_symlink: false,
3387                    size: None,
3388                    modified: None,
3389                },
3390            ],
3391            ..Default::default()
3392        };
3393
3394        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3395        core.cwd = PathBuf::from("/tmp");
3396        core.rescan_if_needed(&fs);
3397
3398        assert_eq!(core.scan_generation(), 1);
3399        assert_eq!(
3400            core.scan_status(),
3401            &ScanStatus::Complete {
3402                generation: 1,
3403                loaded: 2,
3404            }
3405        );
3406    }
3407
3408    #[test]
3409    fn incremental_scan_policy_emits_partial_batches_across_ticks() {
3410        let fs = TestFs {
3411            entries: vec![
3412                crate::fs::FsEntry {
3413                    name: "a.txt".into(),
3414                    path: PathBuf::from("/tmp/a.txt"),
3415                    is_dir: false,
3416                    is_symlink: false,
3417                    size: None,
3418                    modified: None,
3419                },
3420                crate::fs::FsEntry {
3421                    name: "b.txt".into(),
3422                    path: PathBuf::from("/tmp/b.txt"),
3423                    is_dir: false,
3424                    is_symlink: false,
3425                    size: None,
3426                    modified: None,
3427                },
3428                crate::fs::FsEntry {
3429                    name: "c.txt".into(),
3430                    path: PathBuf::from("/tmp/c.txt"),
3431                    is_dir: false,
3432                    is_symlink: false,
3433                    size: None,
3434                    modified: None,
3435                },
3436            ],
3437            ..Default::default()
3438        };
3439
3440        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3441        core.cwd = PathBuf::from("/tmp");
3442        core.set_scan_policy(ScanPolicy::Incremental {
3443            batch_entries: 2,
3444            max_batches_per_tick: 1,
3445        });
3446
3447        core.rescan_if_needed(&fs);
3448        assert_eq!(core.scan_generation(), 1);
3449        assert_eq!(core.scan_status(), &ScanStatus::Scanning { generation: 1 });
3450        assert!(core.entries().is_empty());
3451
3452        core.rescan_if_needed(&fs);
3453        assert_eq!(
3454            core.scan_status(),
3455            &ScanStatus::Partial {
3456                generation: 1,
3457                loaded: 2,
3458            }
3459        );
3460        assert_eq!(core.entries().len(), 2);
3461
3462        core.rescan_if_needed(&fs);
3463        assert_eq!(
3464            core.scan_status(),
3465            &ScanStatus::Partial {
3466                generation: 1,
3467                loaded: 3,
3468            }
3469        );
3470        assert_eq!(core.entries().len(), 3);
3471
3472        core.rescan_if_needed(&fs);
3473        assert_eq!(
3474            core.scan_status(),
3475            &ScanStatus::Complete {
3476                generation: 1,
3477                loaded: 3,
3478            }
3479        );
3480        assert_eq!(core.entries().len(), 3);
3481        assert_eq!(fs.read_dir_calls.get(), 1);
3482    }
3483
3484    #[test]
3485    fn incremental_scan_supersedes_pending_generation_batches() {
3486        let fs_old = TestFs {
3487            entries: vec![
3488                crate::fs::FsEntry {
3489                    name: "old-a.txt".into(),
3490                    path: PathBuf::from("/tmp/old-a.txt"),
3491                    is_dir: false,
3492                    is_symlink: false,
3493                    size: None,
3494                    modified: None,
3495                },
3496                crate::fs::FsEntry {
3497                    name: "old-b.txt".into(),
3498                    path: PathBuf::from("/tmp/old-b.txt"),
3499                    is_dir: false,
3500                    is_symlink: false,
3501                    size: None,
3502                    modified: None,
3503                },
3504            ],
3505            ..Default::default()
3506        };
3507        let fs_new = TestFs {
3508            entries: vec![crate::fs::FsEntry {
3509                name: "new.txt".into(),
3510                path: PathBuf::from("/tmp/new.txt"),
3511                is_dir: false,
3512                is_symlink: false,
3513                size: None,
3514                modified: None,
3515            }],
3516            ..Default::default()
3517        };
3518
3519        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3520        core.cwd = PathBuf::from("/tmp");
3521        core.set_scan_policy(ScanPolicy::Incremental {
3522            batch_entries: 1,
3523            max_batches_per_tick: 1,
3524        });
3525
3526        core.rescan_if_needed(&fs_old);
3527        core.rescan_if_needed(&fs_old);
3528        assert_eq!(core.scan_generation(), 1);
3529        assert_eq!(
3530            core.scan_status(),
3531            &ScanStatus::Partial {
3532                generation: 1,
3533                loaded: 1,
3534            }
3535        );
3536
3537        core.request_rescan();
3538        core.rescan_if_needed(&fs_new);
3539        assert_eq!(core.scan_generation(), 2);
3540        assert_eq!(core.scan_status(), &ScanStatus::Scanning { generation: 2 });
3541
3542        core.rescan_if_needed(&fs_new);
3543        core.rescan_if_needed(&fs_new);
3544
3545        assert_eq!(
3546            core.scan_status(),
3547            &ScanStatus::Complete {
3548                generation: 2,
3549                loaded: 1,
3550            }
3551        );
3552        let names: Vec<&str> = core
3553            .entries()
3554            .iter()
3555            .map(|entry| entry.name.as_str())
3556            .collect();
3557        assert_eq!(names, vec!["new.txt"]);
3558        assert_eq!(fs_old.read_dir_calls.get(), 1);
3559        assert_eq!(fs_new.read_dir_calls.get(), 1);
3560    }
3561
3562    #[test]
3563    fn incremental_scan_keeps_unresolved_selection_until_entry_arrives() {
3564        let fs = TestFs {
3565            entries: vec![
3566                crate::fs::FsEntry {
3567                    name: "a.txt".into(),
3568                    path: PathBuf::from("/tmp/a.txt"),
3569                    is_dir: false,
3570                    is_symlink: false,
3571                    size: None,
3572                    modified: None,
3573                },
3574                crate::fs::FsEntry {
3575                    name: "b.txt".into(),
3576                    path: PathBuf::from("/tmp/b.txt"),
3577                    is_dir: false,
3578                    is_symlink: false,
3579                    size: None,
3580                    modified: None,
3581                },
3582                crate::fs::FsEntry {
3583                    name: "c.txt".into(),
3584                    path: PathBuf::from("/tmp/c.txt"),
3585                    is_dir: false,
3586                    is_symlink: false,
3587                    size: None,
3588                    modified: None,
3589                },
3590            ],
3591            ..Default::default()
3592        };
3593
3594        let delayed = entry_id_from_path(Path::new("/tmp/c.txt"), false, false);
3595        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
3596        core.cwd = PathBuf::from("/tmp");
3597        core.set_scan_policy(ScanPolicy::Incremental {
3598            batch_entries: 1,
3599            max_batches_per_tick: 1,
3600        });
3601        core.focus_and_select_by_id(delayed);
3602
3603        core.rescan_if_needed(&fs);
3604        core.rescan_if_needed(&fs);
3605        assert_eq!(core.selected_entry_ids(), vec![delayed]);
3606        assert!(core.selected_entry_paths().is_empty());
3607
3608        core.rescan_if_needed(&fs);
3609        assert_eq!(core.selected_entry_ids(), vec![delayed]);
3610        assert!(core.selected_entry_paths().is_empty());
3611
3612        core.rescan_if_needed(&fs);
3613        assert_eq!(core.selected_entry_ids(), vec![delayed]);
3614        assert_eq!(
3615            core.selected_entry_paths(),
3616            vec![PathBuf::from("/tmp/c.txt")]
3617        );
3618
3619        core.rescan_if_needed(&fs);
3620        assert_eq!(
3621            core.scan_status(),
3622            &ScanStatus::Complete {
3623                generation: 1,
3624                loaded: 3,
3625            }
3626        );
3627        assert_eq!(core.selected_entry_ids(), vec![delayed]);
3628    }
3629
3630    #[test]
3631    fn complete_scan_drops_missing_selection_ids() {
3632        let fs = TestFs {
3633            entries: vec![crate::fs::FsEntry {
3634                name: "a.txt".into(),
3635                path: PathBuf::from("/tmp/a.txt"),
3636                is_dir: false,
3637                is_symlink: false,
3638                size: None,
3639                modified: None,
3640            }],
3641            ..Default::default()
3642        };
3643
3644        let missing = entry_id_from_path(Path::new("/tmp/missing.txt"), false, false);
3645        let mut core = FileDialogCore::new(DialogMode::OpenFiles);
3646        core.cwd = PathBuf::from("/tmp");
3647        core.set_scan_policy(ScanPolicy::Incremental {
3648            batch_entries: 1,
3649            max_batches_per_tick: 1,
3650        });
3651        core.focus_and_select_by_id(missing);
3652
3653        core.rescan_if_needed(&fs);
3654        core.rescan_if_needed(&fs);
3655        assert_eq!(core.selected_entry_ids(), vec![missing]);
3656
3657        core.rescan_if_needed(&fs);
3658        assert_eq!(
3659            core.scan_status(),
3660            &ScanStatus::Complete {
3661                generation: 1,
3662                loaded: 1,
3663            }
3664        );
3665        assert!(core.selected_entry_ids().is_empty());
3666        assert_eq!(core.focused_entry_id(), None);
3667    }
3668
3669    #[test]
3670    #[ignore = "perf-baseline"]
3671    fn perf_baseline_large_directory_scan_profiles() {
3672        for &entry_count in &[10_000usize, 50_000usize] {
3673            let entries = make_synthetic_fs_entries(entry_count);
3674
3675            let fs_sync = TestFs {
3676                entries: entries.clone(),
3677                ..Default::default()
3678            };
3679            let mut core_sync = FileDialogCore::new(DialogMode::OpenFile);
3680            core_sync.cwd = PathBuf::from("/tmp");
3681            let sync_started_at = Instant::now();
3682            core_sync.rescan_if_needed(&fs_sync);
3683            let sync_elapsed = sync_started_at.elapsed();
3684            assert_eq!(
3685                core_sync.scan_status(),
3686                &ScanStatus::Complete {
3687                    generation: 1,
3688                    loaded: entry_count,
3689                }
3690            );
3691            assert_eq!(core_sync.entries().len(), entry_count);
3692
3693            let fs_incremental = TestFs {
3694                entries,
3695                ..Default::default()
3696            };
3697            let mut core_incremental = FileDialogCore::new(DialogMode::OpenFile);
3698            core_incremental.cwd = PathBuf::from("/tmp");
3699            core_incremental.set_scan_policy(ScanPolicy::Incremental {
3700                batch_entries: 512,
3701                max_batches_per_tick: 1,
3702            });
3703
3704            let incremental_started_at = Instant::now();
3705            let mut ticks = 0usize;
3706            loop {
3707                core_incremental.rescan_if_needed(&fs_incremental);
3708                ticks += 1;
3709
3710                match core_incremental.scan_status() {
3711                    ScanStatus::Complete { loaded, .. } => {
3712                        assert_eq!(*loaded, entry_count);
3713                        break;
3714                    }
3715                    ScanStatus::Failed { message, .. } => {
3716                        panic!("incremental perf baseline failed: {message}");
3717                    }
3718                    _ => {}
3719                }
3720
3721                assert!(
3722                    ticks <= (entry_count / 128) + 128,
3723                    "incremental ticks exceeded bound: entry_count={entry_count}, ticks={ticks}"
3724                );
3725            }
3726
3727            let incremental_elapsed = incremental_started_at.elapsed();
3728            assert_eq!(core_incremental.entries().len(), entry_count);
3729
3730            eprintln!(
3731                "PERF_BASELINE entry_count={} sync_ms={} incremental_ms={} incremental_ticks={} batch_entries=512",
3732                entry_count,
3733                sync_elapsed.as_millis(),
3734                incremental_elapsed.as_millis(),
3735                ticks,
3736            );
3737        }
3738    }
3739
3740    #[test]
3741    #[ignore = "perf-baseline"]
3742    fn perf_baseline_incremental_budget_sweep() {
3743        let entry_count = 50_000usize;
3744        let base_entries = make_synthetic_fs_entries(entry_count);
3745
3746        for &max_batches_per_tick in &[1usize, 2usize, 4usize] {
3747            let fs_incremental = TestFs {
3748                entries: base_entries.clone(),
3749                ..Default::default()
3750            };
3751            let mut core_incremental = FileDialogCore::new(DialogMode::OpenFile);
3752            core_incremental.cwd = PathBuf::from("/tmp");
3753            core_incremental.set_scan_policy(ScanPolicy::Incremental {
3754                batch_entries: 512,
3755                max_batches_per_tick,
3756            });
3757
3758            let incremental_started_at = Instant::now();
3759            let mut ticks = 0usize;
3760            loop {
3761                core_incremental.rescan_if_needed(&fs_incremental);
3762                ticks += 1;
3763
3764                match core_incremental.scan_status() {
3765                    ScanStatus::Complete { loaded, .. } => {
3766                        assert_eq!(*loaded, entry_count);
3767                        break;
3768                    }
3769                    ScanStatus::Failed { message, .. } => {
3770                        panic!("incremental budget sweep failed: {message}");
3771                    }
3772                    _ => {}
3773                }
3774
3775                assert!(
3776                    ticks <= (entry_count / 128) + 128,
3777                    "incremental ticks exceeded bound: entry_count={entry_count}, ticks={ticks}"
3778                );
3779            }
3780
3781            let incremental_elapsed = incremental_started_at.elapsed();
3782            assert_eq!(core_incremental.entries().len(), entry_count);
3783
3784            eprintln!(
3785                "PERF_SWEEP entry_count={} incremental_ms={} incremental_ticks={} batch_entries=512 max_batches_per_tick={}",
3786                entry_count,
3787                incremental_elapsed.as_millis(),
3788                ticks,
3789                max_batches_per_tick,
3790            );
3791        }
3792    }
3793
3794    #[test]
3795    fn stale_scan_batch_is_ignored() {
3796        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3797        core.scan_generation = 3;
3798        core.scan_status = ScanStatus::Scanning { generation: 3 };
3799
3800        core.apply_scan_batch(ScanBatch::error(2, "stale".to_string()));
3801
3802        assert_eq!(core.scan_status, ScanStatus::Scanning { generation: 3 });
3803    }
3804
3805    #[test]
3806    fn read_dir_failure_sets_failed_scan_status() {
3807        let fs = TestFs {
3808            read_dir_error: Some(std::io::ErrorKind::PermissionDenied),
3809            ..Default::default()
3810        };
3811
3812        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3813        core.cwd = PathBuf::from("/tmp");
3814        core.rescan_if_needed(&fs);
3815
3816        assert_eq!(core.scan_generation(), 1);
3817        match core.scan_status() {
3818            ScanStatus::Failed {
3819                generation,
3820                message,
3821            } => {
3822                assert_eq!(*generation, 1);
3823                assert!(message.contains("read_dir failure"));
3824            }
3825            other => panic!("unexpected scan status: {other:?}"),
3826        }
3827        assert!(core.entries().is_empty());
3828        assert_eq!(core.dir_snapshot.cwd, PathBuf::from("/tmp"));
3829        assert_eq!(core.dir_snapshot.entry_count, 0);
3830    }
3831
3832    #[test]
3833    fn rescan_if_needed_caches_directory_listing() {
3834        let fs = TestFs {
3835            entries: vec![
3836                crate::fs::FsEntry {
3837                    name: "a.txt".into(),
3838                    path: PathBuf::from("/tmp/a.txt"),
3839                    is_dir: false,
3840                    is_symlink: false,
3841                    size: None,
3842                    modified: None,
3843                },
3844                crate::fs::FsEntry {
3845                    name: "b.txt".into(),
3846                    path: PathBuf::from("/tmp/b.txt"),
3847                    is_dir: false,
3848                    is_symlink: false,
3849                    size: None,
3850                    modified: None,
3851                },
3852                crate::fs::FsEntry {
3853                    name: ".hidden".into(),
3854                    path: PathBuf::from("/tmp/.hidden"),
3855                    is_dir: false,
3856                    is_symlink: false,
3857                    size: None,
3858                    modified: None,
3859                },
3860            ],
3861            ..Default::default()
3862        };
3863
3864        let mut core = FileDialogCore::new(DialogMode::OpenFile);
3865        core.cwd = PathBuf::from("/tmp");
3866
3867        core.rescan_if_needed(&fs);
3868        assert_eq!(fs.read_dir_calls.get(), 1);
3869        assert!(core.entries().iter().all(|e| e.name != ".hidden"));
3870
3871        // Same key => no rescan, no fs hit.
3872        core.rescan_if_needed(&fs);
3873        assert_eq!(fs.read_dir_calls.get(), 1);
3874
3875        // View-only changes should rebuild without hitting fs again.
3876        core.search = "b".into();
3877        core.rescan_if_needed(&fs);
3878        assert_eq!(fs.read_dir_calls.get(), 1);
3879        assert_eq!(core.entries().len(), 1);
3880        assert_eq!(core.entries()[0].name, "b.txt");
3881
3882        core.search.clear();
3883        core.show_hidden = true;
3884        core.rescan_if_needed(&fs);
3885        assert_eq!(fs.read_dir_calls.get(), 1);
3886        assert!(core.entries().iter().any(|e| e.name == ".hidden"));
3887
3888        // Explicit refresh should hit fs again even if the view inputs didn't change.
3889        core.invalidate_dir_cache();
3890        core.rescan_if_needed(&fs);
3891        assert_eq!(fs.read_dir_calls.get(), 2);
3892
3893        // Changing cwd should refresh snapshot.
3894        core.set_cwd(PathBuf::from("/other"));
3895        core.rescan_if_needed(&fs);
3896        assert_eq!(fs.read_dir_calls.get(), 3);
3897    }
3898}