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