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