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